pi-pignon 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +55 -17
- package/docs/CONFIGURATION.md +28 -5
- package/docs/DESIGN.md +2 -0
- package/package.json +8 -6
- package/schema/config.schema.json +2 -2
- package/src/config/schema.ts +2 -2
- package/src/credentials.ts +101 -0
- package/src/deciders/create.ts +12 -4
- package/src/deciders/jev.ts +41 -8
- package/src/extension.ts +20 -4
- package/src/login.ts +76 -0
- package/src/onboarding.ts +4 -2
- package/docs/PLAN-command-output.md +0 -161
- package/docs/PLAN-deciders.md +0 -327
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.1.4 — 2026-09-24
|
|
6
|
+
|
|
7
|
+
- `/pignon login` saves the Jev (TypeSafe) API key, so you no longer have to
|
|
8
|
+
export `TYPESAFE_API_KEY`. It can store a command that prints the key
|
|
9
|
+
(Keychain, 1Password, …), so the key never touches the disk, or the key
|
|
10
|
+
itself in `~/.pi/agent/pignon/credentials.json` (0600; refused when others
|
|
11
|
+
can read it). The environment variable still wins. `/pignon logout` removes
|
|
12
|
+
the key. `/pignon init` and the no-config default pick Jev when a key was
|
|
13
|
+
saved.
|
|
14
|
+
- README: a "Choosing a decider" comparison (latency, cost, privacy, setup),
|
|
15
|
+
setup steps in the order they run in Pi, and what `/pignon doctor` reports.
|
|
16
|
+
The configuration reference explains where Jev's key is looked up and how to
|
|
17
|
+
reach Jev through OpenRouter.
|
|
18
|
+
- The internal `docs/PLAN-*.md` notes are no longer shipped in the package.
|
|
19
|
+
|
|
20
|
+
## 0.1.3 — 2026-09-24
|
|
21
|
+
|
|
22
|
+
- No changes from 0.1.2: the same documentation, released from `main`.
|
|
23
|
+
|
|
24
|
+
## 0.1.2 — 2026-09-24
|
|
25
|
+
|
|
26
|
+
- The repository moved to `siiick/pi-pignon`, after the npm package. Links and
|
|
27
|
+
the config's `$schema` URL point there (the old URLs redirect).
|
|
28
|
+
- The README is shorter: the configuration reference moved to
|
|
29
|
+
`docs/CONFIGURATION.md` and the design notes to `docs/DESIGN.md`, both
|
|
30
|
+
shipped in the package.
|
|
31
|
+
|
|
3
32
|
## 0.1.1 — 2026-09-23
|
|
4
33
|
|
|
5
34
|
- Published on npm: `pi install npm:pi-pignon`.
|
package/README.md
CHANGED
|
@@ -38,12 +38,25 @@ pi install npm:pi-pignon
|
|
|
38
38
|
```
|
|
39
39
|
|
|
40
40
|
`pi update --extensions` keeps it up to date. To pin a version:
|
|
41
|
-
`pi install npm:pi-pignon@0.1.
|
|
41
|
+
`pi install npm:pi-pignon@0.1.4`. To try unreleased changes:
|
|
42
42
|
`pi install git:github.com/siiick/pi-pignon`.
|
|
43
43
|
|
|
44
|
-
###
|
|
44
|
+
### Choosing a decider
|
|
45
45
|
|
|
46
|
-
pignon needs a local **Laya server**,
|
|
46
|
+
pignon needs a decision model: a local **Laya server**, **TypeSafe's Jev**, or both.
|
|
47
|
+
|
|
48
|
+
| | Laya (`laya-serve`) | Jev |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| Runs | On your machine (NVIDIA GPU, Apple Silicon or CPU) | TypeSafe's API |
|
|
51
|
+
| Latency | ~75 ms on Apple Silicon | ~70–500 ms |
|
|
52
|
+
| Cost | Free | Paid per decision; shown on each card and in `/pignon-stats` |
|
|
53
|
+
| Privacy | Prompts stay on your machine | The first 4 000 characters of each routed prompt are sent to TypeSafe |
|
|
54
|
+
| Setup | Install and run a server | An API key |
|
|
55
|
+
|
|
56
|
+
**Both:** Laya first, and Jev only when Laya is down or unsure, with the
|
|
57
|
+
[`sequential` strategy](docs/CONFIGURATION.md#using-several-deciders).
|
|
58
|
+
|
|
59
|
+
### 1. Start Laya, or save a Jev key
|
|
47
60
|
|
|
48
61
|
**Local: Laya with `laya-serve`**
|
|
49
62
|
|
|
@@ -55,35 +68,56 @@ LAYA_HOST=127.0.0.1 laya-serve # http://127.0.0.1:8000
|
|
|
55
68
|
- Always set `LAYA_HOST=127.0.0.1` (default listens on all interfaces)
|
|
56
69
|
- Loads the best available device (NVIDIA GPU → Apple Silicon → CPU)
|
|
57
70
|
- First start downloads checkpoints and may take a while; later starts take 2–3 s
|
|
58
|
-
- While loading or down, prompts are **not routed
|
|
71
|
+
- While loading or down, prompts are **not routed**: they keep the current model
|
|
72
|
+
- To start it at login on macOS, see [the launchd recipe](docs/CONFIGURATION.md#start-laya-serve-at-login-macos)
|
|
59
73
|
|
|
60
74
|
**Remote: Jev**
|
|
61
75
|
|
|
62
|
-
Get a key from [TypeSafe](https://typesafe.ai) and
|
|
76
|
+
Get a key from [TypeSafe](https://typesafe.ai), start `pi`, and run:
|
|
63
77
|
|
|
64
78
|
```bash
|
|
65
|
-
|
|
79
|
+
/pignon login
|
|
66
80
|
```
|
|
67
81
|
|
|
68
|
-
|
|
82
|
+
Pick where the key comes from:
|
|
69
83
|
|
|
70
|
-
|
|
84
|
+
- **A command** that prints it, e.g. `security find-generic-password -ws typesafe`
|
|
85
|
+
(macOS Keychain) or `op read op://Private/TypeSafe/credential` (1Password).
|
|
86
|
+
pignon runs it once per session; the key is never written to disk. Recommended.
|
|
87
|
+
- **Paste the key.** It is saved in `~/.pi/agent/pignon/credentials.json`, which
|
|
88
|
+
only you can read. pignon refuses the file if other users can read it.
|
|
71
89
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
90
|
+
`/pignon logout` removes the saved key. In CI, or if you prefer, export
|
|
91
|
+
`TYPESAFE_API_KEY` before starting Pi instead; it takes precedence over a saved
|
|
92
|
+
key. To reach Jev through OpenRouter, see
|
|
93
|
+
[the configuration reference](docs/CONFIGURATION.md#jev-through-openrouter).
|
|
94
|
+
|
|
95
|
+
### 2. Initialize and check
|
|
75
96
|
|
|
76
|
-
|
|
97
|
+
In Pi:
|
|
77
98
|
|
|
78
99
|
```bash
|
|
79
|
-
/pignon init # writes ~/.pi/agent/pignon.json
|
|
80
|
-
/
|
|
81
|
-
/pignon
|
|
100
|
+
/pignon init # writes ~/.pi/agent/pignon.json with the deciders it finds
|
|
101
|
+
/reload # loads the config (and a key saved with /pignon login)
|
|
102
|
+
/pignon doctor # checks config, deciders and models, with one test decision
|
|
82
103
|
```
|
|
83
104
|
|
|
84
105
|
`/pignon init anthropic` (or `openai`, `openrouter`) picks a preset explicitly.
|
|
85
106
|
`init` never overwrites an existing file.
|
|
86
107
|
|
|
108
|
+
`/pignon doctor` says what is wrong with a decider: laya-serve not running, no
|
|
109
|
+
Jev key, a key rejected by the API, a key command that fails, or a credentials
|
|
110
|
+
file others can read.
|
|
111
|
+
|
|
112
|
+
### 3. Go live
|
|
113
|
+
|
|
114
|
+
pignon starts in shadow mode: it shows what it would do on each prompt without
|
|
115
|
+
switching models. When the decisions look right:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
/pignon live
|
|
119
|
+
```
|
|
120
|
+
|
|
87
121
|
## Commands
|
|
88
122
|
|
|
89
123
|
| Command | Description |
|
|
@@ -98,6 +132,7 @@ pi # or /reload
|
|
|
98
132
|
| `/pignon config migrate` | Convert a laya-router config to pignon format |
|
|
99
133
|
| `/pignon init [preset]` | Write a starter `pignon.json` |
|
|
100
134
|
| `/pignon doctor` | Check config, deciders and models |
|
|
135
|
+
| `/pignon login` / `logout` | Save or remove the Jev API key |
|
|
101
136
|
| `/pignon-stats` | Show tier × form × confidence histogram |
|
|
102
137
|
| `/pignon-stats compare` | Compare two deciders side-by-side |
|
|
103
138
|
| `/pignon-stats export [path]` | Export decisions as JSON lines |
|
|
@@ -154,7 +189,7 @@ Run `/pignon config` to see the resolved table currently in use.
|
|
|
154
189
|
| Variable | Default | Description |
|
|
155
190
|
|----------|---------|-------------|
|
|
156
191
|
| `PIGNON_CONFIG` | `<Pi config dir>/pignon.json` | Config file path |
|
|
157
|
-
| `TYPESAFE_API_KEY` | *(unset)* | Jev API key |
|
|
192
|
+
| `TYPESAFE_API_KEY` | *(unset)* | Jev API key (instead of `/pignon login`) |
|
|
158
193
|
| `TYPESAFE_BASE_URL` | `https://api.typesafe.ai` | Jev API root |
|
|
159
194
|
| `LAYA_HOST`, `LAYA_PORT`, `LAYA_MODELS` | *(varies)* | `laya-serve` startup options |
|
|
160
195
|
|
|
@@ -165,6 +200,9 @@ Full list: [docs/CONFIGURATION.md](docs/CONFIGURATION.md#environment-variables).
|
|
|
165
200
|
- **Local prompts stay local.** With `laya-serve` on this machine, prompts never
|
|
166
201
|
leave it. Jev (or remote laya-serve) receives the first 4 000 characters;
|
|
167
202
|
cards are marked `☁`.
|
|
203
|
+
- **Keys stay out of the config.** The Jev key comes from `TYPESAFE_API_KEY`
|
|
204
|
+
or `/pignon login`, never from `pignon.json`, and a saved key is only sent
|
|
205
|
+
to TypeSafe.
|
|
168
206
|
- **Fail-open.** If a decider is unreachable or fails, the prompt is not routed
|
|
169
207
|
and keeps the current model (a few milliseconds of delay).
|
|
170
208
|
- **Switch cost.** Changing models discards the prompt cache. Downgrades must
|
|
@@ -187,7 +225,7 @@ pi install ./ # load the clone in place
|
|
|
187
225
|
npm run typecheck # Type check
|
|
188
226
|
npm test # Unit tests
|
|
189
227
|
npm run test:worker # Python worker tests
|
|
190
|
-
npm run test:live # Real decider calls (needs
|
|
228
|
+
npm run test:live # Real decider calls (needs TYPESAFE_API_KEY and/or laya-serve)
|
|
191
229
|
npm run schema # Regenerate JSON Schema
|
|
192
230
|
npm run check # typecheck + tests + worker tests
|
|
193
231
|
```
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -18,8 +18,8 @@ Run `/pignon config` to see the resolved table currently in use.
|
|
|
18
18
|
| Variable | Default | Description |
|
|
19
19
|
|----------|---------|-------------|
|
|
20
20
|
| `PIGNON_CONFIG` | `<Pi config dir>/pignon.json` | Path of the optional config file |
|
|
21
|
-
| `PI_CODING_AGENT_DIR` | `~/.pi/agent` | Pi's config directory; pignon keeps its config and
|
|
22
|
-
| `TYPESAFE_API_KEY` | *(unset)* | Jev API key (another variable can be named with `apiKeyEnv`) |
|
|
21
|
+
| `PI_CODING_AGENT_DIR` | `~/.pi/agent` | Pi's config directory; pignon keeps its config, exports and saved key (`pignon/credentials.json`) there |
|
|
22
|
+
| `TYPESAFE_API_KEY` | *(unset)* | Jev API key (another variable can be named with `apiKeyEnv`). Takes precedence over a key saved with `/pignon login` |
|
|
23
23
|
| `TYPESAFE_BASE_URL` | `https://api.typesafe.ai` | Jev API root, when `baseURL` is not set |
|
|
24
24
|
| `TYPESAFE_DEFAULT_MODEL` | `jev-latest` | Jev model, when `model` is not set |
|
|
25
25
|
| `LAYA_ROUTER_CONFIG` | `~/.pi/agent/laya-router.json` | **Legacy:** read only when there is no pignon config |
|
|
@@ -28,7 +28,7 @@ laya-serve reads its own `LAYA_*` variables (`LAYA_HOST`, `LAYA_PORT`, `LAYA_MOD
|
|
|
28
28
|
|
|
29
29
|
## Deciders
|
|
30
30
|
|
|
31
|
-
`deciders` picks the decision model. `/pignon init` writes it for you. Without it, pignon uses the experimental worker when installed, else Jev when `TYPESAFE_API_KEY` is set
|
|
31
|
+
`deciders` picks the decision model. `/pignon init` writes it for you. Without it, pignon uses the experimental worker when installed, else Jev when `TYPESAFE_API_KEY` is set or a key was saved with `/pignon login`; it does not look for laya-serve on its own.
|
|
32
32
|
|
|
33
33
|
```json
|
|
34
34
|
{
|
|
@@ -46,11 +46,34 @@ laya-serve reads its own `LAYA_*` variables (`LAYA_HOST`, `LAYA_PORT`, `LAYA_MOD
|
|
|
46
46
|
| | `timeoutMs` | `1500` | Timeout for one decision |
|
|
47
47
|
| `laya-local` | | | [Experimental worker](#experimental-pignons-mlx-worker), see its section |
|
|
48
48
|
| `jev` | `model` | `jev-latest` | Jev version to pin. Confidences are calibrated per version, so pinning keeps your thresholds valid |
|
|
49
|
-
| | `apiKeyEnv` | `TYPESAFE_API_KEY` | Environment variable holding the key. Keys are never read from the config file |
|
|
50
|
-
| | `baseURL` | TypeSafe |
|
|
49
|
+
| | `apiKeyEnv` | `TYPESAFE_API_KEY` | Environment variable holding the key. When it is unset, the key saved with `/pignon login` is used, but only when neither `apiKeyEnv` nor `baseURL` is set, so the TypeSafe key never goes elsewhere. Keys are never read from the config file |
|
|
50
|
+
| | `baseURL` | TypeSafe | Another API root, e.g. [OpenRouter](#jev-through-openrouter) |
|
|
51
51
|
| | `timeoutMs` | `1500` | Timeout for one decision |
|
|
52
52
|
| | `maxRetries` | `0` | Retries after a failed call; each gets the full timeout |
|
|
53
53
|
|
|
54
|
+
### Jev's API key
|
|
55
|
+
|
|
56
|
+
pignon looks for the key in this order:
|
|
57
|
+
|
|
58
|
+
1. the environment variable named by `apiKeyEnv` (`TYPESAFE_API_KEY` by default);
|
|
59
|
+
2. the key saved with `/pignon login`, in `<Pi config dir>/pignon/credentials.json`: either the key itself, or a `!command` that prints it (run once per session, e.g. to read the macOS Keychain or 1Password). The file must be readable by you only (`chmod 600`).
|
|
60
|
+
|
|
61
|
+
`/pignon login` and `/pignon logout` take effect after `/reload`. A saved key is only used for TypeSafe's own API: never with a `baseURL` or `apiKeyEnv` of your own, and never for laya-serve.
|
|
62
|
+
|
|
63
|
+
### Jev through OpenRouter
|
|
64
|
+
|
|
65
|
+
Jev is also reachable through OpenRouter. `/pignon login` does not apply there: export your OpenRouter key and name it in the config:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"deciders": [
|
|
70
|
+
{ "type": "jev", "baseURL": "https://openrouter.ai/api", "apiKeyEnv": "OPENROUTER_API_KEY" }
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Run `/pignon doctor` to check that it answers.
|
|
76
|
+
|
|
54
77
|
### Using several deciders
|
|
55
78
|
|
|
56
79
|
List more than one and `strategy` says how they work together:
|
package/docs/DESIGN.md
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
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`.
|
|
6
6
|
|
|
7
|
+
The Jev key is never read from `pignon.json`, which people share. `/pignon login` saves it in its own file, `<Pi config dir>/pignon/credentials.json`, not in Pi's `auth.json`, which Pi writes under a lock extensions cannot take. The file is written 0600 in a 0700 directory, through a temporary file and a rename, and refused when group or others can read it. It can hold a `!command` instead of the key (Keychain, 1Password…), so the key never touches the disk; the command runs once per session, and its stderr is never shown, since it could echo the key. The environment variable takes precedence, and a saved key is only given to a `jev` decider on TypeSafe's own endpoint, never to a custom `baseURL`, a custom `apiKeyEnv`, or laya-serve.
|
|
8
|
+
|
|
7
9
|
## Fail-open
|
|
8
10
|
|
|
9
11
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pignon",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Pi coding agent extension that shifts to the right LLM for each prompt, using a local (Laya) or remote (Jev) decision model to judge task difficulty",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -27,7 +27,9 @@
|
|
|
27
27
|
"src",
|
|
28
28
|
"schema",
|
|
29
29
|
"examples",
|
|
30
|
-
"docs",
|
|
30
|
+
"docs/CONFIGURATION.md",
|
|
31
|
+
"docs/DESIGN.md",
|
|
32
|
+
"docs/assets",
|
|
31
33
|
"CHANGELOG.md"
|
|
32
34
|
],
|
|
33
35
|
"engines": {
|
|
@@ -40,7 +42,7 @@
|
|
|
40
42
|
"clean": "rm -rf dist node_modules/.vitest",
|
|
41
43
|
"build": "echo 'nothing to build (TypeScript runs via jiti)'",
|
|
42
44
|
"check": "tsc --noEmit && vitest run && npm run test:worker",
|
|
43
|
-
"test:worker": "cd worker &&
|
|
45
|
+
"test:worker": "cd worker && ${PYTHON:-.venv/bin/python} -m unittest",
|
|
44
46
|
"schema": "UPDATE_SCHEMA=1 vitest run tests/schema.test.ts",
|
|
45
47
|
"test:live": "PIGNON_LIVE=1 vitest run tests/live",
|
|
46
48
|
"prepublishOnly": "npm run check"
|
|
@@ -61,9 +63,9 @@
|
|
|
61
63
|
"devDependencies": {
|
|
62
64
|
"@earendil-works/pi-coding-agent": "^0.87.1",
|
|
63
65
|
"@earendil-works/pi-tui": "^0.87.1",
|
|
64
|
-
"@types/node": "^
|
|
65
|
-
"typescript": "^
|
|
66
|
-
"vitest": "^
|
|
66
|
+
"@types/node": "^26.6.2",
|
|
67
|
+
"typescript": "^7.0.2",
|
|
68
|
+
"vitest": "^5.0.1",
|
|
67
69
|
"typebox": "^1.3.34"
|
|
68
70
|
}
|
|
69
71
|
}
|
|
@@ -109,7 +109,7 @@
|
|
|
109
109
|
"apiKeyEnv": {
|
|
110
110
|
"type": "string",
|
|
111
111
|
"minLength": 1,
|
|
112
|
-
"description": "Environment variable holding the API key. Default: TYPESAFE_API_KEY."
|
|
112
|
+
"description": "Environment variable holding the API key. Default: TYPESAFE_API_KEY, else the key saved with /pignon login (only when neither apiKeyEnv nor baseURL is set)."
|
|
113
113
|
},
|
|
114
114
|
"timeoutMs": {
|
|
115
115
|
"type": "number",
|
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
},
|
|
130
130
|
"minItems": 1,
|
|
131
131
|
"maxItems": 4,
|
|
132
|
-
"description": "Decision models, in the order to try them. Default: laya-local when its experimental worker is installed, else jev when TYPESAFE_API_KEY is set. /pignon init adds laya-serve when it is running."
|
|
132
|
+
"description": "Decision models, in the order to try them. Default: laya-local when its experimental worker is installed, else jev when TYPESAFE_API_KEY is set or a key was saved with /pignon login. /pignon init adds laya-serve when it is running."
|
|
133
133
|
},
|
|
134
134
|
"strategy": {
|
|
135
135
|
"type": "object",
|
package/src/config/schema.ts
CHANGED
|
@@ -147,7 +147,7 @@ export const JevDeciderSchema = Type.Object(
|
|
|
147
147
|
Type.String({ minLength: 1, description: "API root. `https://openrouter.ai/api` goes through OpenRouter. Default: TypeSafe." }),
|
|
148
148
|
),
|
|
149
149
|
apiKeyEnv: Type.Optional(
|
|
150
|
-
Type.String({ minLength: 1, description: "Environment variable holding the API key. Default: TYPESAFE_API_KEY." }),
|
|
150
|
+
Type.String({ minLength: 1, description: "Environment variable holding the API key. Default: TYPESAFE_API_KEY, else the key saved with /pignon login (only when neither apiKeyEnv nor baseURL is set)." }),
|
|
151
151
|
),
|
|
152
152
|
timeoutMs: timeoutMs("Timeout for one decision, in milliseconds. Default: 1500."),
|
|
153
153
|
maxRetries: Type.Optional(Type.Integer({ minimum: 0, maximum: 3, description: "Retries after a failed attempt. Default: 0." })),
|
|
@@ -165,7 +165,7 @@ export const DecidersSchema = Type.Array(Type.Union([LayaServeDeciderSchema, Lay
|
|
|
165
165
|
minItems: 1,
|
|
166
166
|
maxItems: 4,
|
|
167
167
|
description:
|
|
168
|
-
"Decision models, in the order to try them. Default: laya-local when its experimental worker is installed, else jev when TYPESAFE_API_KEY is set. /pignon init adds laya-serve when it is running.",
|
|
168
|
+
"Decision models, in the order to try them. Default: laya-local when its experimental worker is installed, else jev when TYPESAFE_API_KEY is set or a key was saved with /pignon login. /pignon init adds laya-serve when it is running.",
|
|
169
169
|
});
|
|
170
170
|
|
|
171
171
|
export const StrategySchema = Type.Object(
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API keys stored by `/pignon login`, for users who would rather not export an
|
|
3
|
+
* environment variable.
|
|
4
|
+
*
|
|
5
|
+
* They live in their own file, `<agent dir>/pignon/credentials.json`, never in
|
|
6
|
+
* pignon.json (which people share) nor in Pi's auth.json (which Pi writes
|
|
7
|
+
* under a lock pignon cannot take). The file is created 0600 in a 0700
|
|
8
|
+
* directory, and refused, like ssh does, when group or others can read it.
|
|
9
|
+
*
|
|
10
|
+
* A stored key is either the key itself or, like in Pi's auth.json, a command
|
|
11
|
+
* prefixed with `!` whose output is the key (`!security find-generic-password
|
|
12
|
+
* -ws typesafe`): with a password manager, the key never touches the disk.
|
|
13
|
+
*
|
|
14
|
+
* { "typesafe": { "key": "sk-..." } }
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { exec } from "node:child_process";
|
|
18
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { dirname, join } from "node:path";
|
|
20
|
+
|
|
21
|
+
import { agentDir } from "./config/load.js";
|
|
22
|
+
|
|
23
|
+
/** Name of the TypeSafe (Jev) key in the credentials file. */
|
|
24
|
+
export const TYPESAFE_CREDENTIAL = "typesafe";
|
|
25
|
+
|
|
26
|
+
/** A key command gets this long, e.g. to wait for a Keychain or 1Password prompt. */
|
|
27
|
+
const COMMAND_TIMEOUT_MS = 30_000;
|
|
28
|
+
|
|
29
|
+
export function credentialsPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
30
|
+
return join(agentDir(env), "pignon", "credentials.json");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type StoredKey =
|
|
34
|
+
| { kind: "missing" }
|
|
35
|
+
| { kind: "error"; error: string }
|
|
36
|
+
/** `value` is the key, or a command (starting with `!`) that prints it. */
|
|
37
|
+
| { kind: "ok"; value: string };
|
|
38
|
+
|
|
39
|
+
/** Read one stored key without resolving it. */
|
|
40
|
+
export function readStoredKey(name: string, path: string = credentialsPath()): StoredKey {
|
|
41
|
+
let text: string;
|
|
42
|
+
try {
|
|
43
|
+
const mode = statSync(path).mode;
|
|
44
|
+
if (process.platform !== "win32" && (mode & 0o077) !== 0) {
|
|
45
|
+
return { kind: "error", error: `${path} can be read by other users; run chmod 600 on it` };
|
|
46
|
+
}
|
|
47
|
+
text = readFileSync(path, "utf8");
|
|
48
|
+
} catch (err) {
|
|
49
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return { kind: "missing" };
|
|
50
|
+
return { kind: "error", error: `cannot read ${path}: ${(err as Error).message}` };
|
|
51
|
+
}
|
|
52
|
+
let entry: unknown;
|
|
53
|
+
try {
|
|
54
|
+
entry = (JSON.parse(text) as Record<string, unknown>)[name];
|
|
55
|
+
} catch {
|
|
56
|
+
return { kind: "error", error: `${path} is not valid JSON` };
|
|
57
|
+
}
|
|
58
|
+
if (entry === undefined) return { kind: "missing" };
|
|
59
|
+
const value = (entry as { key?: unknown } | null)?.key;
|
|
60
|
+
if (typeof value !== "string" || !value.trim()) return { kind: "error", error: `${path}: ${name}.key must be a non-empty string` };
|
|
61
|
+
return { kind: "ok", value: value.trim() };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Store (or, with `undefined`, remove) one key, leaving the others. */
|
|
65
|
+
export function writeStoredKey(name: string, value: string | undefined, path: string = credentialsPath()): void {
|
|
66
|
+
let data: Record<string, unknown> = {};
|
|
67
|
+
try {
|
|
68
|
+
data = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
|
71
|
+
}
|
|
72
|
+
if (value === undefined) delete data[name];
|
|
73
|
+
else data[name] = { key: value };
|
|
74
|
+
|
|
75
|
+
if (Object.keys(data).length === 0) {
|
|
76
|
+
rmSync(path, { force: true });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
80
|
+
// Written aside then renamed: a crash never leaves a half-written file.
|
|
81
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
82
|
+
writeFileSync(temp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
83
|
+
chmodSync(temp, 0o600);
|
|
84
|
+
renameSync(temp, path);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The key a stored value stands for: the value itself, or what its `!command` prints. */
|
|
88
|
+
export async function resolveStoredKey(value: string): Promise<string> {
|
|
89
|
+
if (!value.startsWith("!")) return value;
|
|
90
|
+
const command = value.slice(1).trim();
|
|
91
|
+
const output = await new Promise<string>((resolve, reject) => {
|
|
92
|
+
exec(command, { timeout: COMMAND_TIMEOUT_MS, encoding: "utf8" }, (err, stdout) => {
|
|
93
|
+
// The command's stderr is not quoted: it could echo the key.
|
|
94
|
+
if (err) reject(new Error(`key command failed (${err.killed ? "timed out" : `exit ${err.code ?? "?"}`})`));
|
|
95
|
+
else resolve(stdout);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
const key = output.trim();
|
|
99
|
+
if (!key) throw new Error("key command printed nothing");
|
|
100
|
+
return key;
|
|
101
|
+
}
|
package/src/deciders/create.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* returns an `UnavailableDecider` whose warmup error says what to install.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { type StoredKey, TYPESAFE_CREDENTIAL, credentialsPath, readStoredKey } from "../credentials.js";
|
|
11
12
|
import type { DeciderSpec, RouterConfig } from "../types.js";
|
|
12
13
|
import { DEFAULT_API_KEY_ENV, JevDecider } from "./jev.js";
|
|
13
14
|
import { LayaWorker, layaRuntimeStatus } from "./laya-local.js";
|
|
@@ -26,10 +27,13 @@ export interface CreateDeciderDeps {
|
|
|
26
27
|
env?: NodeJS.ProcessEnv;
|
|
27
28
|
/** Whether the local worker can run here (tests replace the platform check). */
|
|
28
29
|
layaStatus?: typeof layaRuntimeStatus;
|
|
30
|
+
/** Reads a key saved by `/pignon login`. Defaults to the credentials file. */
|
|
31
|
+
storedKey?: (name: string) => StoredKey;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
export function createDecider(config: RouterConfig, deps: CreateDeciderDeps = {}): CreatedDecider {
|
|
32
35
|
const env = deps.env ?? process.env;
|
|
36
|
+
const storedKey = deps.storedKey ?? ((name: string) => readStoredKey(name, credentialsPath(env)));
|
|
33
37
|
const notes: string[] = [];
|
|
34
38
|
let specs = config.deciders;
|
|
35
39
|
|
|
@@ -37,19 +41,19 @@ export function createDecider(config: RouterConfig, deps: CreateDeciderDeps = {}
|
|
|
37
41
|
const laya = (deps.layaStatus ?? layaRuntimeStatus)(env);
|
|
38
42
|
if (laya.ok) {
|
|
39
43
|
specs = [{ type: "laya-local" }];
|
|
40
|
-
} else if (env[DEFAULT_API_KEY_ENV]?.trim()) {
|
|
44
|
+
} else if (env[DEFAULT_API_KEY_ENV]?.trim() || storedKey(TYPESAFE_CREDENTIAL).kind !== "missing") {
|
|
41
45
|
specs = [{ type: "jev" }];
|
|
42
46
|
} else {
|
|
43
47
|
return {
|
|
44
48
|
decider: new UnavailableDecider(
|
|
45
|
-
`no decider configured: start laya-serve (see pignon's README) and run /pignon init, or set ${DEFAULT_API_KEY_ENV} for Jev`,
|
|
49
|
+
`no decider configured: start laya-serve (see pignon's README) and run /pignon init, or run /pignon login (or set ${DEFAULT_API_KEY_ENV}) for Jev`,
|
|
46
50
|
),
|
|
47
51
|
notes,
|
|
48
52
|
};
|
|
49
53
|
}
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
const deciders = specs.map((spec) => build(spec, config, env));
|
|
56
|
+
const deciders = specs.map((spec) => build(spec, config, env, storedKey));
|
|
53
57
|
if (deciders.length === 1) return { decider: deciders[0]!, notes };
|
|
54
58
|
return {
|
|
55
59
|
decider: new StrategyDecider(deciders, config.strategy, (answers, latencyMs) => parseDecision(answers, latencyMs, config)),
|
|
@@ -57,7 +61,7 @@ export function createDecider(config: RouterConfig, deps: CreateDeciderDeps = {}
|
|
|
57
61
|
};
|
|
58
62
|
}
|
|
59
63
|
|
|
60
|
-
function build(spec: DeciderSpec, config: RouterConfig, env: NodeJS.ProcessEnv): Decider {
|
|
64
|
+
function build(spec: DeciderSpec, config: RouterConfig, env: NodeJS.ProcessEnv, storedKey: (name: string) => StoredKey): Decider {
|
|
61
65
|
switch (spec.type) {
|
|
62
66
|
case "laya-serve":
|
|
63
67
|
return createLayaServeDecider(spec, env);
|
|
@@ -69,6 +73,10 @@ function build(spec: DeciderSpec, config: RouterConfig, env: NodeJS.ProcessEnv):
|
|
|
69
73
|
case "jev":
|
|
70
74
|
return new JevDecider({
|
|
71
75
|
env,
|
|
76
|
+
// The stored key is TypeSafe's: only for TypeSafe's own endpoint and variable.
|
|
77
|
+
...(spec.apiKeyEnv === undefined && spec.baseURL === undefined
|
|
78
|
+
? { storedKey: () => storedKey(TYPESAFE_CREDENTIAL) }
|
|
79
|
+
: {}),
|
|
72
80
|
...(spec.model !== undefined ? { model: spec.model } : {}),
|
|
73
81
|
...(spec.baseURL !== undefined ? { baseURL: spec.baseURL } : {}),
|
|
74
82
|
...(spec.apiKeyEnv !== undefined ? { apiKeyEnv: spec.apiKeyEnv } : {}),
|
package/src/deciders/jev.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
TypeSafeError,
|
|
29
29
|
} from "@typesafe-ai/sdk";
|
|
30
30
|
|
|
31
|
+
import { type StoredKey, resolveStoredKey } from "../credentials.js";
|
|
31
32
|
import type { LayaQuestion } from "../types.js";
|
|
32
33
|
import { type Decider, type DeciderResult, type DecisionRequest, DeciderError } from "./types.js";
|
|
33
34
|
|
|
@@ -59,6 +60,12 @@ export interface JevDeciderOptions {
|
|
|
59
60
|
timeoutMs?: number;
|
|
60
61
|
/** Retries after a failed attempt. Each gets the full timeout. */
|
|
61
62
|
maxRetries?: number;
|
|
63
|
+
/**
|
|
64
|
+
* The key saved by `/pignon login`, used when the environment variable is
|
|
65
|
+
* unset. Only for TypeSafe itself: never set it with another `baseURL` or
|
|
66
|
+
* `apiKeyEnv`, which would send the TypeSafe key elsewhere.
|
|
67
|
+
*/
|
|
68
|
+
storedKey?: () => StoredKey;
|
|
62
69
|
/** Where to read the API key and SDK settings. Defaults to `process.env`. */
|
|
63
70
|
env?: NodeJS.ProcessEnv;
|
|
64
71
|
/** HTTP implementation (tests). */
|
|
@@ -78,6 +85,8 @@ export class JevDecider implements Decider {
|
|
|
78
85
|
private readonly timeoutMs: number;
|
|
79
86
|
private readonly logLines: string[] = [];
|
|
80
87
|
private client?: TypeSafeClient;
|
|
88
|
+
/** A stored key command runs once per session, failed or not: it may prompt the user. */
|
|
89
|
+
private storedKeyResolution?: Promise<string>;
|
|
81
90
|
private lastModel?: string;
|
|
82
91
|
private stopped = false;
|
|
83
92
|
|
|
@@ -89,9 +98,12 @@ export class JevDecider implements Decider {
|
|
|
89
98
|
this.timeoutMs = options.timeoutMs ?? DEFAULT_JEV_TIMEOUT_MS;
|
|
90
99
|
}
|
|
91
100
|
|
|
92
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* Ready as soon as an API key is available (or none is needed); there is
|
|
103
|
+
* nothing to load. A stored key is ready once `warmup` has resolved it.
|
|
104
|
+
*/
|
|
93
105
|
get isReady(): boolean {
|
|
94
|
-
return !this.stopped && (this.
|
|
106
|
+
return !this.stopped && (this.client !== undefined || this.envApiKey() !== undefined || this.options.requireApiKey === false);
|
|
95
107
|
}
|
|
96
108
|
|
|
97
109
|
/** The model that last answered, else the one requests will name. */
|
|
@@ -107,11 +119,11 @@ export class JevDecider implements Decider {
|
|
|
107
119
|
|
|
108
120
|
/** Check the API key and build the client. No network call. */
|
|
109
121
|
async warmup(): Promise<void> {
|
|
110
|
-
this.ensureClient();
|
|
122
|
+
await this.ensureClient();
|
|
111
123
|
}
|
|
112
124
|
|
|
113
125
|
async decide(request: DecisionRequest, signal?: AbortSignal): Promise<DeciderResult> {
|
|
114
|
-
const client = this.ensureClient();
|
|
126
|
+
const client = await this.ensureClient();
|
|
115
127
|
const started = Date.now();
|
|
116
128
|
try {
|
|
117
129
|
const result = await client.systemOne(
|
|
@@ -147,16 +159,37 @@ export class JevDecider implements Decider {
|
|
|
147
159
|
return this.options.env ?? process.env;
|
|
148
160
|
}
|
|
149
161
|
|
|
150
|
-
private
|
|
162
|
+
private envApiKey(): string | undefined {
|
|
151
163
|
const key = this.env()[this.apiKeyEnv]?.trim();
|
|
152
164
|
return key ? key : undefined;
|
|
153
165
|
}
|
|
154
166
|
|
|
155
|
-
|
|
167
|
+
/** The environment variable first (CI, existing setups), then the stored key. */
|
|
168
|
+
private async apiKey(): Promise<string | undefined> {
|
|
169
|
+
const fromEnv = this.envApiKey();
|
|
170
|
+
if (fromEnv || !this.options.storedKey) return fromEnv;
|
|
171
|
+
const stored = this.options.storedKey();
|
|
172
|
+
if (stored.kind === "missing") return undefined;
|
|
173
|
+
if (stored.kind === "error") throw new DeciderError(`${this.id}: ${stored.error}`);
|
|
174
|
+
this.storedKeyResolution ??= resolveStoredKey(stored.value);
|
|
175
|
+
try {
|
|
176
|
+
return await this.storedKeyResolution;
|
|
177
|
+
} catch (err) {
|
|
178
|
+
throw new DeciderError(`${this.id}: ${(err as Error).message}; fix it with /pignon login, then /reload`, err);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private async ensureClient(): Promise<TypeSafeClient> {
|
|
183
|
+
if (this.stopped) throw new DeciderError(`${this.id}: decider is stopped`);
|
|
184
|
+
if (this.client) return this.client;
|
|
185
|
+
const apiKey = (await this.apiKey()) ?? (this.options.requireApiKey === false ? NO_API_KEY : undefined);
|
|
186
|
+
if (!apiKey) {
|
|
187
|
+
const hint = this.options.storedKey ? ` (or run /pignon login)` : "";
|
|
188
|
+
throw new DeciderError(`${this.id}: ${this.apiKeyEnv} is not set${hint}`);
|
|
189
|
+
}
|
|
190
|
+
// The key may have taken a while to resolve.
|
|
156
191
|
if (this.stopped) throw new DeciderError(`${this.id}: decider is stopped`);
|
|
157
192
|
if (this.client) return this.client;
|
|
158
|
-
const apiKey = this.apiKey() ?? (this.options.requireApiKey === false ? NO_API_KEY : undefined);
|
|
159
|
-
if (!apiKey) throw new DeciderError(`${this.id}: ${this.apiKeyEnv} is not set`);
|
|
160
193
|
|
|
161
194
|
const env = this.env();
|
|
162
195
|
try {
|
package/src/extension.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pignon — Pi agent extension.
|
|
3
3
|
*
|
|
4
|
-
* Shifts to the right model for each prompt by asking a decider (
|
|
5
|
-
*
|
|
6
|
-
* hard the prompt is, then looking the answer up in the routing table.
|
|
4
|
+
* Shifts to the right model for each prompt by asking a decider (a local Laya
|
|
5
|
+
* model through laya-serve, TypeSafe's hosted Jev, or the experimental worker)
|
|
6
|
+
* how hard the prompt is, then looking the answer up in the routing table.
|
|
7
7
|
*
|
|
8
8
|
* /pignon -> show current mode
|
|
9
9
|
* /pignon live -> apply decisions
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* /pignon config migrate -> convert a laya-router config file
|
|
16
16
|
* /pignon init [preset] -> write a starter config file
|
|
17
17
|
* /pignon doctor -> check deciders, models and config
|
|
18
|
+
* /pignon login -> save the TypeSafe (Jev) API key
|
|
19
|
+
* /pignon logout -> remove it
|
|
18
20
|
* /pignon-stats -> session statistics
|
|
19
21
|
* /pignon-stats compare -> how two deciders agree (parallel strategy)
|
|
20
22
|
* /pignon-stats export [path] -> decisions as JSON lines
|
|
@@ -46,6 +48,7 @@ import { showReport } from "./report.js";
|
|
|
46
48
|
import { buildStatsLines } from "./stats.js";
|
|
47
49
|
import type { DeciderSpec, RouterConfig, RouterLogEntry, RouterMode } from "./types.js";
|
|
48
50
|
import { hideDeciding, renderDecisionCard, showDeciding } from "./ui.js";
|
|
51
|
+
import { login, logout } from "./login.js";
|
|
49
52
|
import {
|
|
50
53
|
type ModelLookup,
|
|
51
54
|
choosePreset,
|
|
@@ -75,6 +78,8 @@ const SUBCOMMANDS = [
|
|
|
75
78
|
"init",
|
|
76
79
|
...PRESET_NAMES.map((name) => `init ${name}`),
|
|
77
80
|
"doctor",
|
|
81
|
+
"login",
|
|
82
|
+
"logout",
|
|
78
83
|
];
|
|
79
84
|
|
|
80
85
|
type PiModel = Parameters<ExtensionAPI["setModel"]>[0];
|
|
@@ -337,6 +342,17 @@ export function createExtension(options: ExtensionOptions = {}): (pi: ExtensionA
|
|
|
337
342
|
await showReport(ctx, "pignon doctor", report.then(([, ...body]) => body));
|
|
338
343
|
return;
|
|
339
344
|
}
|
|
345
|
+
if (arg === "login") {
|
|
346
|
+
if (!ctx.hasUI) return;
|
|
347
|
+
const result = await login(ctx.ui);
|
|
348
|
+
if (result) notify(ctx, `pignon: ${result.message}`, result.level);
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (arg === "logout") {
|
|
352
|
+
const result = logout();
|
|
353
|
+
notify(ctx, `pignon: ${result.message}`, result.level);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
340
356
|
if (arg === "config migrate") {
|
|
341
357
|
if (!loaded.legacy) {
|
|
342
358
|
notify(ctx, "pignon: config is already in the pignon format");
|
|
@@ -411,7 +427,7 @@ export function createExtension(options: ExtensionOptions = {}): (pi: ExtensionA
|
|
|
411
427
|
SUBCOMMANDS.filter((v) => v.startsWith(prefix)).map((v) => ({ value: v, label: v }));
|
|
412
428
|
|
|
413
429
|
pi.registerCommand("pignon", {
|
|
414
|
-
description: "pignon mode (shadow | live | off | unpin | log | config)",
|
|
430
|
+
description: "pignon mode and setup (shadow | live | off | unpin | log | config | init | doctor | login | logout)",
|
|
415
431
|
getArgumentCompletions: completions,
|
|
416
432
|
handler: modeCommand,
|
|
417
433
|
});
|
package/src/login.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/pignon login` and `/pignon logout`: save or remove the TypeSafe (Jev) API
|
|
3
|
+
* key in pignon's credentials file (see `credentials.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Pi-free: the dialogs are reached through `LoginUI`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
TYPESAFE_CREDENTIAL,
|
|
10
|
+
credentialsPath,
|
|
11
|
+
readStoredKey,
|
|
12
|
+
resolveStoredKey,
|
|
13
|
+
writeStoredKey,
|
|
14
|
+
} from "./credentials.js";
|
|
15
|
+
import { DEFAULT_API_KEY_ENV } from "./deciders/jev.js";
|
|
16
|
+
|
|
17
|
+
/** The part of Pi's UI the login dialogs use. */
|
|
18
|
+
export interface LoginUI {
|
|
19
|
+
select(title: string, options: string[]): Promise<string | undefined>;
|
|
20
|
+
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type LoginResult = { level: "info" | "warning" | "error"; message: string };
|
|
24
|
+
|
|
25
|
+
const FROM_COMMAND = "Read it from a command (password manager, Keychain): the key is not written to disk";
|
|
26
|
+
const PASTE = "Paste the key: saved in pignon's credentials file (readable by you only)";
|
|
27
|
+
|
|
28
|
+
const EXAMPLE_COMMAND =
|
|
29
|
+
process.platform === "darwin" ? "security find-generic-password -ws typesafe" : "op read op://Private/TypeSafe/credential";
|
|
30
|
+
|
|
31
|
+
export async function login(
|
|
32
|
+
ui: LoginUI,
|
|
33
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
34
|
+
path: string = credentialsPath(env),
|
|
35
|
+
): Promise<LoginResult | undefined> {
|
|
36
|
+
const how = await ui.select("How should pignon get your TypeSafe API key?", [FROM_COMMAND, PASTE]);
|
|
37
|
+
if (how === undefined) return undefined;
|
|
38
|
+
|
|
39
|
+
let value: string;
|
|
40
|
+
if (how === FROM_COMMAND) {
|
|
41
|
+
const command = (await ui.input("Command that prints the key", EXAMPLE_COMMAND))?.trim().replace(/^!\s*/, "");
|
|
42
|
+
if (!command) return undefined;
|
|
43
|
+
value = `!${command}`;
|
|
44
|
+
try {
|
|
45
|
+
await resolveStoredKey(value);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return { level: "error", message: `${(err as Error).message}; nothing saved` };
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
const key = (await ui.input("TypeSafe API key", "paste the key"))?.trim();
|
|
51
|
+
if (!key) return undefined;
|
|
52
|
+
if (/\s/.test(key)) return { level: "error", message: "an API key has no spaces; nothing saved" };
|
|
53
|
+
value = key;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
writeStoredKey(TYPESAFE_CREDENTIAL, value, path);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return { level: "error", message: `could not write ${path}: ${(err as Error).message}` };
|
|
60
|
+
}
|
|
61
|
+
const shadowed = env[DEFAULT_API_KEY_ENV]?.trim() ? ` ${DEFAULT_API_KEY_ENV} is set and is used first.` : "";
|
|
62
|
+
return { level: shadowed ? "warning" : "info", message: `saved the TypeSafe key in ${path}; /reload to use it.${shadowed}` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function logout(env: NodeJS.ProcessEnv = process.env, path: string = credentialsPath(env)): LoginResult {
|
|
66
|
+
if (readStoredKey(TYPESAFE_CREDENTIAL, path).kind === "missing") {
|
|
67
|
+
return { level: "info", message: "no TypeSafe key saved" };
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
writeStoredKey(TYPESAFE_CREDENTIAL, undefined, path);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
return { level: "error", message: `could not write ${path}: ${(err as Error).message}` };
|
|
73
|
+
}
|
|
74
|
+
const still = env[DEFAULT_API_KEY_ENV]?.trim() ? ` ${DEFAULT_API_KEY_ENV} is still set.` : "";
|
|
75
|
+
return { level: "info", message: `removed the saved TypeSafe key; /reload to apply.${still}` };
|
|
76
|
+
}
|
package/src/onboarding.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { dirname } from "node:path";
|
|
|
10
10
|
|
|
11
11
|
import { type PresetName, PRESETS, PRESET_NAMES } from "./config/presets.js";
|
|
12
12
|
import { CONFIG_SCHEMA_URL } from "./config/schema.js";
|
|
13
|
+
import { type StoredKey, TYPESAFE_CREDENTIAL, credentialsPath, readStoredKey } from "./credentials.js";
|
|
13
14
|
import { DEFAULT_API_KEY_ENV } from "./deciders/jev.js";
|
|
14
15
|
import { type WorkerLaunch, layaRuntimeStatus } from "./deciders/laya-local.js";
|
|
15
16
|
import { probeLayaServe } from "./deciders/laya-serve.js";
|
|
@@ -49,16 +50,17 @@ export function choosePreset<M>(lookup: ModelLookup<M>): PresetName {
|
|
|
49
50
|
/**
|
|
50
51
|
* The decider to start with, local first: a running laya-serve on its default
|
|
51
52
|
* address, else the experimental worker when installed, else Jev when its key
|
|
52
|
-
* is set
|
|
53
|
+
* is set or saved by `/pignon login`.
|
|
53
54
|
*/
|
|
54
55
|
export async function detectDeciders(
|
|
55
56
|
env: NodeJS.ProcessEnv = process.env,
|
|
56
57
|
layaStatus: typeof layaRuntimeStatus = layaRuntimeStatus,
|
|
57
58
|
probe: () => Promise<boolean> = () => probeLayaServe(),
|
|
59
|
+
storedKey: (name: string) => StoredKey = (name) => readStoredKey(name, credentialsPath(env)),
|
|
58
60
|
): Promise<DeciderSpec[]> {
|
|
59
61
|
if (await probe()) return [{ type: "laya-serve" }];
|
|
60
62
|
if (layaStatus(env).ok) return [{ type: "laya-local" }];
|
|
61
|
-
if (env[DEFAULT_API_KEY_ENV]?.trim()) return [{ type: "jev" }];
|
|
63
|
+
if (env[DEFAULT_API_KEY_ENV]?.trim() || storedKey(TYPESAFE_CREDENTIAL).kind !== "missing") return [{ type: "jev" }];
|
|
62
64
|
return [];
|
|
63
65
|
}
|
|
64
66
|
|
|
@@ -1,161 +0,0 @@
|
|
|
1
|
-
# Plan (pignon): command output that is not truncated and does not persist
|
|
2
|
-
|
|
3
|
-
Status: implemented · 2026-09-23 (see *Changes from the proposal*)
|
|
4
|
-
|
|
5
|
-
## Problem
|
|
6
|
-
|
|
7
|
-
`/pignon doctor` (and `config`, `log`, `stats`) render through `ctx.ui.setWidget(key, string[])`.
|
|
8
|
-
Two consequences, both bad UX:
|
|
9
|
-
|
|
10
|
-
1. **Truncated.** Pi keeps only the first 10 lines of a string-array widget and appends
|
|
11
|
-
`... (widget truncated)`. Doctor emits ~15–25 lines, so the model checks — the part the
|
|
12
|
-
user ran the command for — are silently dropped.
|
|
13
|
-
2. **Persists.** A widget is a persistent slot: it stays above the editor through every
|
|
14
|
-
following prompt until `setWidget(key, undefined)`, i.e. until `/pignon doctor clear`.
|
|
15
|
-
|
|
16
|
-
## Why it happens (verified in the Pi source)
|
|
17
|
-
|
|
18
|
-
`@earendil-works/pi-coding-agent` 0.87.1, `InteractiveMode` in
|
|
19
|
-
`dist/bundle/chunks/chunk-OJP47DM6.js`:
|
|
20
|
-
|
|
21
|
-
```js
|
|
22
|
-
if (Array.isArray(content)) {
|
|
23
|
-
let container = new Container;
|
|
24
|
-
for (let line of content.slice(0, _InteractiveMode.MAX_WIDGET_LINES))
|
|
25
|
-
container.addChild(new Text(line, 1, 0));
|
|
26
|
-
content.length > _InteractiveMode.MAX_WIDGET_LINES &&
|
|
27
|
-
container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0));
|
|
28
|
-
component = container;
|
|
29
|
-
} else component = content(this.ui, theme); // factory overload: no cap
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
with `static MAX_WIDGET_LINES = 10`.
|
|
33
|
-
|
|
34
|
-
The cap applies **only** to the `string[]` overload. The component-factory overload is
|
|
35
|
-
uncapped — `src/ui.ts` already relies on that for the `pignon-deciding` spinner.
|
|
36
|
-
|
|
37
|
-
Corroborated by a third-party extension (`itayinbarr/little-coder`,
|
|
38
|
-
`.pi/extensions/extensions-info/manifest.ts`):
|
|
39
|
-
|
|
40
|
-
> pi slices a string-array widget to MAX_WIDGET_LINES (10) and appends
|
|
41
|
-
> "... (widget truncated)", so anything past that is silently lost.
|
|
42
|
-
|
|
43
|
-
Persistence is by design, not a bug: `docs/tui.md` lists `ctx.ui.setWidget()` under
|
|
44
|
-
*"Persistent content near the editor"*. The fix is therefore to stop using a widget for
|
|
45
|
-
one-shot reports, not to tune the widget.
|
|
46
|
-
|
|
47
|
-
## What each command emits today
|
|
48
|
-
|
|
49
|
-
| command | lines | vs the 10-line cap |
|
|
50
|
-
| --- | --- | --- |
|
|
51
|
-
| `doctor` | ~15–25: header, `config` + 1–2, `deciders` + 1–3, `models` + up to 8 | truncated |
|
|
52
|
-
| `log` | 30 (`LOG_WIDGET_LINES`) | truncated to 10 — worst case |
|
|
53
|
-
| `stats` | ~12+: header, bucket header, 3 tiers × 2 forms, summary | truncated |
|
|
54
|
-
| `config` | ~8: header, column header, N tiers, footer, hint | ok at 3 tiers, truncates at 4+ |
|
|
55
|
-
|
|
56
|
-
## Decision
|
|
57
|
-
|
|
58
|
-
Render one-shot reports in a **dismissible overlay** (`ctx.ui.custom({ overlay: true })`)
|
|
59
|
-
that scrolls itself: no line cap, scrollable, disposed on close so nothing persists.
|
|
60
|
-
Fallback to `notify` in RPC mode and to stderr in print/JSON mode.
|
|
61
|
-
|
|
62
|
-
Apply it to **all four** commands through one shared helper.
|
|
63
|
-
|
|
64
|
-
## Design
|
|
65
|
-
|
|
66
|
-
### `src/report.ts` (new)
|
|
67
|
-
|
|
68
|
-
```ts
|
|
69
|
-
export async function showReport(
|
|
70
|
-
ctx: ExtensionContext,
|
|
71
|
-
title: string,
|
|
72
|
-
lines: string[] | Promise<string[]>,
|
|
73
|
-
): Promise<void>
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
Three tiers, following `docs/extensions.md` (*"Guard terminal-only behavior with
|
|
77
|
-
`ctx.mode === "tui"` and use `ctx.hasUI` for interactions supported by interactive and RPC
|
|
78
|
-
clients"*):
|
|
79
|
-
|
|
80
|
-
| mode | behaviour |
|
|
81
|
-
| --- | --- |
|
|
82
|
-
| `ctx.mode === "tui"` | `ctx.ui.custom(..., { overlay: true })` — `ReportOverlay` below |
|
|
83
|
-
| RPC (`ctx.hasUI`, not tui) | `ctx.ui.notify(lines.join("\n"), "info")` — RPC forwards `notify`, not custom components |
|
|
84
|
-
| print / JSON (`!ctx.hasUI`) | `process.stderr.write(lines.join("\n") + "\n")` — stdout stays parseable in JSON mode |
|
|
85
|
-
|
|
86
|
-
The `Promise<string[]>` overload matters for `doctor`, which can block ~15 s on the decider
|
|
87
|
-
probe: open the overlay immediately with `running checks…`, then `setLines()` →
|
|
88
|
-
`invalidate()` + `tui.requestRender()` when it resolves. The editor is never left looking
|
|
89
|
-
frozen.
|
|
90
|
-
|
|
91
|
-
### `ReportOverlay`
|
|
92
|
-
|
|
93
|
-
A `Component` (`render(width)` / `handleInput(data)` / `invalidate()` / `dispose()`):
|
|
94
|
-
|
|
95
|
-
- Keeps its own line offset and renders only the visible slice, framed by a
|
|
96
|
-
rounded border: title in the top rule; hint and `a–b/n` position in the bottom rule
|
|
97
|
-
- Body height: 70% of `tui.terminal.rows` (read on each render) minus the 2 border rows
|
|
98
|
-
- Overlay options: `{ width: <widest line + frame>, minWidth: 40, anchor: "center", margin: 1 }`;
|
|
99
|
-
`90%` while doctor's lines are still pending (Pi resolves overlay options once, at open)
|
|
100
|
-
- Keys: ↑↓ / `j` `k` (line), PgUp / PgDn / space (page), Home / End / `g` `G`,
|
|
101
|
-
`Esc` / `q` / `Enter` → `done()`
|
|
102
|
-
- Every line passes through `truncateToWidth(…, pad)` — see Risks
|
|
103
|
-
|
|
104
|
-
## Files
|
|
105
|
-
|
|
106
|
-
| file | change |
|
|
107
|
-
| --- | --- |
|
|
108
|
-
| `src/report.ts` | **new** — `showReport()` + `ReportOverlay` |
|
|
109
|
-
| `src/extension.ts` | replace the 4 report `showWidget(...)` calls with `showReport(...)`; drop the `"(/pignon X clear to hide)"` trailers; raise `LOG_WIDGET_LINES` 30 → 200 (the overlay scrolls) |
|
|
110
|
-
| `src/config/describe.ts` | drop its internal `"(/pignon config clear to hide)"` line |
|
|
111
|
-
| `src/ui.ts` | unchanged — `showDeciding` is *meant* to persist, and already uses the uncapped factory overload |
|
|
112
|
-
| `src/onboarding.ts` | unchanged — already returns plain `string[]` |
|
|
113
|
-
| `tests/extension.test.ts`, `tests/extension-routing.test.ts` | add `custom` and `mode` to the mocks; update the `ctx.ui.setWidget` assertions |
|
|
114
|
-
| `tests/report.test.ts` | **new** — overlay line rendering and key handling (pure, no terminal) |
|
|
115
|
-
| `tests/helpers/fake-report.ts` | **new** — a `ctx.ui.custom` fake that keeps the overlay for assertions |
|
|
116
|
-
| `src/stats.ts`, `src/compare.ts` | drop their `"(/pignon-stats clear to hide)"` lines |
|
|
117
|
-
| `README.md`, `CHANGELOG.md` | the four commands no longer need `clear`; keep the subcommands as no-ops for back-compat |
|
|
118
|
-
|
|
119
|
-
## Steps
|
|
120
|
-
|
|
121
|
-
1. Write `src/report.ts`: `ReportOverlay` (Box + ScrollView + Text) and `showReport()`.
|
|
122
|
-
2. Rewire `doctor`, `config`, `log`, `stats` in `src/extension.ts`; remove the "clear to
|
|
123
|
-
hide" trailers.
|
|
124
|
-
3. Extend the test mocks with `custom` and `mode`, then update and add tests.
|
|
125
|
-
4. Update README and CHANGELOG.
|
|
126
|
-
|
|
127
|
-
## Risks
|
|
128
|
-
|
|
129
|
-
- **Width overflow.** pi-tui throws on over-wide lines (reported upstream as issue #48 by
|
|
130
|
-
`little-coder`). `truncateToWidth()` on every line is load-bearing, not cosmetic.
|
|
131
|
-
- **Modal.** `doctor` now needs `Esc` before the user can type again. Accepted: it is a
|
|
132
|
-
diagnostic, and it is the cost of nothing persisting.
|
|
133
|
-
- **Key collision.** `matchesKey(data, "q")` is safe here only because the overlay has no
|
|
134
|
-
text input. Any future search/filter field inside it must handle `q` as text first.
|
|
135
|
-
- **Back-compat.** `doctor clear` / `config clear` / `stats clear` / `log clear` stay
|
|
136
|
-
accepted and keep clearing the (now unused) widget key so a stale widget from an older
|
|
137
|
-
session cannot linger.
|
|
138
|
-
|
|
139
|
-
## Changes from the proposal
|
|
140
|
-
|
|
141
|
-
- **No `ScrollView`.** Pi composites overlays in `TUI.compositeOverlays()`
|
|
142
|
-
(`pi-tui/dist/tui.js`): it calls `component.render(width)` and slices the result to
|
|
143
|
-
`maxHeight`. The `[LAYOUT_NODE]` / `updateLayout` path runs only in
|
|
144
|
-
`renderLayoutFrame()` for the alt-screen root, never for overlays, and
|
|
145
|
-
`ScrollView.render()` returns all of its child's lines. A `ScrollView` in an overlay
|
|
146
|
-
would therefore never scroll, and `maxHeight: "70%"` would truncate it silently: the
|
|
147
|
-
same bug with a border around it. `ReportOverlay` scrolls itself instead.
|
|
148
|
-
- **Overlay options go under `overlayOptions`**, not at the top level of the `custom()`
|
|
149
|
-
options.
|
|
150
|
-
- **Width fits the content** instead of a fixed 80%, which truncated the config table's
|
|
151
|
-
last column at 100 columns.
|
|
152
|
-
- **Five call sites, not four**: `/pignon-stats compare` also used the widget. `stats.ts`
|
|
153
|
-
and `compare.ts` had their own "clear to hide" lines too.
|
|
154
|
-
- **Titles move into the border.** Each report's first line (`pignon doctor`,
|
|
155
|
-
`pignon config · <source>`, …) becomes the overlay title. The producers are unchanged,
|
|
156
|
-
so RPC and stderr output still start with it.
|
|
157
|
-
- `clear` subcommands are no longer offered in completion, but are still accepted.
|
|
158
|
-
- A rejected `lines` promise shows `✗ <message>` in the overlay instead of leaving
|
|
159
|
-
`running checks…` forever.
|
|
160
|
-
- Verified in Pi 0.87.1 through a pty: config and doctor render whole, doctor scrolls on a
|
|
161
|
-
14-row terminal, and Esc leaves nothing above the editor.
|
package/docs/PLAN-deciders.md
DELETED
|
@@ -1,327 +0,0 @@
|
|
|
1
|
-
# Plan (pignon): pluggable deciders (local Laya + remote Jev) and a user-defined routing table
|
|
2
|
-
|
|
3
|
-
Status: proposal · 2026-09-23
|
|
4
|
-
|
|
5
|
-
## Goals
|
|
6
|
-
|
|
7
|
-
1. **Remote decider.** Route with TypeSafe's Jev (hosted, via `@typesafe-ai/sdk`) as well
|
|
8
|
-
as the local Laya worker. Jev is used as a *fallback*: when Laya is unavailable, errors,
|
|
9
|
-
or answers below a confidence floor.
|
|
10
|
-
2. **User-owned routing table.** Move the model map and the difficulty tiers out of
|
|
11
|
-
`types.ts` into config. Users name their own models and write their own ordered list of
|
|
12
|
-
difficulty tiers (2..N, with the criterion text sent to the decider).
|
|
13
|
-
3. **Publishable.** Easy setup, a schema-checked config, docs, tests, and modules small
|
|
14
|
-
enough to read one at a time.
|
|
15
|
-
|
|
16
|
-
Non-goals (for now): fine-tuning Laya, per-project config, and asking questions other than
|
|
17
|
-
tier and form.
|
|
18
|
-
|
|
19
|
-
## What stays the same
|
|
20
|
-
|
|
21
|
-
- The routing policy (`policy.ts`) keeps its algorithm: confidence gates, cooldown, payback
|
|
22
|
-
and cache guard, fail-open.
|
|
23
|
-
- Pi wiring: modes (shadow/live/off), manual pin, decision cards, `/pignon-stats` (renamed from `/laya*`, kept as aliases for one release).
|
|
24
|
-
- The Laya worker protocol and the Python worker. Only the TS client moves.
|
|
25
|
-
- Prompt privacy on the local path (the hash and length are logged, never the text).
|
|
26
|
-
|
|
27
|
-
## Facts that constrain the design (from the SDK v0.6.0 `.d.ts`)
|
|
28
|
-
|
|
29
|
-
| Fact | Consequence |
|
|
30
|
-
|---|---|
|
|
31
|
-
| `TypeSafeClient({ apiKey, baseURL, defaultModel, timeout, retry, logger, logLevel, fetch })` | `fetch` can be injected, so tests need no network. `baseURL: "https://openrouter.ai/api"` + an OpenRouter key also works. |
|
|
32
|
-
| Env fallbacks: `TYPESAFE_API_KEY`, `TYPESAFE_BASE_URL`, `TYPESAFE_DEFAULT_MODEL` | Nothing to configure for the common case. |
|
|
33
|
-
| `timeout` applies **per attempt**, default 2 retries, no total budget | Set `retry.maxRetries: 0` (or 1) and bound the whole call with our own `AbortSignal.timeout`. |
|
|
34
|
-
| Default logger is `console`, and `debug` logs request **bodies** | Pass our own logger (into the `/pignon log` ring buffer), cap `logLevel` at `warn`, so nothing draws over the TUI and prompts never reach logs. |
|
|
35
|
-
| `ChoiceResponse` = `{ choice, confidence, probabilities }`, **no `type` field** | The shared parser must not require `type === "choice"` (the local worker's `choiceOf` does today). |
|
|
36
|
-
| `usage` has tokens; cost comes back at runtime but is not typed | Record `costUsd` when present and show it in stats. |
|
|
37
|
-
| 70–500 ms latency, 32k-token state limit, input-billed | Separate timeout per decider (Jev ≈ 1500 ms). Keep the 4 000-char prompt cap for both deciders. |
|
|
38
|
-
| Error classes: `AuthenticationError`, `RateLimitError`, `APITimeoutError`, `APIUserAbortError`… | Map them to short status texts ("jev: bad API key", "jev: rate limited"). |
|
|
39
|
-
|
|
40
|
-
## Architecture
|
|
41
|
-
|
|
42
|
-
```
|
|
43
|
-
┌──────────────── extension.ts (Pi wiring only) ────────────────┐
|
|
44
|
-
prompt ───► │ router.ts: routePrompt() │
|
|
45
|
-
│ deciders/* ──► RoutingDecision ──► policy.decide() ──► setModel
|
|
46
|
-
└───────────────────────────────────────────────────────────────┘
|
|
47
|
-
|
|
48
|
-
deciders/
|
|
49
|
-
types.ts Decider interface, DecisionQuestions, RawAnswers
|
|
50
|
-
questions.ts build the tier/form questions from config (single source)
|
|
51
|
-
parse.ts RawAnswers → RoutingDecision (shared, tolerant)
|
|
52
|
-
laya-local.ts LayaWorker (today's laya-worker.ts) implementing Decider
|
|
53
|
-
jev.ts TypeSafeClient adapter implementing Decider
|
|
54
|
-
fallback.ts FallbackDecider: tries deciders in order, escalating on low confidence
|
|
55
|
-
create.ts factory: config → Decider
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
### The `Decider` seam
|
|
59
|
-
|
|
60
|
-
```ts
|
|
61
|
-
export interface Decider {
|
|
62
|
-
readonly id: string; // "laya-local" | "jev" | "fallback(laya-local→jev)"
|
|
63
|
-
readonly label: string; // shown on the spinner and card, e.g. "jev-1.13.0"
|
|
64
|
-
readonly isReady: boolean; // local: model warm; remote: key present
|
|
65
|
-
readonly remote: boolean; // prompt leaves the machine → shown on the card
|
|
66
|
-
warmup(signal?: AbortSignal): Promise<void>;
|
|
67
|
-
decide(q: DecisionQuestions, text: string, signal: AbortSignal): Promise<DeciderResult>;
|
|
68
|
-
stop(): void;
|
|
69
|
-
readonly recentLogs: readonly string[];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export interface DeciderResult {
|
|
73
|
-
answers: RawAnswers; // { reasoning_demand: {choice, confidence}, needs_exploration: … }
|
|
74
|
-
deciderId: string; // which decider actually answered
|
|
75
|
-
model: string; // checkpoint / jev model version
|
|
76
|
-
latencyMs: number;
|
|
77
|
-
costUsd?: number;
|
|
78
|
-
attempts: Attempt[]; // one per decider tried (for the card and stats)
|
|
79
|
-
}
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
`parse.ts` turns `answers` into `RoutingDecision` against the **configured** tier ids, so the
|
|
83
|
-
policy never sees decider-specific shapes.
|
|
84
|
-
|
|
85
|
-
### Fallback semantics (`FallbackDecider`)
|
|
86
|
-
|
|
87
|
-
Config order is the try order. For each decider:
|
|
88
|
-
|
|
89
|
-
1. Skip it if it is not ready (e.g. Laya still loading) → record `skipped: not ready`.
|
|
90
|
-
2. Call it with its own timeout, under the caller's signal.
|
|
91
|
-
3. Accept the answer if `tierConfidence >= escalateBelow` **or** it is the last decider.
|
|
92
|
-
4. Otherwise escalate: record the attempt and try the next one.
|
|
93
|
-
|
|
94
|
-
If every decider fails, rethrow → the policy fails open (as today). When the last decider
|
|
95
|
-
answers with *lower* confidence than an earlier one, keep the most confident answer. An
|
|
96
|
-
overall `budgetMs` (default 3000) caps total latency, so a slow Laya plus a slow Jev cannot
|
|
97
|
-
stall a prompt.
|
|
98
|
-
|
|
99
|
-
Defaults: `escalateBelow: 0.75`, and escalate on error and on not-ready.
|
|
100
|
-
|
|
101
|
-
## Configuration (v2)
|
|
102
|
-
|
|
103
|
-
One JSON file, as today (`~/.pi/agent/pignon.json` or `$PIGNON_CONFIG`; the old `laya-router.json` is read as a fallback with a rename hint). Every
|
|
104
|
-
key is optional; defaults reproduce today's behavior exactly.
|
|
105
|
-
|
|
106
|
-
```jsonc
|
|
107
|
-
{
|
|
108
|
-
"$schema": "https://unpkg.com/pignon/schema/config.schema.json",
|
|
109
|
-
"version": 2,
|
|
110
|
-
|
|
111
|
-
// 1. Deciders, in fallback order
|
|
112
|
-
"deciders": [
|
|
113
|
-
{ "type": "laya-local", "timeoutMs": 2500 }, // optional: model, python, workerDir…
|
|
114
|
-
{ "type": "jev", "timeoutMs": 1500, "model": "jev-1.13.0" } // apiKey from TYPESAFE_API_KEY
|
|
115
|
-
],
|
|
116
|
-
"strategy": { "mode": "sequential", "escalateBelow": 0.75 }, // see "Decision strategies"
|
|
117
|
-
|
|
118
|
-
// 2. Model aliases: define once, reference by name
|
|
119
|
-
"models": {
|
|
120
|
-
"flash": { "provider": "openrouter", "modelId": "deepseek/deepseek-v4-flash-0731", "thinking": "off" },
|
|
121
|
-
"flash-41": { "provider": "openrouter", "modelId": "deepseek/deepseek-v4.1-flash", "thinking": "low" },
|
|
122
|
-
"glm": { "provider": "openrouter", "modelId": "z-ai/glm-5.3", "thinking": "high" },
|
|
123
|
-
"hy4": { "provider": "openrouter", "modelId": "tencent/hy4-preview", "thinking": "low" }
|
|
124
|
-
},
|
|
125
|
-
|
|
126
|
-
// 3. Difficulty tiers, easiest first. The criterion is what the decider reads.
|
|
127
|
-
"tiers": [
|
|
128
|
-
{ "id": "trivial", "criterion": "Mechanical edit, rename, formatting, or a single factual lookup",
|
|
129
|
-
"model": "flash", "explorationAllowed": false },
|
|
130
|
-
{ "id": "standard", "criterion": "Localized change across a few files with clear intent",
|
|
131
|
-
"model": "flash-41" },
|
|
132
|
-
{ "id": "hard", "criterion": "Multi-step investigation, debugging with unclear cause, or cross-cutting design",
|
|
133
|
-
"direct": "glm", "exploration": "hy4" }
|
|
134
|
-
],
|
|
135
|
-
|
|
136
|
-
// Optional wording overrides; bump questionsVersion whenever you edit criteria.
|
|
137
|
-
"questions": { "version": "q1", "tierInstructions": "…", "explorationInstructions": "…" },
|
|
138
|
-
|
|
139
|
-
"thresholds": { "minConfidenceDowngrade": 0.85 }
|
|
140
|
-
}
|
|
141
|
-
```
|
|
142
|
-
|
|
143
|
-
Rules:
|
|
144
|
-
|
|
145
|
-
- A tier gives either `model` (both forms) or `direct` + `exploration`. A value can be an
|
|
146
|
-
alias or an inline `{ provider, modelId, thinking }`.
|
|
147
|
-
- `explorationAllowed: false` generalizes today's hard-coded "exploration forbids trivial"
|
|
148
|
-
rule: an exploration task at that tier goes up to the next tier that allows it.
|
|
149
|
-
- Need 2..8 tiers with unique ids. Unknown alias → error naming the tier.
|
|
150
|
-
- `version` missing + the old `tiers: { hard: { direct: … } }` object shape → migrate in
|
|
151
|
-
memory and notify once ("config uses v1 format; run `/pignon config migrate`").
|
|
152
|
-
- Invalid config: same contract as today. Report it on session start, fall back per
|
|
153
|
-
section, never block loading.
|
|
154
|
-
- **Secrets never go in the file.** `jev.apiKeyEnv` (default `TYPESAFE_API_KEY`) names the
|
|
155
|
-
variable. A literal `apiKey` is rejected with a message.
|
|
156
|
-
|
|
157
|
-
### Validation
|
|
158
|
-
|
|
159
|
-
Use TypeBox (see "Validation with TypeBox" below). The schema module is the single source
|
|
160
|
-
for the TS types, the runtime checks, and `schema/config.schema.json`.
|
|
161
|
-
|
|
162
|
-
### Presets and onboarding
|
|
163
|
-
|
|
164
|
-
- `presets/openrouter.json` (today's table), `presets/anthropic.json`, `presets/openai.json`.
|
|
165
|
-
Config can say `"extends": "openrouter"` and override only what differs.
|
|
166
|
-
- `/pignon init [preset]` writes a starter config (it refuses to overwrite an existing file),
|
|
167
|
-
lists the models from `ctx.modelRegistry` that resolve, and flags those that do not.
|
|
168
|
-
- `/pignon doctor` checks each decider (worker venv present? Apple Silicon? key set? one test
|
|
169
|
-
`decide` round-trip) and each table model (in registry? auth?), with one ✓/✗ line per check.
|
|
170
|
-
- `/pignon config` shows the resolved table (tier × form → model · thinking) and its source.
|
|
171
|
-
|
|
172
|
-
## Code changes, file by file
|
|
173
|
-
|
|
174
|
-
| File | Change |
|
|
175
|
-
|---|---|
|
|
176
|
-
| `src/types.ts` | Drop `Tier` union, `DEFAULT_TIERS`, `TIER_ORDER`; keep protocol + policy types. `Profile.tier: TierId` (string). Add `DeciderAttempt`, extend `RouterLogEntry` with `decider`, `escalated`, `attempts`, `costUsd`, `questionsVersion`. |
|
|
177
|
-
| `src/config/schema.ts` | TypeBox schema, v1→v2 migration. |
|
|
178
|
-
| `src/config/load.ts` | Today's `loadConfig` (file read, error collection) on top of the schema; resolves `extends` and aliases into a `ResolvedConfig` whose `table` is an ordered array. |
|
|
179
|
-
| `src/config/defaults.ts` | Default models, tiers, and deciders (`[laya-local]` so behavior does not change on upgrade). |
|
|
180
|
-
| `src/deciders/*` | See the architecture section. `laya-worker.ts` moves to `deciders/laya-local.ts` with its public API unchanged, plus a thin `Decider` adapter. |
|
|
181
|
-
| `src/policy.ts` | Tier order comes from `config.table` (index = rank). The trivial/exploration rule reads `explorationAllowed`. `profileFromModel` takes the table. |
|
|
182
|
-
| `src/router.ts` | New: `routePrompt` + `buildLogEntry` pulled out of `extension.ts` (Pi-free except for a small `RouterHost` interface: `findModel`, `setModel`, `setThinking`, `contextTokens`), so it can be tested without Pi mocks. |
|
|
183
|
-
| `src/extension.ts` | Wiring only: build the decider from config, register hooks and commands. Spinner/status text use `decider.label`. |
|
|
184
|
-
| `src/ui.ts` | Card head shows the decider that answered (`laya` / `jev ☁`), escalation (`laya 0.62 → jev 0.91`), and cost. Stats grid rows come from the config tiers. |
|
|
185
|
-
| `package.json` | `@typesafe-ai/sdk` and `typebox` in `dependencies`; `files`, `license`, `repository`, `keywords: ["pi-package", "pi-extension"]`; drop `private`. |
|
|
186
|
-
|
|
187
|
-
The old `~/.pi/agent/extensions/jev-router` becomes redundant: its behavior equals
|
|
188
|
-
`deciders: [{ "type": "jev" }]`. Remove it after migration so two routers don't both fire.
|
|
189
|
-
|
|
190
|
-
## Tests
|
|
191
|
-
|
|
192
|
-
| Suite | Covers |
|
|
193
|
-
|---|---|
|
|
194
|
-
| `deciders/contract.test.ts` | One shared suite run against every `Decider` (Laya via a fake spawn, Jev via an injected `fetch`): ready/not-ready, abort, timeout, malformed answers, `stop()`. |
|
|
195
|
-
| `deciders/jev.test.ts` | Request body (questions from config, 4 000-char cap, model pin); SDK errors → short messages; `maxRetries` 0; no console output; cost parsing; missing key → `isReady=false`, never throws at construction. |
|
|
196
|
-
| `deciders/fallback.test.ts` | Escalation on low confidence, error, and not-ready; keeps the best answer; overall budget; caller abort stops the chain; `attempts` recorded. |
|
|
197
|
-
| `config/*.test.ts` | Schema accept/reject tables, alias resolution, `extends`, v1 migration, literal-apiKey rejection, per-section fallback, JSON Schema file up to date. |
|
|
198
|
-
| `policy.test.ts` | Existing 38 cases kept, parameterized over a 2-tier and a 4-tier table; `explorationAllowed`. |
|
|
199
|
-
| `router.test.ts` | End-to-end through `RouterHost` fakes (moves most of `extension-routing.test.ts`). |
|
|
200
|
-
| `extension.test.ts` | Commands incl. `init`, `doctor`, `config`. |
|
|
201
|
-
| `live.test.ts` | Skipped unless `TYPESAFE_API_KEY` is set (`npm run test:live`): one real Jev call checks the response shape still matches the parser. |
|
|
202
|
-
|
|
203
|
-
`npm run check` = typecheck + unit + worker tests + schema freshness. No network in `check`.
|
|
204
|
-
|
|
205
|
-
## Docs
|
|
206
|
-
|
|
207
|
-
- `README.md`, restructured: 60-second quickstart (Jev only: set the key, `/pignon init`,
|
|
208
|
-
`/pignon live`), then "Add the local Laya model (Apple Silicon)", then Configuration
|
|
209
|
-
reference, Commands, How routing decides, Privacy, Troubleshooting (`/pignon doctor`).
|
|
210
|
-
- **Privacy section:** local Laya keeps prompts on the machine. Jev sends the first 4 000
|
|
211
|
-
characters of each routed prompt to TypeSafe (or OpenRouter). The card marks remote
|
|
212
|
-
decisions with ☁.
|
|
213
|
-
- `docs/configuration.md`: every key, generated tables from the schema descriptions.
|
|
214
|
-
- `docs/writing-tiers.md`: how to write criteria, why to bump `questions.version`, and how
|
|
215
|
-
to calibrate thresholds from `/laya-stats` in shadow mode.
|
|
216
|
-
- `CHANGELOG.md`, `LICENSE`, and `examples/*.json`.
|
|
217
|
-
|
|
218
|
-
## Phases (each ends green on `npm run check`)
|
|
219
|
-
|
|
220
|
-
1. ✅ **Seam, no behavior change.** `git init`; add the `Decider` interface; wrap `LayaWorker`;
|
|
221
|
-
move parsing to `parse.ts`; extract `router.ts`. Existing tests keep passing.
|
|
222
|
-
2. ✅ **Configurable table.** TypeBox schema, aliases, ordered tiers, questions built from config,
|
|
223
|
-
v1 migration, dynamic tiers in policy/UI/stats.
|
|
224
|
-
3. ✅ **Jev decider.** SDK adapter, error mapping, logger/retry hardening, contract + unit tests.
|
|
225
|
-
4. ✅ **Strategies.** sequential + parallel, budget, `/pignon-stats compare|export`, attempts on card and log, stats
|
|
226
|
-
per decider (escalation rate, cost).
|
|
227
|
-
5. ✅ **Onboarding.** Presets + `extends`, `/pignon init|doctor|config`, JSON Schema generation.
|
|
228
|
-
6. ✅ **Publish.** ~~PyPI `pignon-laya` + uvx launcher~~ (dropped, see below) + protocol check, docs, package metadata, live test,
|
|
229
|
-
`npm pack` dry-run, and a test install into a clean `~/.pi` via `pi install`/symlink. Retire `jev-router`.
|
|
230
|
-
Released as git tag `v0.1.0` (`pi install git:github.com/siiick/pignon@v0.1.0`); npm is deferred, the package
|
|
231
|
-
is ready for it (only the Pi package gallery needs npm).
|
|
232
|
-
7. ✅ **laya-serve.** `laya-serve` decider over the Jev client (no key, local when on loopback), detected by
|
|
233
|
-
`/pignon init`, documented as the way to run Laya locally; `laya-local` becomes experimental.
|
|
234
|
-
|
|
235
|
-
## Decision (2026-09-23): laya-serve instead of publishing pignon-laya
|
|
236
|
-
|
|
237
|
-
The official `laya` package already ships `laya-serve`, a server speaking Jev's
|
|
238
|
-
`POST /v1/systemone`. Benchmarked through pignon's own deciders on 12 prompts × 5 rounds:
|
|
239
|
-
same tier and exploration answers on 12/12 (probabilities equal to ~0.001), p50 75 ms
|
|
240
|
-
against 61 ms for the MLX worker, 2–3 s restart (18 s on the very first run), 712 MB
|
|
241
|
-
installed against 258 MB. A stopped server refuses connections within milliseconds, so
|
|
242
|
-
prompts are never held. Publishing our own package would duplicate it for ~14 ms, so
|
|
243
|
-
`pignon-laya` stays in the repository, unpublished (PyPI's `Private :: Do Not Upload`
|
|
244
|
-
classifier), and the uvx launcher is removed. A `serve` command upstream in laya-mlx
|
|
245
|
-
would bring MLX speed to everyone; to propose there.
|
|
246
|
-
|
|
247
|
-
## Decisions (2026-09-23)
|
|
248
|
-
|
|
249
|
-
1. **Default deciders.** Laya if its runtime can start, else Jev if a key is set, else a
|
|
250
|
-
status that says "no decider, run `/pignon doctor`". Resolved once per session, and
|
|
251
|
-
`/pignon config` shows which one was picked.
|
|
252
|
-
2. **Name: `pignon`** (the sprocket on a bike cassette: the router changes sprockets between tiers). npm `pignon`, PyPI `pignon-laya`, commands `/pignon` and `/pignon-stats`, config `~/.pi/agent/pignon.json`, env prefix `PIGNON_` (`LAYA_*` stays for the worker itself).
|
|
253
|
-
3. **Validation: TypeBox**, not zod (see below).
|
|
254
|
-
4. **Both strategies**, `sequential` and `parallel`, with a comparison view for benchmarking.
|
|
255
|
-
|
|
256
|
-
## Validation with TypeBox (replaces the zod section)
|
|
257
|
-
|
|
258
|
-
TypeBox 1.x (`typebox` on npm, 1.3.x; Pi itself depends on 1.3.27) builds schemas that
|
|
259
|
-
*are* JSON Schema objects:
|
|
260
|
-
|
|
261
|
-
- Types: `Static<typeof ConfigSchema>`.
|
|
262
|
-
- Checking: `Compile(ConfigSchema)` from `typebox/compile`. Its `.Errors(value)` gives
|
|
263
|
-
instance paths for the notify message.
|
|
264
|
-
- JSON Schema: `npm run schema` writes `JSON.stringify(ConfigSchema, null, 2)` to
|
|
265
|
-
`schema/config.schema.json`. No converter needed. A test fails if the file is stale.
|
|
266
|
-
- Add `typebox` as our own `dependency` (range `^1.3.27`). It is not hoisted from Pi.
|
|
267
|
-
|
|
268
|
-
## Decision strategies
|
|
269
|
-
|
|
270
|
-
```jsonc
|
|
271
|
-
"deciders": [ { "type": "laya-local" }, { "type": "jev" } ],
|
|
272
|
-
"strategy": {
|
|
273
|
-
"mode": "sequential", // or "parallel"
|
|
274
|
-
"escalateBelow": 0.75, // sequential: try the next decider below this confidence
|
|
275
|
-
"pick": "most-confident", // parallel: "most-confident" | "first" (list order wins when it answers)
|
|
276
|
-
"budgetMs": 3000 // both modes: total wall time
|
|
277
|
-
}
|
|
278
|
-
```
|
|
279
|
-
|
|
280
|
-
- **sequential** (default): the fallback behavior above. Cheapest; Jev is only called
|
|
281
|
-
when needed.
|
|
282
|
-
- **parallel**: every ready decider runs at once under `budgetMs`. The router then uses
|
|
283
|
-
one answer, chosen by `pick`. With `pick: "first"`, Laya stays authoritative and Jev
|
|
284
|
-
only runs alongside it for comparison: this is the benchmarking setup. Note that every
|
|
285
|
-
routed prompt then costs a Jev call and sends the prompt out.
|
|
286
|
-
- Every attempt is logged (`decider`, `model`, tier, confidences, latency, cost, error), so
|
|
287
|
-
the data is the same in both modes.
|
|
288
|
-
- `/pignon-stats compare` shows, over the session's parallel decisions, the tier agreement rate
|
|
289
|
-
and a Laya × Jev confusion matrix, the form agreement rate, mean confidence per decider,
|
|
290
|
-
p50/p95 latency, and total Jev cost. `/pignon-stats export` writes the attempts as JSONL
|
|
291
|
-
(hashes, not prompts) for offline analysis or future fine-tuning labels.
|
|
292
|
-
|
|
293
|
-
## Distributing the optional Laya runtime
|
|
294
|
-
|
|
295
|
-
Laya needs Python, `laya-mlx` (MLX), and Apple Silicon. Most users of a published
|
|
296
|
-
extension will have none of these. So the runtime must be **opt-in**, **outside the npm
|
|
297
|
-
package**, and it **must survive extension updates**.
|
|
298
|
-
|
|
299
|
-
| Option | Verdict |
|
|
300
|
-
|---|---|
|
|
301
|
-
| Ship `worker/` in the npm package; user runs `uv sync` inside `node_modules/…` | ✗ The venv is hidden, is wiped on every update, and the absolute paths break (already an issue today with `rsync`). |
|
|
302
|
-
| Prebuilt binary (PyInstaller) as an optional npm dependency | ✗ MLX + Metal make it large, it needs code signing and notarization, and it rebuilds on every laya-mlx release. |
|
|
303
|
-
| Docker | ✗ No Metal GPU in containers on macOS. |
|
|
304
|
-
| **Separate PyPI package, launched with `uvx`** | ✓ Recommended. |
|
|
305
|
-
|
|
306
|
-
**Recommended design:**
|
|
307
|
-
|
|
308
|
-
- Publish `worker/` to PyPI as `pignon-laya` (`[project.scripts] pignon-laya = "laya_worker:main"`,
|
|
309
|
-
dependency `laya-mlx`). It is a separate release artifact with its own version, and it
|
|
310
|
-
lives in the same repo.
|
|
311
|
-
- The npm package does **not** include `worker/` (`files` whitelist).
|
|
312
|
-
- Launch order in the `laya-local` decider:
|
|
313
|
-
1. `command` in config (dev: `["uv", "run", "--project", "./worker", "pignon-laya"]`);
|
|
314
|
-
2. `pignon-laya` on `PATH` (for `uv tool install pignon-laya` or `pipx`);
|
|
315
|
-
3. `uvx --from pignon-laya==<compatible range> pignon-laya`. It installs nothing up
|
|
316
|
-
front, uv caches the environment outside the extension, and it survives npm updates;
|
|
317
|
-
4. none of these → `isReady = false` with a clear reason; the strategy skips it.
|
|
318
|
-
- **Protocol handshake:** the `ready` line already carries `PROTOCOL_VERSION` (0.3.0).
|
|
319
|
-
The extension declares the major version it accepts and refuses a mismatch with an
|
|
320
|
-
"upgrade with `uv tool upgrade pignon-laya`" message instead of misparsing.
|
|
321
|
-
- **Platform gate:** on anything other than `darwin`/`arm64`, the decider reports
|
|
322
|
-
"unsupported platform" without trying to spawn anything.
|
|
323
|
-
- **Onboarding:** `/pignon laya install` runs `uv tool install pignon-laya` and then a warmup
|
|
324
|
-
(the first checkpoint download is about 850 MB; progress goes to the log widget). It
|
|
325
|
-
asks first and needs `uv` (doctor links to the uv installer).
|
|
326
|
-
- **Worker tests** stay in the Python package. `npm run check` still runs them in the repo.
|
|
327
|
-
- The existing env allowlist, the model pinning, and the stderr capture are unchanged.
|