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/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-pignon",
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"pi-coding-agent",
|
|
9
|
+
"llm-router",
|
|
10
|
+
"model-routing",
|
|
11
|
+
"llm",
|
|
12
|
+
"laya",
|
|
13
|
+
"jev"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/siiick/pi-pignon#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/siiick/pi-pignon/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/siiick/pi-pignon.git"
|
|
22
|
+
},
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"author": "Nicolas Chaintron",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"schema",
|
|
29
|
+
"examples",
|
|
30
|
+
"CHANGELOG.md"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=22.19"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest",
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"clean": "rm -rf dist node_modules/.vitest",
|
|
40
|
+
"build": "echo 'nothing to build (TypeScript runs via jiti)'",
|
|
41
|
+
"check": "tsc --noEmit && vitest run && npm run test:worker",
|
|
42
|
+
"test:worker": "cd worker && .venv/bin/python -m unittest",
|
|
43
|
+
"schema": "UPDATE_SCHEMA=1 vitest run tests/schema.test.ts",
|
|
44
|
+
"test:live": "PIGNON_LIVE=1 vitest run tests/live",
|
|
45
|
+
"prepublishOnly": "npm run check"
|
|
46
|
+
},
|
|
47
|
+
"pi": {
|
|
48
|
+
"extensions": [
|
|
49
|
+
"./src/extension.ts"
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@typesafe-ai/sdk": "^0.6.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
57
|
+
"@earendil-works/pi-tui": "*",
|
|
58
|
+
"typebox": "*"
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@earendil-works/pi-coding-agent": "^0.87.1",
|
|
62
|
+
"@earendil-works/pi-tui": "^0.87.1",
|
|
63
|
+
"@types/node": "^22.0.0",
|
|
64
|
+
"typescript": "^5.6.0",
|
|
65
|
+
"vitest": "^2.1.0",
|
|
66
|
+
"typebox": "^1.3.34"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/siiick/pi-pignon/main/schema/config.schema.json",
|
|
4
|
+
"type": "object",
|
|
5
|
+
"properties": {
|
|
6
|
+
"$schema": {
|
|
7
|
+
"type": "string"
|
|
8
|
+
},
|
|
9
|
+
"version": {
|
|
10
|
+
"type": "number",
|
|
11
|
+
"const": 2,
|
|
12
|
+
"description": "Config format version."
|
|
13
|
+
},
|
|
14
|
+
"extends": {
|
|
15
|
+
"enum": [
|
|
16
|
+
"openrouter",
|
|
17
|
+
"anthropic",
|
|
18
|
+
"openai"
|
|
19
|
+
],
|
|
20
|
+
"description": "Model preset for the built-in names, applied before `models`: `openrouter` (OpenRouter: DeepSeek flash models, GLM for reasoning, HY4 as agent (the built-in table)); `anthropic` (Anthropic: Haiku for small edits, Sonnet for the rest, Opus for hard reasoning); `openai` (OpenAI: GPT-6 Luna for small edits, GPT-5.6 Terra for the rest, GPT-6 Sol for reasoning, Codex as agent)."
|
|
21
|
+
},
|
|
22
|
+
"deciders": {
|
|
23
|
+
"type": "array",
|
|
24
|
+
"items": {
|
|
25
|
+
"anyOf": [
|
|
26
|
+
{
|
|
27
|
+
"type": "object",
|
|
28
|
+
"required": [
|
|
29
|
+
"type"
|
|
30
|
+
],
|
|
31
|
+
"properties": {
|
|
32
|
+
"type": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"const": "laya-serve",
|
|
35
|
+
"description": "A Laya model served by the official laya-serve (`pip install \"laya[serve]\"`). Runs on your machine unless url points elsewhere."
|
|
36
|
+
},
|
|
37
|
+
"url": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"pattern": "^https?://",
|
|
40
|
+
"description": "Server root. Default: http://127.0.0.1:8000 (laya-serve's default port)."
|
|
41
|
+
},
|
|
42
|
+
"model": {
|
|
43
|
+
"type": "string",
|
|
44
|
+
"minLength": 1,
|
|
45
|
+
"description": "Laya checkpoint: `english`, `multilingual` or `typed-decisions`. Default: chosen by the server from the prompt's language."
|
|
46
|
+
},
|
|
47
|
+
"apiKeyEnv": {
|
|
48
|
+
"type": "string",
|
|
49
|
+
"minLength": 1,
|
|
50
|
+
"description": "Environment variable holding the server's key, when it was started with LAYA_API_KEY. Default: no key."
|
|
51
|
+
},
|
|
52
|
+
"timeoutMs": {
|
|
53
|
+
"type": "number",
|
|
54
|
+
"exclusiveMinimum": 0,
|
|
55
|
+
"description": "Timeout for one decision, in milliseconds. Default: 1500."
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"additionalProperties": false
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"type": "object",
|
|
62
|
+
"required": [
|
|
63
|
+
"type"
|
|
64
|
+
],
|
|
65
|
+
"properties": {
|
|
66
|
+
"type": {
|
|
67
|
+
"type": "string",
|
|
68
|
+
"const": "laya-local",
|
|
69
|
+
"description": "Experimental: pignon's own Laya worker (Apple Silicon, laya-mlx), installed from the repository's worker/ directory."
|
|
70
|
+
},
|
|
71
|
+
"timeoutMs": {
|
|
72
|
+
"type": "number",
|
|
73
|
+
"exclusiveMinimum": 0,
|
|
74
|
+
"description": "Timeout for one decision, in milliseconds. Default: thresholds.layaTimeoutMs."
|
|
75
|
+
},
|
|
76
|
+
"command": {
|
|
77
|
+
"type": "array",
|
|
78
|
+
"items": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"minLength": 1
|
|
81
|
+
},
|
|
82
|
+
"minItems": 1,
|
|
83
|
+
"description": "Command that starts the worker, e.g. [\"uv\", \"run\", \"--project\", \"/path/to/pignon/worker\", \"pignon-laya\"]. Default: found automatically (LAYA_PYTHON, a source checkout, then pignon-laya on PATH)."
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"additionalProperties": false
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"type": "object",
|
|
90
|
+
"required": [
|
|
91
|
+
"type"
|
|
92
|
+
],
|
|
93
|
+
"properties": {
|
|
94
|
+
"type": {
|
|
95
|
+
"type": "string",
|
|
96
|
+
"const": "jev",
|
|
97
|
+
"description": "TypeSafe's hosted Jev model. Sends each routed prompt (first 4000 characters) to the API."
|
|
98
|
+
},
|
|
99
|
+
"model": {
|
|
100
|
+
"type": "string",
|
|
101
|
+
"minLength": 1,
|
|
102
|
+
"description": "Jev model to pin, e.g. `jev-1.13.0`. Default: jev-latest."
|
|
103
|
+
},
|
|
104
|
+
"baseURL": {
|
|
105
|
+
"type": "string",
|
|
106
|
+
"minLength": 1,
|
|
107
|
+
"description": "API root. `https://openrouter.ai/api` goes through OpenRouter. Default: TypeSafe."
|
|
108
|
+
},
|
|
109
|
+
"apiKeyEnv": {
|
|
110
|
+
"type": "string",
|
|
111
|
+
"minLength": 1,
|
|
112
|
+
"description": "Environment variable holding the API key. Default: TYPESAFE_API_KEY."
|
|
113
|
+
},
|
|
114
|
+
"timeoutMs": {
|
|
115
|
+
"type": "number",
|
|
116
|
+
"exclusiveMinimum": 0,
|
|
117
|
+
"description": "Timeout for one decision, in milliseconds. Default: 1500."
|
|
118
|
+
},
|
|
119
|
+
"maxRetries": {
|
|
120
|
+
"type": "integer",
|
|
121
|
+
"minimum": 0,
|
|
122
|
+
"maximum": 3,
|
|
123
|
+
"description": "Retries after a failed attempt. Default: 0."
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"additionalProperties": false
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
},
|
|
130
|
+
"minItems": 1,
|
|
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."
|
|
133
|
+
},
|
|
134
|
+
"strategy": {
|
|
135
|
+
"type": "object",
|
|
136
|
+
"properties": {
|
|
137
|
+
"mode": {
|
|
138
|
+
"enum": [
|
|
139
|
+
"sequential",
|
|
140
|
+
"parallel"
|
|
141
|
+
],
|
|
142
|
+
"description": "`sequential` (default): ask the deciders in order until one is confident enough. `parallel`: ask them all at once, e.g. to compare them."
|
|
143
|
+
},
|
|
144
|
+
"escalateBelow": {
|
|
145
|
+
"type": "number",
|
|
146
|
+
"minimum": 0,
|
|
147
|
+
"maximum": 1,
|
|
148
|
+
"description": "Sequential: ask the next decider when tier confidence is below this. Default 0.75."
|
|
149
|
+
},
|
|
150
|
+
"pick": {
|
|
151
|
+
"enum": [
|
|
152
|
+
"most-confident",
|
|
153
|
+
"first"
|
|
154
|
+
],
|
|
155
|
+
"description": "Parallel: route on the most confident answer (default), or on the first decider in the list that answered (the others are only recorded)."
|
|
156
|
+
},
|
|
157
|
+
"budgetMs": {
|
|
158
|
+
"type": "number",
|
|
159
|
+
"exclusiveMinimum": 0,
|
|
160
|
+
"description": "Wall-time limit for one decision, all deciders included. Default 3000."
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
"additionalProperties": false,
|
|
164
|
+
"description": "How several deciders are combined."
|
|
165
|
+
},
|
|
166
|
+
"models": {
|
|
167
|
+
"type": "object",
|
|
168
|
+
"patternProperties": {
|
|
169
|
+
"^[A-Za-z0-9][A-Za-z0-9._-]*$": {
|
|
170
|
+
"type": "object",
|
|
171
|
+
"required": [
|
|
172
|
+
"provider",
|
|
173
|
+
"modelId",
|
|
174
|
+
"thinking"
|
|
175
|
+
],
|
|
176
|
+
"properties": {
|
|
177
|
+
"provider": {
|
|
178
|
+
"type": "string",
|
|
179
|
+
"minLength": 1,
|
|
180
|
+
"description": "Pi provider id, e.g. `openrouter` or `anthropic`."
|
|
181
|
+
},
|
|
182
|
+
"modelId": {
|
|
183
|
+
"type": "string",
|
|
184
|
+
"minLength": 1,
|
|
185
|
+
"description": "Model id within the provider. Check with `pi --list-models`."
|
|
186
|
+
},
|
|
187
|
+
"thinking": {
|
|
188
|
+
"enum": [
|
|
189
|
+
"off",
|
|
190
|
+
"low",
|
|
191
|
+
"medium",
|
|
192
|
+
"high",
|
|
193
|
+
"xhigh"
|
|
194
|
+
],
|
|
195
|
+
"description": "Pi thinking level to set with the model. Pi clamps it to what the model supports."
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
"additionalProperties": false
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
"description": "Named models, referenced from `tiers`. Merged over the built-in names (fast, balanced, reasoner, agent)."
|
|
202
|
+
},
|
|
203
|
+
"tiers": {
|
|
204
|
+
"type": "array",
|
|
205
|
+
"items": {
|
|
206
|
+
"type": "object",
|
|
207
|
+
"required": [
|
|
208
|
+
"id",
|
|
209
|
+
"criterion"
|
|
210
|
+
],
|
|
211
|
+
"properties": {
|
|
212
|
+
"id": {
|
|
213
|
+
"type": "string",
|
|
214
|
+
"pattern": "^[a-z][a-z0-9_-]*$",
|
|
215
|
+
"description": "Tier name, shown on decision cards. Lowercase letters, digits, `-` and `_`."
|
|
216
|
+
},
|
|
217
|
+
"criterion": {
|
|
218
|
+
"type": "string",
|
|
219
|
+
"minLength": 1,
|
|
220
|
+
"description": "How to recognize a task of this tier. This text is what the decision model reads."
|
|
221
|
+
},
|
|
222
|
+
"model": {
|
|
223
|
+
"anyOf": [
|
|
224
|
+
{
|
|
225
|
+
"type": "string",
|
|
226
|
+
"minLength": 1,
|
|
227
|
+
"description": "Name of an entry in `models`."
|
|
228
|
+
},
|
|
229
|
+
{
|
|
230
|
+
"type": "object",
|
|
231
|
+
"required": [
|
|
232
|
+
"provider",
|
|
233
|
+
"modelId",
|
|
234
|
+
"thinking"
|
|
235
|
+
],
|
|
236
|
+
"properties": {
|
|
237
|
+
"provider": {
|
|
238
|
+
"type": "string",
|
|
239
|
+
"minLength": 1,
|
|
240
|
+
"description": "Pi provider id, e.g. `openrouter` or `anthropic`."
|
|
241
|
+
},
|
|
242
|
+
"modelId": {
|
|
243
|
+
"type": "string",
|
|
244
|
+
"minLength": 1,
|
|
245
|
+
"description": "Model id within the provider. Check with `pi --list-models`."
|
|
246
|
+
},
|
|
247
|
+
"thinking": {
|
|
248
|
+
"enum": [
|
|
249
|
+
"off",
|
|
250
|
+
"low",
|
|
251
|
+
"medium",
|
|
252
|
+
"high",
|
|
253
|
+
"xhigh"
|
|
254
|
+
],
|
|
255
|
+
"description": "Pi thinking level to set with the model. Pi clamps it to what the model supports."
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
"additionalProperties": false
|
|
259
|
+
}
|
|
260
|
+
],
|
|
261
|
+
"description": "Model for every task of this tier. Use it, or both `direct` and `exploration`."
|
|
262
|
+
},
|
|
263
|
+
"direct": {
|
|
264
|
+
"anyOf": [
|
|
265
|
+
{
|
|
266
|
+
"type": "string",
|
|
267
|
+
"minLength": 1,
|
|
268
|
+
"description": "Name of an entry in `models`."
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"type": "object",
|
|
272
|
+
"required": [
|
|
273
|
+
"provider",
|
|
274
|
+
"modelId",
|
|
275
|
+
"thinking"
|
|
276
|
+
],
|
|
277
|
+
"properties": {
|
|
278
|
+
"provider": {
|
|
279
|
+
"type": "string",
|
|
280
|
+
"minLength": 1,
|
|
281
|
+
"description": "Pi provider id, e.g. `openrouter` or `anthropic`."
|
|
282
|
+
},
|
|
283
|
+
"modelId": {
|
|
284
|
+
"type": "string",
|
|
285
|
+
"minLength": 1,
|
|
286
|
+
"description": "Model id within the provider. Check with `pi --list-models`."
|
|
287
|
+
},
|
|
288
|
+
"thinking": {
|
|
289
|
+
"enum": [
|
|
290
|
+
"off",
|
|
291
|
+
"low",
|
|
292
|
+
"medium",
|
|
293
|
+
"high",
|
|
294
|
+
"xhigh"
|
|
295
|
+
],
|
|
296
|
+
"description": "Pi thinking level to set with the model. Pi clamps it to what the model supports."
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
"additionalProperties": false
|
|
300
|
+
}
|
|
301
|
+
],
|
|
302
|
+
"description": "Model for tasks that can be done without exploring the codebase."
|
|
303
|
+
},
|
|
304
|
+
"exploration": {
|
|
305
|
+
"anyOf": [
|
|
306
|
+
{
|
|
307
|
+
"type": "string",
|
|
308
|
+
"minLength": 1,
|
|
309
|
+
"description": "Name of an entry in `models`."
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
"type": "object",
|
|
313
|
+
"required": [
|
|
314
|
+
"provider",
|
|
315
|
+
"modelId",
|
|
316
|
+
"thinking"
|
|
317
|
+
],
|
|
318
|
+
"properties": {
|
|
319
|
+
"provider": {
|
|
320
|
+
"type": "string",
|
|
321
|
+
"minLength": 1,
|
|
322
|
+
"description": "Pi provider id, e.g. `openrouter` or `anthropic`."
|
|
323
|
+
},
|
|
324
|
+
"modelId": {
|
|
325
|
+
"type": "string",
|
|
326
|
+
"minLength": 1,
|
|
327
|
+
"description": "Model id within the provider. Check with `pi --list-models`."
|
|
328
|
+
},
|
|
329
|
+
"thinking": {
|
|
330
|
+
"enum": [
|
|
331
|
+
"off",
|
|
332
|
+
"low",
|
|
333
|
+
"medium",
|
|
334
|
+
"high",
|
|
335
|
+
"xhigh"
|
|
336
|
+
],
|
|
337
|
+
"description": "Pi thinking level to set with the model. Pi clamps it to what the model supports."
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
"additionalProperties": false
|
|
341
|
+
}
|
|
342
|
+
],
|
|
343
|
+
"description": "Model for tasks that need to explore the codebase first."
|
|
344
|
+
},
|
|
345
|
+
"explorationAllowed": {
|
|
346
|
+
"type": "boolean",
|
|
347
|
+
"description": "Set false to send tasks that need exploration to the next tier up. Default true."
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
"additionalProperties": false
|
|
351
|
+
},
|
|
352
|
+
"minItems": 2,
|
|
353
|
+
"maxItems": 8,
|
|
354
|
+
"description": "Difficulty tiers, easiest first. Replaces the default list as a whole."
|
|
355
|
+
},
|
|
356
|
+
"questions": {
|
|
357
|
+
"type": "object",
|
|
358
|
+
"properties": {
|
|
359
|
+
"version": {
|
|
360
|
+
"type": "string",
|
|
361
|
+
"minLength": 1,
|
|
362
|
+
"description": "Label stored with each decision. Change it whenever you edit the wording or the tier criteria."
|
|
363
|
+
},
|
|
364
|
+
"tierInstructions": {
|
|
365
|
+
"type": "string",
|
|
366
|
+
"minLength": 1
|
|
367
|
+
},
|
|
368
|
+
"explorationInstructions": {
|
|
369
|
+
"type": "string",
|
|
370
|
+
"minLength": 1
|
|
371
|
+
},
|
|
372
|
+
"explorationCriteria": {
|
|
373
|
+
"type": "object",
|
|
374
|
+
"properties": {
|
|
375
|
+
"yes": {
|
|
376
|
+
"type": "string",
|
|
377
|
+
"minLength": 1
|
|
378
|
+
},
|
|
379
|
+
"no": {
|
|
380
|
+
"type": "string",
|
|
381
|
+
"minLength": 1
|
|
382
|
+
}
|
|
383
|
+
},
|
|
384
|
+
"additionalProperties": false
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
"additionalProperties": false,
|
|
388
|
+
"description": "Wording of the questions sent to the decision model."
|
|
389
|
+
},
|
|
390
|
+
"confidenceSource": {
|
|
391
|
+
"enum": [
|
|
392
|
+
"reported",
|
|
393
|
+
"top-probability"
|
|
394
|
+
],
|
|
395
|
+
"description": "`reported`: the decision model's confidence. `top-probability`: the probability of the chosen answer, for checkpoints whose confidence is uncalibrated."
|
|
396
|
+
},
|
|
397
|
+
"thresholds": {
|
|
398
|
+
"type": "object",
|
|
399
|
+
"properties": {
|
|
400
|
+
"minConfidenceDowngrade": {
|
|
401
|
+
"type": "number",
|
|
402
|
+
"minimum": 0,
|
|
403
|
+
"description": "Confidence needed to downgrade, or to move in from a model outside the table."
|
|
404
|
+
},
|
|
405
|
+
"minConfidenceUpgrade": {
|
|
406
|
+
"type": "number",
|
|
407
|
+
"minimum": 0,
|
|
408
|
+
"description": "Confidence needed to upgrade."
|
|
409
|
+
},
|
|
410
|
+
"minConfidenceForm": {
|
|
411
|
+
"type": "number",
|
|
412
|
+
"minimum": 0,
|
|
413
|
+
"description": "Confidence needed to call a task direct rather than exploration."
|
|
414
|
+
},
|
|
415
|
+
"cacheGuardTokens": {
|
|
416
|
+
"type": "number",
|
|
417
|
+
"minimum": 0,
|
|
418
|
+
"description": "Context size above which lateral switches (and downgrades, when prices are unknown) are refused."
|
|
419
|
+
},
|
|
420
|
+
"minPromptsBetweenSwitches": {
|
|
421
|
+
"type": "number",
|
|
422
|
+
"minimum": 0,
|
|
423
|
+
"description": "Prompts to wait after a switch before the next downgrade or lateral switch."
|
|
424
|
+
},
|
|
425
|
+
"maxPaybackRequests": {
|
|
426
|
+
"type": "number",
|
|
427
|
+
"minimum": 0,
|
|
428
|
+
"description": "A downgrade must recoup its cache-miss cost within this many LLM requests."
|
|
429
|
+
},
|
|
430
|
+
"assumedOutputTokensPerRequest": {
|
|
431
|
+
"type": "number",
|
|
432
|
+
"minimum": 0,
|
|
433
|
+
"description": "Output per request assumed when estimating what a downgrade saves."
|
|
434
|
+
},
|
|
435
|
+
"layaTimeoutMs": {
|
|
436
|
+
"type": "number",
|
|
437
|
+
"minimum": 0,
|
|
438
|
+
"description": "Timeout for one decision from the local Laya worker, in milliseconds."
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
"additionalProperties": false
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
"additionalProperties": false,
|
|
445
|
+
"title": "pignon configuration"
|
|
446
|
+
}
|
package/src/compare.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/pignon-stats compare` and `/pignon-stats export`: how two deciders agree,
|
|
3
|
+
* over the decisions where both answered (parallel strategy, or sequential
|
|
4
|
+
* escalations).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join } from "node:path";
|
|
9
|
+
|
|
10
|
+
import { agentDir } from "./config/load.js";
|
|
11
|
+
|
|
12
|
+
import type { DeciderAttempt, RouterLogEntry, RoutingTable } from "./types.js";
|
|
13
|
+
|
|
14
|
+
type Answered = DeciderAttempt & { outcome: "answered" };
|
|
15
|
+
|
|
16
|
+
const answeredBy = (row: RouterLogEntry, decider: string): Answered | undefined =>
|
|
17
|
+
row.attempts?.find((a): a is Answered => a.decider === decider && a.outcome === "answered");
|
|
18
|
+
|
|
19
|
+
/** The two deciders that answered most often, in order of first appearance. */
|
|
20
|
+
export function comparedPair(rows: RouterLogEntry[]): [string, string] | null {
|
|
21
|
+
const counts = new Map<string, number>();
|
|
22
|
+
for (const row of rows) {
|
|
23
|
+
for (const a of row.attempts ?? []) {
|
|
24
|
+
if (a.outcome === "answered") counts.set(a.decider, (counts.get(a.decider) ?? 0) + 1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const top = [...counts].sort((x, y) => y[1] - x[1]).slice(0, 2);
|
|
28
|
+
if (top.length < 2) return null;
|
|
29
|
+
const order = [...counts.keys()];
|
|
30
|
+
const [a, b] = top.map(([id]) => id).sort((x, y) => order.indexOf(x) - order.indexOf(y));
|
|
31
|
+
return [a!, b!];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildCompareLines(rows: RouterLogEntry[], table: RoutingTable): string[] | null {
|
|
35
|
+
const pair = comparedPair(rows);
|
|
36
|
+
if (!pair) return null;
|
|
37
|
+
const [a, b] = pair;
|
|
38
|
+
const both = rows
|
|
39
|
+
.map((row) => ({ a: answeredBy(row, a), b: answeredBy(row, b) }))
|
|
40
|
+
.filter((p): p is { a: Answered; b: Answered } => p.a !== undefined && p.b !== undefined);
|
|
41
|
+
if (both.length === 0) return null;
|
|
42
|
+
|
|
43
|
+
const pct = (n: number) => `${Math.round((100 * n) / both.length)}%`;
|
|
44
|
+
const tierAgree = both.filter((p) => p.a.tier === p.b.tier).length;
|
|
45
|
+
const formAgree = both.filter((p) => p.a.needsExploration === p.b.needsExploration).length;
|
|
46
|
+
|
|
47
|
+
// Confusion matrix over the tiers seen, in table order first.
|
|
48
|
+
const seen = new Set(both.flatMap((p) => [p.a.tier ?? "?", p.b.tier ?? "?"]));
|
|
49
|
+
const tiers = [...table.map((t) => t.id).filter((id) => seen.has(id)), ...[...seen].filter((id) => !table.some((t) => t.id === id))];
|
|
50
|
+
const width = Math.max(10, ...tiers.map((t) => t.length + 2));
|
|
51
|
+
const corner = `${a} ↓ ${b} →`;
|
|
52
|
+
const matrix = [
|
|
53
|
+
`${corner.padEnd(Math.max(corner.length + 2, width))}${tiers.map((t) => t.padStart(width)).join("")}`,
|
|
54
|
+
...tiers.map((row) => {
|
|
55
|
+
const cells = tiers.map((col) => both.filter((p) => (p.a.tier ?? "?") === row && (p.b.tier ?? "?") === col).length);
|
|
56
|
+
return `${row.padEnd(Math.max(corner.length + 2, width))}${cells.map((n) => String(n).padStart(width)).join("")}`;
|
|
57
|
+
}),
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const perDecider = (id: string) => {
|
|
61
|
+
const attempts = rows.flatMap((r) => r.attempts ?? []).filter((x) => x.decider === id);
|
|
62
|
+
const answered = attempts.filter((x): x is Answered => x.outcome === "answered");
|
|
63
|
+
const latencies = answered.map((x) => x.latencyMs ?? 0).sort((x, y) => x - y);
|
|
64
|
+
const confidences = answered.map((x) => x.tierConfidence ?? 0);
|
|
65
|
+
const cost = attempts.reduce((sum, x) => sum + (x.costUsd ?? 0), 0);
|
|
66
|
+
return {
|
|
67
|
+
confidence: confidences.length ? (confidences.reduce((x, y) => x + y, 0) / confidences.length).toFixed(2) : "—",
|
|
68
|
+
latency: latencies.length ? `${percentile(latencies, 0.5)}/${percentile(latencies, 0.95)} ms` : "—",
|
|
69
|
+
failures: String(attempts.filter((x) => x.outcome === "failed").length),
|
|
70
|
+
cost: cost > 0 ? `$${cost.toFixed(5)}` : "—",
|
|
71
|
+
used: String(attempts.filter((x) => x.used).length),
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
const stats = [perDecider(a), perDecider(b)];
|
|
75
|
+
const col = Math.max(12, a.length + 2, b.length + 2);
|
|
76
|
+
const row = (label: string, key: keyof ReturnType<typeof perDecider>) =>
|
|
77
|
+
`${label.padEnd(18)}${stats.map((s) => s[key].padStart(col)).join("")}`;
|
|
78
|
+
|
|
79
|
+
return [
|
|
80
|
+
`pignon compare — ${a} vs ${b} · ${both.length} decisions answered by both`,
|
|
81
|
+
`tier agreement ${pct(tierAgree)} · exploration agreement ${pct(formAgree)}`,
|
|
82
|
+
...matrix,
|
|
83
|
+
`${"".padEnd(18)}${[a, b].map((id) => id.padStart(col)).join("")}`,
|
|
84
|
+
row("mean confidence", "confidence"),
|
|
85
|
+
row("latency p50/p95", "latency"),
|
|
86
|
+
row("failures", "failures"),
|
|
87
|
+
row("cost", "cost"),
|
|
88
|
+
row("routed on", "used"),
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function percentile(sorted: number[], p: number): number {
|
|
93
|
+
return Math.round(sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))]!);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Default export location: outside any project, next to Pi's own files. */
|
|
97
|
+
export function defaultExportPath(now = new Date(), env: NodeJS.ProcessEnv = process.env): string {
|
|
98
|
+
const stamp = now.toISOString().replace(/[:.]/g, "-");
|
|
99
|
+
return join(agentDir(env), "pignon-exports", `decisions-${stamp}.jsonl`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Write one JSON line per decision. Entries hold a prompt hash and length,
|
|
104
|
+
* never the prompt, so the file is safe to share.
|
|
105
|
+
*/
|
|
106
|
+
export function exportDecisions(rows: RouterLogEntry[], path: string): void {
|
|
107
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
108
|
+
writeFileSync(path, rows.map((row) => JSON.stringify(row)).join("\n") + "\n");
|
|
109
|
+
}
|