pi-pstack-adapter 0.4.0 → 0.5.0
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 +7 -0
- package/README.md +15 -1
- package/VERSION +1 -1
- package/adapter-skills/setup.md +39 -5
- package/index.ts +38 -17
- package/package.json +1 -1
- package/src/config.ts +173 -8
- package/src/echo.ts +9 -0
- package/src/generate.ts +17 -5
- package/src/namespace.ts +3 -1
- package/src/status.ts +11 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented here. release-please writes new entries from Conventional Commit PR titles.
|
|
4
4
|
|
|
5
|
+
## [0.5.0](https://github.com/elderfo/pi-pstack-adapter/compare/v0.4.0...v0.5.0) (2026-10-15)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **models:** add a per-role thinking level ([#12](https://github.com/elderfo/pi-pstack-adapter/issues/12)) ([ae99d0c](https://github.com/elderfo/pi-pstack-adapter/commit/ae99d0cb9ab6c516a6847eb47ea44a0b5ec7c141))
|
|
11
|
+
|
|
5
12
|
## [0.4.0](https://github.com/elderfo/pi-pstack-adapter/compare/v0.3.0...v0.4.0) (2026-10-14)
|
|
6
13
|
|
|
7
14
|
|
package/README.md
CHANGED
|
@@ -150,6 +150,20 @@ In a noninteractive session, set roles with `PISTACK_MODELS`:
|
|
|
150
150
|
PISTACK_MODELS='feature=provider/model,bug-fix=provider/other-model' pi
|
|
151
151
|
```
|
|
152
152
|
|
|
153
|
+
Add a thinking level to a role with a `:level` suffix. The levels are `off`, `minimal`, `low`,
|
|
154
|
+
`medium`, `high`, `xhigh`, and `max`:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
PISTACK_MODELS='feature=provider/model:medium,judgment=provider/other-model:high' pi
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Role names are lowercase letters, digits, and hyphens. A model value is printable ASCII with no spaces or markup characters, at most 128 characters. A config entry that breaks these rules stops the adapter at startup with an error that names the file, so fix the key there and restart Pi.
|
|
161
|
+
|
|
162
|
+
A role without a level passes none, so the delegation backend applies its own default. A role you leave unset inherits the
|
|
163
|
+
session model and the session thinking level, so a session at high thinking makes every
|
|
164
|
+
inheriting delegate expensive. The `pstack_adapter` tool's `set_model` action takes the same
|
|
165
|
+
level in its `thinking` parameter. A level the model does not offer is clamped by Pi at run time.
|
|
166
|
+
|
|
153
167
|
## Run Pi noninteractively
|
|
154
168
|
|
|
155
169
|
A noninteractive session, such as `pi --print`, cannot answer the download question. To allow
|
|
@@ -188,7 +202,7 @@ settings to `.pi/pi-pstack-adapter.json`. Environment variables override both fi
|
|
|
188
202
|
| `PISTACK_UPSTREAM_PLUGIN_PATH` | Selects the plugin directory inside the checkout. |
|
|
189
203
|
| `PISTACK_ALLOW_BOOTSTRAP` | Allows a noninteractive first download. |
|
|
190
204
|
| `PISTACK_CACHE_DIR` | Changes the cache directory. |
|
|
191
|
-
| `PISTACK_MODELS` | Assigns models as comma-separated `role=provider/model` pairs. |
|
|
205
|
+
| `PISTACK_MODELS` | Assigns models as comma-separated `role=provider/model[:thinking]` pairs. |
|
|
192
206
|
| `PISTACK_DELEGATION` | Sets the delegation provider to try first: `t3` (default) or `pi-subagents`. The config file key is `delegation`. |
|
|
193
207
|
|
|
194
208
|
## Security and trust
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.5.0
|
package/adapter-skills/setup.md
CHANGED
|
@@ -27,6 +27,13 @@ Roles used by pstack workflows.
|
|
|
27
27
|
The status report lists the same roles, so read it back rather than trusting this table if the
|
|
28
28
|
adapter has been upgraded.
|
|
29
29
|
|
|
30
|
+
Each role also has a thinking level: `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or
|
|
31
|
+
`max`. Upstream pstack calls this a reasoning budget and encodes it in the model slug. A role with
|
|
32
|
+
no thinking level passes none, so the delegation backend applies its own default. A role left on
|
|
33
|
+
`inherit` uses the parent session's model and thinking level, so a parent at high thinking makes
|
|
34
|
+
every inheriting child expensive. Model choice often affects cost more than thinking level does,
|
|
35
|
+
so consider a cheaper model for the bulk roles as well.
|
|
36
|
+
|
|
30
37
|
Steps.
|
|
31
38
|
|
|
32
39
|
1. Call the adapter tool with `action: "models"` to list the provider and model ids this Pi
|
|
@@ -34,13 +41,40 @@ Steps.
|
|
|
34
41
|
2. Ask the user which model each role should use. Offer the listed ids, and offer
|
|
35
42
|
`inherit` for any role they do not care about. Use the structured question tool when it is
|
|
36
43
|
available; otherwise ask in plain text. Only ids from step 1 are accepted.
|
|
37
|
-
3.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
44
|
+
3. Ask for a thinking level. Do not assume the user wants none. Skip this step only when every
|
|
45
|
+
role is on `inherit`. It applies to every role that got a model in step 2. A role on
|
|
46
|
+
`inherit` cannot have a level, so tell the user that and say that those roles follow the
|
|
47
|
+
parent session's level. Ask one single-select question first. Put the whole choice in each option
|
|
48
|
+
title so the user can pick without opening anything else, for example "Balanced: code roles
|
|
49
|
+
medium, judgment high, strongest-judgment xhigh (suggested)".
|
|
50
|
+
|
|
51
|
+
| choice | code roles (`feature`, `refactoring`, `bug-fix`, `perf-issue`, `hillclimb`) | `judgment` | `strongest-judgment` |
|
|
52
|
+
| --- | --- | --- | --- |
|
|
53
|
+
| Balanced (suggest this first) | `medium` | `high` | `xhigh` |
|
|
54
|
+
| Low cost | `low` | `medium` | `high` |
|
|
55
|
+
| Maximum quality | `high` | `xhigh` | `max` |
|
|
56
|
+
| Backend default | none | none | none |
|
|
57
|
+
| Custom | ask once per column, as a single-select of level names | | |
|
|
58
|
+
|
|
59
|
+
These are starting points, not measurements. They keep the heaviest reasoning on the roles
|
|
60
|
+
that make the hardest calls. Offer Custom for a user who wants different levels. Ask once for
|
|
61
|
+
the code roles, once for `judgment`, and once for `strongest-judgment`. Ask a role on its own
|
|
62
|
+
only when the user says it should differ from its group. Backend default passes no level and
|
|
63
|
+
leaves the choice to the delegation backend. A Custom user who wants thinking disabled picks
|
|
64
|
+
`off`.
|
|
65
|
+
A model may not offer every level. A level it lacks is clamped when it runs, so say that
|
|
66
|
+
instead of asking again. The tool rejects any level other than `off` on a model that has no reasoning support.
|
|
67
|
+
When it does, set that role with no level and tell the user which role has none.
|
|
68
|
+
4. For each role, call the adapter tool with `action: "set_model"`, the role, the chosen
|
|
69
|
+
`provider/model` id, and `thinking` when step 3 picked a level. Pass `scope: "project"` when
|
|
70
|
+
the user wants the choice to apply only to this repository. Calling `set_model` for a role
|
|
71
|
+
again without `thinking` clears a level set earlier, so always pass it again.
|
|
72
|
+
5. Read back `action: "status"` and show the user what was written, including the thinking
|
|
73
|
+
column.
|
|
41
74
|
|
|
42
75
|
For a scripted environment, set `PISTACK_MODELS` instead, for example
|
|
43
|
-
`PISTACK_MODELS=feature=anthropic/claude-sonnet-4-5,bug-fix=openai/gpt-5`. The
|
|
76
|
+
`PISTACK_MODELS=feature=anthropic/claude-sonnet-4-5:medium,bug-fix=openai/gpt-5:high`. The
|
|
77
|
+
optional `:level` suffix sets the thinking level. The environment wins
|
|
44
78
|
over both config files, so CI never needs this dialog.
|
|
45
79
|
|
|
46
80
|
## Upstream source
|
package/index.ts
CHANGED
|
@@ -13,13 +13,20 @@ import {
|
|
|
13
13
|
type AdapterConfig,
|
|
14
14
|
type ConfigFile,
|
|
15
15
|
type ConfigLayer,
|
|
16
|
+
THINKING_LEVELS,
|
|
16
17
|
environmentLayer,
|
|
18
|
+
invalidRoleMessage,
|
|
19
|
+
isValidModelSpec,
|
|
20
|
+
isValidRole,
|
|
21
|
+
joinModelSpec,
|
|
17
22
|
mergeConfig,
|
|
23
|
+
planModelAssignment,
|
|
18
24
|
readConfigFile,
|
|
19
25
|
trustWarning,
|
|
20
26
|
updateConfigFile,
|
|
21
27
|
} from "./src/config.ts";
|
|
22
28
|
import { generate } from "./src/generate.ts";
|
|
29
|
+
import { safeEcho } from "./src/echo.ts";
|
|
23
30
|
import { MODE_ENTRY_TYPE, modeFromEntries, modeInstruction } from "./src/mode.ts";
|
|
24
31
|
import { CONFIG_FILE_NAME, agentDir, cacheRoot } from "./src/paths.ts";
|
|
25
32
|
import { bashToolContext, probeExecutables } from "./src/executables.ts";
|
|
@@ -56,9 +63,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
56
63
|
startupError = undefined;
|
|
57
64
|
modeActive = modeFromEntries(ctx.sessionManager.getBranch() as never[]);
|
|
58
65
|
|
|
59
|
-
const config = loadConfig(ctx);
|
|
60
66
|
try {
|
|
61
|
-
state = await bringUpUpstream(
|
|
67
|
+
state = await bringUpUpstream(loadConfig(ctx), ctx);
|
|
62
68
|
} catch (error) {
|
|
63
69
|
startupError = error as Error;
|
|
64
70
|
if (ctx.hasUI) ctx.ui.notify(`pstack adapter: ${(error as Error).message}`, "error");
|
|
@@ -135,6 +141,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
135
141
|
skill: Type.Optional(Type.String({ description: "Skill name for `check`, with or without the namespace prefix" })),
|
|
136
142
|
role: Type.Optional(Type.String({ description: "pstack model role for `set_model`" })),
|
|
137
143
|
model: Type.Optional(Type.String({ description: "`provider/model` id, or `inherit` to clear the role" })),
|
|
144
|
+
thinking: Type.Optional(
|
|
145
|
+
Type.String({
|
|
146
|
+
description: `Thinking level for \`set_model\`: ${THINKING_LEVELS.join(", ")}. Omit to pass no level, which also clears a level set earlier.`,
|
|
147
|
+
}),
|
|
148
|
+
),
|
|
138
149
|
repo: Type.Optional(Type.String({ description: "Upstream repository URL for `set_source`" })),
|
|
139
150
|
ref: Type.Optional(Type.String({ description: "Branch, tag, or commit for `set_source`" })),
|
|
140
151
|
localPath: Type.Optional(Type.String({ description: "Local pstack checkout for `set_source`" })),
|
|
@@ -195,6 +206,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
195
206
|
skill?: string;
|
|
196
207
|
role?: string;
|
|
197
208
|
model?: string;
|
|
209
|
+
thinking?: string;
|
|
198
210
|
repo?: string;
|
|
199
211
|
ref?: string;
|
|
200
212
|
localPath?: string;
|
|
@@ -234,7 +246,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
234
246
|
case "models":
|
|
235
247
|
return renderModels(ctx);
|
|
236
248
|
case "set_model":
|
|
237
|
-
return setModel(params.role, params.model, params.scope ?? "user", ctx);
|
|
249
|
+
return setModel(params.role, params.model, params.thinking, params.scope ?? "user", ctx);
|
|
238
250
|
case "set_source":
|
|
239
251
|
return setSource(params, params.scope ?? "user", ctx);
|
|
240
252
|
default:
|
|
@@ -254,7 +266,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
254
266
|
(candidate) => candidate.generatedName === requested || candidate.generatedName === `${prefix}${bare}`,
|
|
255
267
|
);
|
|
256
268
|
if (!skill) {
|
|
257
|
-
return `No pstack skill named ${
|
|
269
|
+
return `No pstack skill named ${safeEcho(requested)}. Run the adapter tool with action "status" for the full list.`;
|
|
258
270
|
}
|
|
259
271
|
const resolved = shellState(ctx, pi.getActiveTools());
|
|
260
272
|
// An inactive bash tool or an unresolved shell is already a blocker, so do not spawn anything.
|
|
@@ -274,27 +286,36 @@ export default function (pi: ExtensionAPI) {
|
|
|
274
286
|
function renderModels(ctx: ExtensionContext): string {
|
|
275
287
|
const scoped = ctx.scopedModels ?? [];
|
|
276
288
|
const models = scoped.length > 0 ? scoped.map((entry) => entry.model) : ctx.modelRegistry.getAvailable();
|
|
277
|
-
const ids = models.map((model) => `${model.provider}/${model.id}`).sort();
|
|
289
|
+
const ids = models.map((model) => `${model.provider}/${model.id}`).filter(isValidModelSpec).sort();
|
|
278
290
|
if (ids.length === 0) return "No models are available in this Pi installation.";
|
|
279
291
|
return `Available model ids.\n\n${ids.map((id) => `- \`${id}\``).join("\n")}`;
|
|
280
292
|
}
|
|
281
293
|
|
|
282
|
-
function setModel(
|
|
294
|
+
function setModel(
|
|
295
|
+
role: string | undefined,
|
|
296
|
+
model: string | undefined,
|
|
297
|
+
thinking: string | undefined,
|
|
298
|
+
scope: "user" | "project",
|
|
299
|
+
ctx: ExtensionContext,
|
|
300
|
+
): string {
|
|
283
301
|
if (!role) return "set_model needs a `role`.";
|
|
284
302
|
if (!model) return "set_model needs a `model`, or `inherit` to clear the role.";
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
303
|
+
if (!isValidRole(role)) return invalidRoleMessage(role);
|
|
304
|
+
const available = ctx.modelRegistry
|
|
305
|
+
.getAvailable()
|
|
306
|
+
.map((candidate) => ({ id: `${candidate.provider}/${candidate.id}`, reasoning: candidate.reasoning }));
|
|
307
|
+
const plan = planModelAssignment(model, thinking, available);
|
|
308
|
+
if (plan.kind === "rejected") return plan.message;
|
|
289
309
|
const path = configPath(scope, ctx);
|
|
290
|
-
const
|
|
291
|
-
const models = { ...
|
|
292
|
-
if (
|
|
293
|
-
else models[role] =
|
|
310
|
+
const stored = readConfigFile(path)?.values.models;
|
|
311
|
+
const models = typeof stored === "object" && stored !== null && !Array.isArray(stored) ? { ...stored } : {};
|
|
312
|
+
if (plan.kind === "inherit") delete models[role];
|
|
313
|
+
else models[role] = joinModelSpec(plan.spec);
|
|
294
314
|
updateConfigFile(path, { models });
|
|
295
|
-
return
|
|
296
|
-
|
|
297
|
-
|
|
315
|
+
if (plan.kind === "inherit") return `Role \`${role}\` now inherits the parent session model and thinking. Written to ${path}.`;
|
|
316
|
+
return plan.spec.thinking === undefined
|
|
317
|
+
? `Role \`${role}\` now uses \`${plan.spec.model}\` at the model's default thinking. Written to ${path}.`
|
|
318
|
+
: `Role \`${role}\` now uses \`${plan.spec.model}\` with thinking \`${plan.spec.thinking}\`. Written to ${path}.`;
|
|
298
319
|
}
|
|
299
320
|
|
|
300
321
|
async function setSource(
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, isAbsolute, resolve } from "node:path";
|
|
3
3
|
import { CERTIFIED } from "./certified.ts";
|
|
4
|
+
import { safeEcho } from "./echo.ts";
|
|
4
5
|
import { DEFAULT_NAMESPACE, validateNamespace } from "./namespace.ts";
|
|
5
6
|
import { expandHome } from "./paths.ts";
|
|
6
7
|
import { DEFAULT_DELEGATION, DELEGATION_PROVIDERS, isDelegationProvider } from "./delegation.ts";
|
|
@@ -66,8 +67,9 @@ export function readConfigFile(path: string): ConfigLayer | undefined {
|
|
|
66
67
|
let parsed: unknown;
|
|
67
68
|
try {
|
|
68
69
|
parsed = JSON.parse(raw);
|
|
69
|
-
} catch
|
|
70
|
-
|
|
70
|
+
} catch {
|
|
71
|
+
// The parser's message quotes raw file text, which can reach the agent. Say only where to look.
|
|
72
|
+
throw new Error(`${path} is not valid JSON. Fix the syntax in that file and restart Pi.`);
|
|
71
73
|
}
|
|
72
74
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
73
75
|
throw new Error(`${path} must contain a JSON object.`);
|
|
@@ -87,7 +89,139 @@ export function updateConfigFile(path: string, patch: ConfigFile): ConfigFile {
|
|
|
87
89
|
return next;
|
|
88
90
|
}
|
|
89
91
|
|
|
90
|
-
/** `
|
|
92
|
+
/** Thinking levels Pi accepts as a `:level` suffix on a model id. */
|
|
93
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
94
|
+
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
95
|
+
|
|
96
|
+
export function isThinkingLevel(value: string): value is ThinkingLevel {
|
|
97
|
+
return (THINKING_LEVELS as readonly string[]).includes(value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ModelSpec {
|
|
101
|
+
readonly model: string;
|
|
102
|
+
readonly thinking?: ThinkingLevel;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Splits `provider/model[:thinking]`. Only a known level is a suffix, so a model id that
|
|
107
|
+
* itself contains a colon, such as `ollama/qwen:7b`, stays whole. Pi parses ids the same way.
|
|
108
|
+
*/
|
|
109
|
+
export function splitModelSpec(spec: string): ModelSpec {
|
|
110
|
+
const separator = spec.lastIndexOf(":");
|
|
111
|
+
if (separator > 0) {
|
|
112
|
+
const suffix = spec.slice(separator + 1).toLowerCase();
|
|
113
|
+
if (isThinkingLevel(suffix)) return { model: spec.slice(0, separator), thinking: suffix };
|
|
114
|
+
}
|
|
115
|
+
return { model: spec };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function joinModelSpec(spec: ModelSpec): string {
|
|
119
|
+
return spec.thinking === undefined ? spec.model : `${spec.model}:${spec.thinking}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface AvailableModel {
|
|
123
|
+
readonly id: string;
|
|
124
|
+
readonly reasoning?: boolean;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export type ModelAssignment =
|
|
128
|
+
| { readonly kind: "inherit" }
|
|
129
|
+
| { readonly kind: "assign"; readonly spec: ModelSpec }
|
|
130
|
+
| { readonly kind: "rejected"; readonly message: string };
|
|
131
|
+
|
|
132
|
+
/** Validates a `set_model` request. `available` holds `provider/id` entries from the registry. */
|
|
133
|
+
export function planModelAssignment(
|
|
134
|
+
model: string,
|
|
135
|
+
thinking: string | undefined,
|
|
136
|
+
available: readonly AvailableModel[],
|
|
137
|
+
): ModelAssignment {
|
|
138
|
+
if (model.trim().toLowerCase() === "inherit") {
|
|
139
|
+
if (thinking?.trim()) {
|
|
140
|
+
return {
|
|
141
|
+
kind: "rejected",
|
|
142
|
+
message:
|
|
143
|
+
"A role that inherits the parent session has no thinking level of its own. Pass a `provider/model` with `thinking`, or drop `thinking`.",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return { kind: "inherit" };
|
|
147
|
+
}
|
|
148
|
+
const whole = model.trim();
|
|
149
|
+
// A registry id that itself ends in a level word, such as `x/model:low`, wins over a split.
|
|
150
|
+
const exact = available.some((candidate) => candidate.id === whole);
|
|
151
|
+
const spec = exact ? { model: whole } : splitModelSpec(whole);
|
|
152
|
+
const requested = thinking?.trim().toLowerCase() || undefined;
|
|
153
|
+
if (requested !== undefined && !isThinkingLevel(requested)) {
|
|
154
|
+
return {
|
|
155
|
+
kind: "rejected",
|
|
156
|
+
message: `Unknown thinking level \`${safeEcho(thinking).replaceAll('"', "")}\`. Use one of ${THINKING_LEVELS.map((level) => `\`${level}\``).join(", ")}.`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
if (requested !== undefined && spec.thinking !== undefined && spec.thinking !== requested) {
|
|
160
|
+
return {
|
|
161
|
+
kind: "rejected",
|
|
162
|
+
message: `\`model\` names thinking \`${spec.thinking}\` but \`thinking\` says \`${requested}\`. Pass one of them.`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const level = requested !== undefined && isThinkingLevel(requested) ? requested : spec.thinking;
|
|
166
|
+
if (!isValidModelSpec(spec.model)) return { kind: "rejected", message: invalidModelMessage(spec.model) };
|
|
167
|
+
if (exact && requested === undefined && splitModelSpec(whole).thinking !== undefined) {
|
|
168
|
+
// Stored whole, `x/model:low` would read back as model `x/model` at thinking `low`.
|
|
169
|
+
return {
|
|
170
|
+
kind: "rejected",
|
|
171
|
+
message: `Model \`${whole}\` ends in a thinking-level word, so a plain assignment would be misread. Pass \`thinking\` explicitly, for example \`high\`.`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const known = available.find((candidate) => candidate.id === spec.model);
|
|
175
|
+
if (!known) {
|
|
176
|
+
return {
|
|
177
|
+
kind: "rejected",
|
|
178
|
+
message: `No model \`${spec.model}\` is available in this Pi installation. Call this tool with action "models" for real ids, or pass "inherit".`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (level !== undefined && level !== "off" && known.reasoning === false) {
|
|
182
|
+
return {
|
|
183
|
+
kind: "rejected",
|
|
184
|
+
message: `Model \`${spec.model}\` does not support thinking. Drop \`thinking\`, or pick a reasoning model.`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
const assigned: ModelSpec = { model: spec.model, thinking: level };
|
|
188
|
+
if (!isValidModelSpec(joinModelSpec(assigned))) return { kind: "rejected", message: invalidModelMessage(joinModelSpec(assigned)) };
|
|
189
|
+
return { kind: "assign", spec: assigned };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Role names reach generated markdown and status tables, so they stay plain kebab-case. */
|
|
193
|
+
const ROLE_PATTERN = /^[a-z][a-z0-9-]{0,63}$/;
|
|
194
|
+
/**
|
|
195
|
+
* A model spec is also rendered into markdown the agent reads, so it is printable ASCII with the
|
|
196
|
+
* markup characters removed. An allow-list of ASCII keeps invisible text (tag characters, zero-width
|
|
197
|
+
* and bidi controls) out; catalogue ids such as OpenRouter's `~anthropic/...` aliases are ASCII.
|
|
198
|
+
* A trailing colon is never a valid id or level.
|
|
199
|
+
*/
|
|
200
|
+
const PRINTABLE_ASCII = /^[\x21-\x7e]+$/;
|
|
201
|
+
const MARKUP_CHARACTERS = /[`|<>[\]()\\*]/;
|
|
202
|
+
|
|
203
|
+
const MAX_MODEL_SPEC_LENGTH = 128;
|
|
204
|
+
|
|
205
|
+
export function isValidModelSpec(model: string): boolean {
|
|
206
|
+
const plain = (value: string) => PRINTABLE_ASCII.test(value) && !MARKUP_CHARACTERS.test(value) && !value.endsWith(":");
|
|
207
|
+
if (model.length > MAX_MODEL_SPEC_LENGTH || !plain(model)) return false;
|
|
208
|
+
// `x/model::high` splits to `x/model:`, which is no model id.
|
|
209
|
+
return plain(splitModelSpec(model).model);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function isValidRole(role: string): boolean {
|
|
213
|
+
return ROLE_PATTERN.test(role);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function invalidRoleMessage(role: string): string {
|
|
217
|
+
return `Invalid role name ${safeEcho(role)}. Use lowercase letters, digits, and hyphens, starting with a letter, for example "bug-fix".`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function invalidModelMessage(model: unknown): string {
|
|
221
|
+
return `Invalid model value ${typeof model === "string" ? `(${model.length} characters)` : safeEcho(model)}. Use provider/model or provider/model:thinking with no spaces or markup characters, at most 128 characters.`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** `role=provider/model[:thinking],role2=provider/model2` */
|
|
91
225
|
export function parseModelRoles(value: string): Record<string, string> {
|
|
92
226
|
const roles: Record<string, string> = {};
|
|
93
227
|
for (const pair of value.split(",")) {
|
|
@@ -95,9 +229,15 @@ export function parseModelRoles(value: string): Record<string, string> {
|
|
|
95
229
|
if (trimmed === "") continue;
|
|
96
230
|
const separator = trimmed.indexOf("=");
|
|
97
231
|
if (separator <= 0) {
|
|
98
|
-
throw new Error(`Invalid model role assignment ${
|
|
232
|
+
throw new Error(`Invalid model role assignment ${safeEcho(trimmed)}. Use role=provider/model or role=provider/model:thinking.`);
|
|
99
233
|
}
|
|
100
|
-
|
|
234
|
+
const role = trimmed.slice(0, separator).trim();
|
|
235
|
+
const model = trimmed.slice(separator + 1).trim();
|
|
236
|
+
if (!isValidRole(role)) throw new Error(invalidRoleMessage(role));
|
|
237
|
+
// An empty value is an unset variable that expanded to nothing, not a model.
|
|
238
|
+
if (model === "") continue;
|
|
239
|
+
if (!isValidModelSpec(model)) throw new Error(invalidModelMessage(model));
|
|
240
|
+
roles[role] = model;
|
|
101
241
|
}
|
|
102
242
|
return roles;
|
|
103
243
|
}
|
|
@@ -111,7 +251,13 @@ export function environmentLayer(env: NodeJS.ProcessEnv): ConfigLayer {
|
|
|
111
251
|
if (env[ENV.pluginPath]) values.pluginPath = env[ENV.pluginPath];
|
|
112
252
|
if (env[ENV.cacheDir]) values.cacheDir = env[ENV.cacheDir];
|
|
113
253
|
if (env[ENV.allowBootstrap]) values.allowBootstrap = !NEGATIVE.has((env[ENV.allowBootstrap] as string).trim().toLowerCase());
|
|
114
|
-
if (env[ENV.models])
|
|
254
|
+
if (env[ENV.models]) {
|
|
255
|
+
try {
|
|
256
|
+
values.models = parseModelRoles(env[ENV.models] as string);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
throw new Error(`${ENV.models}: ${(error as Error).message}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
115
261
|
if (env[ENV.delegation]) values.delegation = (env[ENV.delegation] as string).trim().toLowerCase();
|
|
116
262
|
return { label: "environment", values };
|
|
117
263
|
}
|
|
@@ -134,7 +280,7 @@ export function mergeConfig(layers: readonly ConfigLayer[]): AdapterConfig {
|
|
|
134
280
|
merged.ref = undefined;
|
|
135
281
|
}
|
|
136
282
|
if (key === "models") {
|
|
137
|
-
merged.models = { ...merged.models, ...(value
|
|
283
|
+
merged.models = { ...merged.models, ...validatedModels(value, layer.label) };
|
|
138
284
|
} else {
|
|
139
285
|
(merged as Record<string, unknown>)[key] = value;
|
|
140
286
|
}
|
|
@@ -162,6 +308,25 @@ export function mergeConfig(layers: readonly ConfigLayer[]): AdapterConfig {
|
|
|
162
308
|
};
|
|
163
309
|
}
|
|
164
310
|
|
|
311
|
+
/** A bad `models` entry fails with the layer that holds it, so status can still be reached after a fix. */
|
|
312
|
+
function validatedModels(value: unknown, label: string): Record<string, string> {
|
|
313
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
314
|
+
throw new Error(`${label} sets \`models\` to something other than an object of role to provider/model.`);
|
|
315
|
+
}
|
|
316
|
+
const models: Record<string, string> = {};
|
|
317
|
+
for (const [role, model] of Object.entries(value)) {
|
|
318
|
+
if (!isValidRole(role)) {
|
|
319
|
+
throw new Error(`${label}: ${invalidRoleMessage(role)} Edit that key in \`models\` in the named file, then restart Pi.`);
|
|
320
|
+
}
|
|
321
|
+
if (model === "") continue;
|
|
322
|
+
if (typeof model !== "string" || !isValidModelSpec(model)) {
|
|
323
|
+
throw new Error(`${label}: role ${safeEcho(role)}. ${invalidModelMessage(model)}`);
|
|
324
|
+
}
|
|
325
|
+
Object.defineProperty(models, role, { value: model, enumerable: true, writable: true, configurable: true });
|
|
326
|
+
}
|
|
327
|
+
return models;
|
|
328
|
+
}
|
|
329
|
+
|
|
165
330
|
/**
|
|
166
331
|
* The certified commit is the default, not a floor. Naming a ref or a repository without a
|
|
167
332
|
* commit means the user asked for that ref, so the pin must get out of the way and trust
|
|
@@ -179,7 +344,7 @@ function validateDelegation(value: string | undefined, layers: readonly ConfigLa
|
|
|
179
344
|
if (isDelegationProvider(value)) return value;
|
|
180
345
|
const owner = [...layers].reverse().find((layer) => layer.values.delegation === value);
|
|
181
346
|
throw new Error(
|
|
182
|
-
`${owner?.label ?? "config"} sets \`delegation\` to ${
|
|
347
|
+
`${owner?.label ?? "config"} sets \`delegation\` to ${safeEcho(value)}. Use one of ${DELEGATION_PROVIDERS.map((id) => `"${id}"`).join(", ")}, or remove it to prefer "${DEFAULT_DELEGATION}".`,
|
|
183
348
|
);
|
|
184
349
|
}
|
|
185
350
|
|
package/src/echo.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rejected input is attacker-shaped text that can reach the agent through an error message, so echo
|
|
3
|
+
* only a short, plain, quoted form of it. Non-strings are reported by type alone.
|
|
4
|
+
*/
|
|
5
|
+
export function safeEcho(value: unknown): string {
|
|
6
|
+
if (typeof value !== "string") return `(a value of type ${value === null ? "null" : typeof value})`;
|
|
7
|
+
const cleaned = value.slice(0, 24).replace(/[^A-Za-z0-9._/:~@+-]/g, "?");
|
|
8
|
+
return `"${cleaned}${value.length > 24 ? "..." : ""}"`;
|
|
9
|
+
}
|
package/src/generate.ts
CHANGED
|
@@ -483,9 +483,18 @@ function t3Section(namespace: string): string {
|
|
|
483
483
|
path of \`agents/${namespace}-poteto-agent.md\` beside this file. The \`agent prompts\` row of
|
|
484
484
|
the status report gives that directory.
|
|
485
485
|
- Model: a role configured as \`provider/model\` becomes
|
|
486
|
-
\`target: { providerInstanceId: "pi", model: "provider/model" }\`.
|
|
487
|
-
\`
|
|
488
|
-
\`${T3_CAPABILITIES_TOOL}\`
|
|
486
|
+
\`target: { providerInstanceId: "pi", model: "provider/model" }\`. Read the thinking column of
|
|
487
|
+
the \`model roles\` table in the \`pstack_adapter\` \`status\` report. When it names a level, add it to
|
|
488
|
+
\`target.options\` under the option id that \`${T3_CAPABILITIES_TOOL}\` lists for that model's
|
|
489
|
+
thinking setting. For Pi models the id is \`thinking\`, as in \`options: { thinking: "<level>" }\`.
|
|
490
|
+
A role whose thinking reads "backend default" omits \`options\`, so the backend applies its own
|
|
491
|
+
default. An unconfigured role omits \`target\`, so the child inherits the parent model and
|
|
492
|
+
thinking.
|
|
493
|
+
- If the model does not list the level, use the highest level it lists that is below the requested
|
|
494
|
+
one, and tell the user. If it lists none below the requested level, or lists no thinking option,
|
|
495
|
+
omit \`options\` and tell the user. If the level is \`off\` and the model does not list \`off\`, omit
|
|
496
|
+
\`options\` and tell the user, because the closest listed level would turn thinking on.
|
|
497
|
+
For a different model family, call \`${T3_CAPABILITIES_TOOL}\` and use a provider and model it lists.
|
|
489
498
|
- Background: \`mode: "async"\`. A finished child wakes this thread, so end the turn instead of
|
|
490
499
|
polling. Call \`${T3_STATUS_TOOL}\` only when you need a result mid-turn.
|
|
491
500
|
- Call \`${T3_CAPABILITIES_TOOL}\` or \`${T3_STATUS_TOOL}\` only when the \`check\` report allows it.
|
|
@@ -508,7 +517,10 @@ function subagentSection(namespace: string): string {
|
|
|
508
517
|
|
|
509
518
|
- Agent names are namespaced, for example \`${namespace}-poteto-agent\`. Call
|
|
510
519
|
\`${SUBAGENT_TOOL}({ action: "list" })\` to see them.
|
|
511
|
-
- Model: pass a role's \`provider/model\` as \`model\`.
|
|
520
|
+
- Model: pass a role's \`provider/model\` as \`model\`. When the thinking column of the \`model roles\`
|
|
521
|
+
table in the \`pstack_adapter\` \`status\` report names a level for the role, append it as
|
|
522
|
+
\`provider/model:<level>\`. A role whose thinking reads "backend default" gets no suffix, so
|
|
523
|
+
pi-subagents applies its own default. An unconfigured role omits \`model\`.
|
|
512
524
|
- Background: \`async: true\`.`;
|
|
513
525
|
}
|
|
514
526
|
|
|
@@ -524,7 +536,7 @@ that names a Cursor tool, path, or model.
|
|
|
524
536
|
| \`run_in_background: true\` | \`mode: "async"\` on \`delegate_task\`, or \`async: true\` on \`subagent\`. |
|
|
525
537
|
| \`AskUserQuestion\`, \`AskQuestion\` | the \`ask_user\` tool. |
|
|
526
538
|
| \`TodoWrite\`, \`TaskCreate\`, \`TaskUpdate\` | no Pi equivalent. Keep an uncommitted \`todo.md\` checklist instead. |
|
|
527
|
-
| \`claude-opus-5-5-xhigh\`, \`grok-4.7-xhigh-fast\`, other Cursor model slugs | the role models configured by \`/skill:${namespace}-setup\`. Call the \`pstack_adapter\` tool with \`action: "status"\` to read the current role table. An unconfigured role inherits the parent model, so omit \`target\` or \`model\`. |
|
|
539
|
+
| \`claude-opus-5-5-xhigh\`, \`grok-4.7-xhigh-fast\`, other Cursor model slugs | the role models configured by \`/skill:${namespace}-setup\`. Call the \`pstack_adapter\` tool with \`action: "status"\` to read the current role table. An effort word in a slug, such as \`-xhigh\` or \`-max\`, is not a model id. The role table's thinking column carries that level, and \`-fast\` has no equivalent. An unconfigured role inherits the parent model and thinking, so omit \`target\` or \`model\`. |
|
|
528
540
|
| a role line valued \`auto\` or \`inherit-parent\` | an unconfigured role. Omit \`target\` or \`model\` so the child runs on the parent model. |
|
|
529
541
|
| \`~/.cursor/rules/pstack-models.mdc\` | adapter configuration written by \`/skill:${namespace}-setup\`. |
|
|
530
542
|
| role lines \`feature, refactoring\`, \`bug-fix\`, \`perf-issue\`, \`hillclimb\`, \`judgment and prose\`, \`hardest tasks\` | adapter roles \`feature\` or \`refactoring\`, \`bug-fix\`, \`perf-issue\`, \`hillclimb\`, \`judgment\`, and \`strongest-judgment\`. |
|
package/src/namespace.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { safeEcho } from "./echo.ts";
|
|
2
|
+
|
|
1
3
|
export const DEFAULT_NAMESPACE = "pistack";
|
|
2
4
|
|
|
3
5
|
const NAMESPACE_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
@@ -5,7 +7,7 @@ const NAMESPACE_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
|
5
7
|
export function validateNamespace(value: string): string {
|
|
6
8
|
if (!NAMESPACE_PATTERN.test(value)) {
|
|
7
9
|
throw new Error(
|
|
8
|
-
`Invalid pstack adapter namespace ${
|
|
10
|
+
`Invalid pstack adapter namespace ${safeEcho(value)}. Use lowercase letters, digits, and single hyphens.`,
|
|
9
11
|
);
|
|
10
12
|
}
|
|
11
13
|
return value;
|
package/src/status.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CERTIFIED } from "./certified.ts";
|
|
2
2
|
import { CAPABILITY_LABELS, MODEL_ROLES, TIER_ORDER } from "./registry.ts";
|
|
3
3
|
import type { AdapterConfig } from "./config.ts";
|
|
4
|
-
import { trustWarning } from "./config.ts";
|
|
4
|
+
import { splitModelSpec, trustWarning } from "./config.ts";
|
|
5
5
|
import { DELEGATION_BACKENDS, delegationOrder } from "./delegation.ts";
|
|
6
6
|
import type { PrerequisiteReport } from "./capabilities.ts";
|
|
7
7
|
import type {
|
|
@@ -88,14 +88,17 @@ export function renderStatus(input: StatusInput): string {
|
|
|
88
88
|
|
|
89
89
|
lines.push(`## Model roles`);
|
|
90
90
|
lines.push("");
|
|
91
|
-
lines.push(`| role | model |`);
|
|
92
|
-
lines.push(`| --- | --- |`);
|
|
93
|
-
for (const role of MODEL_ROLES) {
|
|
94
|
-
lines.push(`| ${role} | ${config.models[role] ?? "inherit (parent session model)"} |`);
|
|
95
|
-
}
|
|
91
|
+
lines.push(`| role | model | thinking |`);
|
|
92
|
+
lines.push(`| --- | --- | --- |`);
|
|
96
93
|
const extra = Object.keys(config.models).filter((role) => !MODEL_ROLES.includes(role)).sort();
|
|
97
|
-
for (const role of extra) {
|
|
98
|
-
|
|
94
|
+
for (const role of [...MODEL_ROLES, ...extra]) {
|
|
95
|
+
const configured = config.models[role];
|
|
96
|
+
if (configured === undefined) {
|
|
97
|
+
lines.push(`| ${role} | inherit (parent session model) | inherit (parent session thinking) |`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const spec = splitModelSpec(configured);
|
|
101
|
+
lines.push(`| ${role} | ${spec.model} | ${spec.thinking ?? "backend default"} |`);
|
|
99
102
|
}
|
|
100
103
|
lines.push("");
|
|
101
104
|
|