@huseynsnmz/pi-suite 0.2.2 → 0.2.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 +34 -0
- package/README.md +13 -4
- package/package.json +12 -2
- package/packages/board/README.md +1 -1
- package/packages/board/package.json +2 -1
- package/packages/commands/README.md +1 -1
- package/packages/commands/src/index.ts +22 -0
- package/packages/frame/README.md +8 -2
- package/packages/frame/docs/reference.md +1 -1
- package/packages/frame/src/index.ts +8 -3
- package/packages/frame/src/kit/settings/files.ts +8 -8
- package/packages/frame/src/kit/settings/store.ts +3 -1
- package/packages/frame/src/ui/editor.ts +64 -7
- package/packages/frame/src/ui/history-search.ts +21 -0
- package/packages/frame/src/ui/pi-entries.ts +2 -0
- package/packages/frame/src/ui/thinking.ts +34 -0
- package/packages/memory/README.md +20 -1
- package/packages/memory/src/index.ts +60 -1
- package/packages/memory/src/mastermind.ts +110 -0
- package/packages/memory/src/playbook.ts +92 -0
- package/packages/modes/README.md +11 -3
- package/packages/modes/skills/review-permissions/SKILL.md +11 -2
- package/packages/modes/src/index.ts +29 -11
- package/packages/modes/src/judge-gate.ts +78 -7
- package/packages/modes/src/modes.ts +4 -1
- package/packages/modes/src/review/apply.mjs +1 -1
- package/packages/modes/src/review/collect.mjs +45 -2
- package/packages/modes/src/workdir.ts +105 -0
- package/packages/router/README.md +30 -9
- package/packages/router/src/config.ts +30 -28
- package/packages/router/src/index.ts +70 -13
- package/packages/router/src/route.ts +16 -10
- package/packages/subagent/LICENSE +21 -0
- package/packages/subagent/README.md +17 -0
- package/packages/subagent/package.json +22 -0
- package/packages/subagent/src/index.ts +76 -0
- package/packages/subscription/LICENSE +21 -0
- package/packages/subscription/README.md +60 -0
- package/packages/subscription/package.json +23 -0
- package/packages/subscription/src/anchors.ts +75 -0
- package/packages/subscription/src/billing.ts +39 -0
- package/packages/subscription/src/catalog.ts +310 -0
- package/packages/subscription/src/cc-sessions.ts +122 -0
- package/packages/subscription/src/config.ts +34 -0
- package/packages/subscription/src/convert.ts +161 -0
- package/packages/subscription/src/index.ts +69 -0
- package/packages/subscription/src/mcp.ts +78 -0
- package/packages/subscription/src/models.ts +65 -0
- package/packages/subscription/src/prompt.ts +57 -0
- package/packages/subscription/src/provider.ts +617 -0
- package/packages/subscription/src/session.ts +294 -0
- package/packages/subscription/src/util.ts +73 -0
- package/packages/web/README.md +5 -5
- package/packages/web/src/config.ts +13 -23
- package/packages/web/src/index.ts +11 -7
- package/packages/web/src/reader.ts +15 -4
- package/packages/web/src/validate.ts +2 -2
- package/src/index.ts +93 -11
- package/src/settings-ui.ts +63 -0
- package/src/settings.ts +117 -15
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bash keeps its folder, the way a shell would: a `cd` at the start of a command carries over to later commands while
|
|
3
|
+
* it stays in the project. Pi starts a new shell for each command, so later ones get the prefix from here, before the
|
|
4
|
+
* permission system reads them. It follows a literal cd, so paths are judged from the right folder, and this works
|
|
5
|
+
* with whichever extension runs bash, such as pi-sandbox. Replacing the bash tool would turn that extension off.
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { applyChoices, checkChoices } from "./review/apply.mjs";
|
|
12
|
+
import { ruleFor } from "./review/collect.mjs";
|
|
13
|
+
|
|
14
|
+
const LEADING_CD = /^\s*cd\s+(?:'([^']*)'|"([^"$`\\]*)"|([^\s;&|`$<>()'"\\]+))(?=\s*(?:&&|;|\n|$))/;
|
|
15
|
+
/** Bare when it can be, as a rule like `cd /work/app/*` only matches the path as written. */
|
|
16
|
+
const quoted = (text: string) => (/^[\w@%+=:,./-]+$/.test(text) ? text : `'${text.replaceAll("'", "'\\''")}'`);
|
|
17
|
+
|
|
18
|
+
function folderAt(path: string): string | undefined {
|
|
19
|
+
try {
|
|
20
|
+
return statSync(path).isDirectory() ? realpathSync(path) : undefined;
|
|
21
|
+
} catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Where a cd goes. A model often repeats a move it already made, so a path that isn't here is tried from the top and from one up. */
|
|
27
|
+
export function cdTarget(root: string, here: string | undefined, target: string): string | undefined {
|
|
28
|
+
if (target === "-") return undefined;
|
|
29
|
+
const path = target === "~" || target.startsWith("~/") ? join(homedir(), target.slice(1)) : target;
|
|
30
|
+
const from = here ?? root;
|
|
31
|
+
for (const base of [from, root, dirname(from)]) {
|
|
32
|
+
const folder = folderAt(resolve(base, path));
|
|
33
|
+
if (folder !== undefined) return folder;
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The command to really run, and the folder it moves to if it starts with a cd. */
|
|
39
|
+
export function planCommand(root: string, here: string | undefined, command: string): { command: string; move?: string } {
|
|
40
|
+
const match = LEADING_CD.exec(command);
|
|
41
|
+
const move = match === null ? undefined : cdTarget(root, here, match[1] ?? match[2] ?? match[3] ?? "");
|
|
42
|
+
if (match !== null && move !== undefined) return { command: `cd ${quoted(move)}${command.slice(match[0].length)}`, move };
|
|
43
|
+
return { command: here === undefined ? command : `cd ${quoted(here)} && ${command}` };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isInside(root: string, folder: string): boolean {
|
|
47
|
+
const inside = relative(root, folder);
|
|
48
|
+
return inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A cd inside the project, as typed or as its full path. Nothing shorter is safe: the permission system doesn't follow a new file's name into the folder a cd reached. */
|
|
52
|
+
export function cdRules(root: string): string[] {
|
|
53
|
+
return [`cd ${root}`, `cd ${root}/*`];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The permission system judges each part of a chain, so with "*" asking, every `cd x && go test` asks about the cd.
|
|
58
|
+
* Moving inside the project does nothing by itself, so it's allowed for this project, once, with a backup. A cd
|
|
59
|
+
* anywhere else still asks. Whoever removes the rules keeps them removed. Returns what to tell the user.
|
|
60
|
+
*/
|
|
61
|
+
export function addCdRules(agentDir: string, root: string): { handled: boolean; notice?: string } {
|
|
62
|
+
const path = join(agentDir, "extensions", "pi-permission-system", "config.json");
|
|
63
|
+
if (root === "/" || !existsSync(path)) return { handled: false };
|
|
64
|
+
try {
|
|
65
|
+
const bash = JSON.parse(readFileSync(path, "utf8")).permission?.bash;
|
|
66
|
+
const asks = bash === undefined || (typeof bash === "object" && bash !== null && ruleFor(bash, `cd ${root}/x`) === "ask");
|
|
67
|
+
if (!asks) return { handled: true };
|
|
68
|
+
const rules = cdRules(root);
|
|
69
|
+
if (checkChoices(agentDir, { bash: rules }).length > 0) return { handled: true }; // A path a rule can't hold.
|
|
70
|
+
applyChoices(agentDir, { bash: rules });
|
|
71
|
+
return { handled: true, notice: `pi-modes allowed cd inside this project in your bash rules, so you aren't asked about each one. The rest of each command, and a cd anywhere else, are still checked. Remove them from ${path} if you'd rather be asked.` };
|
|
72
|
+
} catch {
|
|
73
|
+
return { handled: false }; // Not ours to fix. It's tried again next start.
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function registerWorkdir(pi: ExtensionAPI) {
|
|
78
|
+
let here: string | undefined;
|
|
79
|
+
const moves = new Map<string, string>();
|
|
80
|
+
pi.on("session_start", () => {
|
|
81
|
+
here = undefined;
|
|
82
|
+
moves.clear();
|
|
83
|
+
});
|
|
84
|
+
pi.on("tool_result", (event, ctx) => {
|
|
85
|
+
const move = moves.get(event.toolCallId);
|
|
86
|
+
if (move === undefined) return undefined;
|
|
87
|
+
moves.delete(event.toolCallId);
|
|
88
|
+
if (event.isError) return undefined;
|
|
89
|
+
const root = realpathSync(ctx.cwd);
|
|
90
|
+
const stays = isInside(root, move);
|
|
91
|
+
here = stays && move !== root ? move : undefined;
|
|
92
|
+
// Told here rather than in the system prompt, which would cost every request and replace the subscription part's prompt.
|
|
93
|
+
const note = stays ? `Bash stays in ${move} for later commands, so don't repeat the cd.` : `Shell cwd was reset to ${root}`;
|
|
94
|
+
return { content: [...event.content, { type: "text" as const, text: `\n${note}` }] };
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
folder: () => here,
|
|
98
|
+
/** Rewrites a bash call in place. */
|
|
99
|
+
apply(toolCallId: string, input: { command: string }, cwd: string): void {
|
|
100
|
+
const plan = planCommand(realpathSync(cwd), here, input.command);
|
|
101
|
+
input.command = plan.command;
|
|
102
|
+
if (plan.move !== undefined) moves.set(toolCallId, plan.move);
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -9,10 +9,12 @@ Adds `router/auto` to [Pi](https://pi.dev): one choice in `/model` that uses a s
|
|
|
9
9
|
Requires Pi 1.5 or newer.
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
|
-
pi install npm:@huseynsnmz/pi-suite@0.2.
|
|
12
|
+
pi install npm:@huseynsnmz/pi-suite@0.2.4
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
Log in to the providers you want with `/login`, run `/router` to choose a model for each role, then pick `router/auto` in `/model`.
|
|
15
|
+
Log in to the providers you want with `/login`, run `/router` to choose a model for each role, then pick `router/auto` in `/model`. It's always listed there, and until `plan` and `build` are set it stops with a message telling you to run `/router`.
|
|
16
|
+
|
|
17
|
+
In `/model`, `Enter` selects a model for this session and `Ctrl+S` saves it as your default for new sessions.
|
|
16
18
|
|
|
17
19
|
## Roles
|
|
18
20
|
|
|
@@ -21,21 +23,40 @@ Log in to the providers you want with `/login`, run `/router` to choose a model
|
|
|
21
23
|
| `plan` | Reads and plans, up to the first edit, and everything in `plan` mode | Required |
|
|
22
24
|
| `build` | Everything after that, and compaction summaries | Required |
|
|
23
25
|
| `reader` | Reads web pages for [pi-web](https://github.com/huseynsnmz/pi-suite/tree/main/packages/web) | Same as `build` |
|
|
26
|
+
| `mastermind` | Reviews past conversations for `/mastermind`, and writes rules for the other models | Not set |
|
|
24
27
|
| `judge` | Decides permission asks for `pi-permission-classifier` | See below |
|
|
25
28
|
| `subagent` | Runs subagents that use `router/auto` | Same as `build` |
|
|
26
29
|
| `fallback` | Takes over when a provider is overloaded or rate limited | Retries on the same model |
|
|
27
30
|
|
|
28
|
-
`/router` saves them in `~/.pi/agent/
|
|
31
|
+
`/router` saves them under `router` in `~/.pi/agent/suite.json`, apart from `judge`, which goes in the judge's own config. `/suite` opens the same screen.
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{
|
|
35
|
+
"router": {
|
|
36
|
+
"plan": "anthropic/claude-opus-5-5",
|
|
37
|
+
"build": "anthropic/claude-haiku-5-5",
|
|
38
|
+
"fallback": "openai/gpt-5.6-luna"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
There's no setting per project, so a repository can't choose where your code gets sent.
|
|
44
|
+
|
|
45
|
+
## Reasoning per role
|
|
46
|
+
|
|
47
|
+
After you choose a model for a role, `/router` asks how much reasoning it should use, offering only the levels that model has. It's saved next to the roles:
|
|
29
48
|
|
|
30
49
|
```json
|
|
31
50
|
{
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
51
|
+
"router": {
|
|
52
|
+
"plan": "anthropic/claude-opus-5-5",
|
|
53
|
+
"build": "anthropic/claude-haiku-5-5",
|
|
54
|
+
"thinking": { "plan": "high", "build": "low" }
|
|
55
|
+
}
|
|
35
56
|
}
|
|
36
57
|
```
|
|
37
58
|
|
|
38
|
-
|
|
59
|
+
A role with no level of its own uses the one you pick with `/effort`. Changing a role's model clears its level, because the new model may not have it. A retry keeps the level of the request that failed. `reader` and `subagent` with no model of their own use `build`'s level. `judge` has no level here, as it's set in the judge's own config.
|
|
39
60
|
|
|
40
61
|
## How it decides
|
|
41
62
|
|
|
@@ -50,7 +71,7 @@ There's no setting per project, so a repository can't choose where your code get
|
|
|
50
71
|
- Moving from `plan` to `build` happens once in a session. It loses the prompt cache one time, as any change of model does.
|
|
51
72
|
- The session stays on `build` after that. For a hard follow-up, switch to `plan` mode, or start a new session.
|
|
52
73
|
- If a request fails because the provider is overloaded, it moves to `fallback` for the rest of that turn. The next message tries the first choice again. Any other failure retries on the same model.
|
|
53
|
-
- A role whose model is missing or has no login stops that request with an error naming it, and so
|
|
74
|
+
- A role whose model is missing or has no login stops that request with an error naming it, and so do broken router settings. The router doesn't pick another model for you.
|
|
54
75
|
- Leaving `plan` mode counts as finishing planning, whether you approved a plan or changed mode.
|
|
55
76
|
- The roles only apply while `router/auto` is selected. On any other model, nothing goes to them, and pages are read as if the router weren't there.
|
|
56
77
|
- `/suite off router` moves the session to the model that last answered. If `router/auto` is your saved default, later sessions use another available model, as Pi does for any model that no longer exists.
|
|
@@ -61,7 +82,7 @@ The phase follows the session tree, so a branch keeps its own.
|
|
|
61
82
|
|
|
62
83
|
**`modes`:** tells the router the mode. With it off, the router only goes by the first edit.
|
|
63
84
|
|
|
64
|
-
**`web`:** your `readerModel` in
|
|
85
|
+
**`web`:** your `readerModel` in the web settings still wins over the `reader` role.
|
|
65
86
|
|
|
66
87
|
**`frame`:** shows who answered, such as `Auto → Haiku`. Pi's own footer already does.
|
|
67
88
|
|
|
@@ -1,40 +1,54 @@
|
|
|
1
|
-
// ~/.pi/agent/
|
|
2
|
-
import {
|
|
3
|
-
import { join } from "node:path";
|
|
1
|
+
// The router section of ~/.pi/agent/suite.json. Global only: a repository must not choose where your code gets sent.
|
|
2
|
+
import { readSection, writeSection } from "../../../src/settings.ts";
|
|
4
3
|
|
|
5
4
|
/** The judge's model lives only in the judge's own config, so there's one answer to which model judges. */
|
|
6
|
-
export const ROLES = ["plan", "build", "reader", "subagent", "fallback"] as const;
|
|
5
|
+
export const ROLES = ["plan", "build", "reader", "subagent", "fallback", "mastermind"] as const;
|
|
7
6
|
export type Role = (typeof ROLES)[number];
|
|
8
7
|
|
|
8
|
+
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
9
|
+
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
10
|
+
/** A role left out uses the level selected with /effort. */
|
|
11
|
+
export type RoleThinking = Partial<Record<Role, ThinkingLevel>>;
|
|
12
|
+
|
|
9
13
|
export interface RouterConfig {
|
|
10
14
|
plan: string;
|
|
11
15
|
build: string;
|
|
12
16
|
reader?: string;
|
|
13
17
|
subagent?: string;
|
|
14
18
|
fallback?: string;
|
|
19
|
+
mastermind?: string;
|
|
20
|
+
thinking?: RoleThinking;
|
|
15
21
|
}
|
|
16
22
|
|
|
17
|
-
export type SavedConfig = Partial<Record<Role, string
|
|
23
|
+
export type SavedConfig = Partial<Record<Role, string>> & { thinking?: RoleThinking };
|
|
18
24
|
|
|
19
25
|
export const VIRTUAL_PROVIDER = "router";
|
|
20
26
|
export const VIRTUAL_ID = "auto";
|
|
21
27
|
|
|
22
|
-
export function configPath(agentDir: string): string {
|
|
23
|
-
return join(agentDir, "router.json");
|
|
24
|
-
}
|
|
25
|
-
|
|
26
28
|
export function parseRef(ref: string): { provider: string; id: string } {
|
|
27
29
|
const at = ref.indexOf("/");
|
|
28
30
|
return { provider: ref.slice(0, at), id: ref.slice(at + 1) };
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
function parseThinking(raw: unknown): void {
|
|
34
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("suite.json router: thinking must be an object");
|
|
35
|
+
for (const [role, level] of Object.entries(raw)) {
|
|
36
|
+
if (!(ROLES as readonly string[]).includes(role)) throw new Error(`suite.json router: thinking has an unknown role "${role}". Use ${ROLES.join(", ")}`);
|
|
37
|
+
if (!(THINKING_LEVELS as readonly unknown[]).includes(level)) throw new Error(`suite.json router: thinking.${role} must be one of ${THINKING_LEVELS.join(", ")}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
/** Throws on anything it doesn't understand, so a typo can't quietly send work to the wrong model. */
|
|
32
42
|
export function parseConfig(raw: unknown): SavedConfig {
|
|
33
|
-
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("
|
|
43
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("suite.json router must be an object");
|
|
34
44
|
for (const [role, ref] of Object.entries(raw)) {
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
if (role === "thinking") {
|
|
46
|
+
parseThinking(ref);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!(ROLES as readonly string[]).includes(role)) throw new Error(`suite.json router: unknown role "${role}". Use ${ROLES.join(", ")}`);
|
|
50
|
+
if (typeof ref !== "string" || !/^[^/\s]+\/\S+$/.test(ref)) throw new Error(`suite.json router: ${role} must look like "provider/model"`);
|
|
51
|
+
if (ref.startsWith(`${VIRTUAL_PROVIDER}/`)) throw new Error(`suite.json router: ${role} can't be a virtual model`);
|
|
38
52
|
}
|
|
39
53
|
return raw as SavedConfig;
|
|
40
54
|
}
|
|
@@ -43,23 +57,11 @@ export function isComplete(saved: SavedConfig): saved is RouterConfig {
|
|
|
43
57
|
return saved.plan !== undefined && saved.build !== undefined;
|
|
44
58
|
}
|
|
45
59
|
|
|
46
|
-
/**
|
|
60
|
+
/** Nothing there is a router nobody has set up yet. Something broken throws. */
|
|
47
61
|
export function loadConfig(agentDir: string): SavedConfig {
|
|
48
|
-
|
|
49
|
-
try {
|
|
50
|
-
text = readFileSync(configPath(agentDir), "utf8");
|
|
51
|
-
} catch (error) {
|
|
52
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
|
53
|
-
throw error;
|
|
54
|
-
}
|
|
55
|
-
return parseConfig(JSON.parse(text));
|
|
62
|
+
return parseConfig(readSection(agentDir, "router"));
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
export function saveConfig(agentDir: string, saved: SavedConfig): void {
|
|
59
|
-
|
|
60
|
-
const named = configPath(agentDir);
|
|
61
|
-
const path = existsSync(named) ? realpathSync(named) : named;
|
|
62
|
-
const temp = `${path}.${process.pid}.tmp`;
|
|
63
|
-
writeFileSync(temp, `${JSON.stringify(saved, null, 2)}\n`);
|
|
64
|
-
renameSync(temp, path);
|
|
66
|
+
writeSection(agentDir, "router", saved);
|
|
65
67
|
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
|
+
import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
1
2
|
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
|
|
3
|
-
import { isComplete, loadConfig, parseConfig, parseRef, type Role, ROLES, type SavedConfig, saveConfig, VIRTUAL_ID, VIRTUAL_PROVIDER } from "./config.ts";
|
|
4
|
+
import { isComplete, loadConfig, parseConfig, parseRef, type Role, ROLES, type SavedConfig, saveConfig, THINKING_LEVELS, type ThinkingLevel, VIRTUAL_ID, VIRTUAL_PROVIDER } from "./config.ts";
|
|
4
5
|
import { judgeModel, setJudgeModel } from "./judge.ts";
|
|
5
6
|
import { chooseModel, type RouterState } from "./route.ts";
|
|
6
7
|
|
|
7
8
|
/** Other extensions emit these with an object, and whoever knows fills it in, whatever the load order. */
|
|
8
9
|
const READER_PROBE = "pi-router:reader-model";
|
|
10
|
+
const MASTERMIND_PROBE = "pi-router:mastermind-model";
|
|
9
11
|
const MODE_PROBE = "pi-modes:mode";
|
|
12
|
+
const SETTINGS_PROBE = "pi-suite:router-settings";
|
|
10
13
|
|
|
11
|
-
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
12
14
|
const VIRTUAL_API = "pi-virtual";
|
|
15
|
+
const SELECTED_LEVEL = "same as /effort";
|
|
13
16
|
|
|
14
17
|
const JUDGE = "judge";
|
|
15
18
|
const JUDGE_HELP = "decides permission asks for pi-permission-classifier";
|
|
@@ -19,6 +22,7 @@ const ROLE_HELP: Record<Role, string> = {
|
|
|
19
22
|
reader: "reads web pages for pi-web",
|
|
20
23
|
subagent: "runs subagents that use this virtual model",
|
|
21
24
|
fallback: "takes over when a provider is overloaded",
|
|
25
|
+
mastermind: "reviews past conversations for /mastermind, and writes rules for the other models",
|
|
22
26
|
};
|
|
23
27
|
|
|
24
28
|
/** What a role shows, and offers to go back to, with no model of its own. */
|
|
@@ -26,6 +30,7 @@ const UNSET: Partial<Record<Role, string>> = {
|
|
|
26
30
|
reader: "same as build",
|
|
27
31
|
subagent: "same as build",
|
|
28
32
|
fallback: "none",
|
|
33
|
+
mastermind: "none",
|
|
29
34
|
};
|
|
30
35
|
|
|
31
36
|
function refOf(model: { provider: string; id: string } | undefined): string | undefined {
|
|
@@ -46,7 +51,15 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
46
51
|
|
|
47
52
|
pi.events.on(READER_PROBE, (data) => {
|
|
48
53
|
if (!isComplete(saved)) return;
|
|
49
|
-
|
|
54
|
+
const probe = data as { model?: string; thinking?: ThinkingLevel };
|
|
55
|
+
probe.model = saved.reader ?? saved.build;
|
|
56
|
+
probe.thinking = saved.thinking?.[saved.reader === undefined ? "build" : "reader"];
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
pi.events.on(MASTERMIND_PROBE, (data) => {
|
|
60
|
+
const probe = data as { model?: string; thinking?: ThinkingLevel };
|
|
61
|
+
probe.model = saved.mastermind;
|
|
62
|
+
probe.thinking = saved.thinking?.mastermind;
|
|
50
63
|
});
|
|
51
64
|
|
|
52
65
|
function currentMode(): string | undefined {
|
|
@@ -56,8 +69,8 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
56
69
|
}
|
|
57
70
|
|
|
58
71
|
function register(): void {
|
|
59
|
-
//
|
|
60
|
-
if (registered
|
|
72
|
+
// Always listed. With nothing set or a broken file it stops with the reason, rather than Pi picking another model.
|
|
73
|
+
if (registered) return;
|
|
61
74
|
registered = true;
|
|
62
75
|
pi.registerVirtualModel<RouterState>({
|
|
63
76
|
provider: VIRTUAL_PROVIDER,
|
|
@@ -73,14 +86,14 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
73
86
|
subagent: process.env.PI_SUBAGENT_CHILD === "1",
|
|
74
87
|
state: request.state,
|
|
75
88
|
previous: refOf(request.previous?.model),
|
|
76
|
-
failed: request.failed && { model: refOf(request.failed.model) as string, errorMessage: request.failed.message.errorMessage },
|
|
89
|
+
failed: request.failed && { model: refOf(request.failed.model) as string, errorMessage: request.failed.message.errorMessage, thinking: request.failed.thinkingLevel },
|
|
77
90
|
messages: request.messages,
|
|
78
91
|
});
|
|
79
92
|
const { provider, id } = parseRef(choice.model);
|
|
80
93
|
const model = ctx.modelRegistry.find(provider, id);
|
|
81
94
|
if (model === undefined) throw new Error(`pi-router: ${choice.model} isn't a known model. Run /router`);
|
|
82
95
|
if (!ctx.modelRegistry.hasConfiguredAuth(model)) throw new Error(`pi-router: no login for ${choice.model}. Run /login or /router`);
|
|
83
|
-
return { model, thinkingLevel: request.thinkingLevel, state: choice.state };
|
|
96
|
+
return { model, thinkingLevel: choice.thinking ?? request.thinkingLevel, state: choice.state };
|
|
84
97
|
},
|
|
85
98
|
});
|
|
86
99
|
}
|
|
@@ -94,9 +107,15 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
94
107
|
ctx.ui.notify("The permission judge has no model of its own, so with router/auto it can't judge and every ask comes to you. Set judge in /router.", "warning");
|
|
95
108
|
}
|
|
96
109
|
|
|
110
|
+
function warnIfNotSetUp(ctx: ExtensionContext): void {
|
|
111
|
+
if (problem !== undefined || isComplete(saved) || !ctx.hasUI || refOf(ctx.model) !== `${VIRTUAL_PROVIDER}/${VIRTUAL_ID}`) return;
|
|
112
|
+
ctx.ui.notify("router/auto has no models yet. Run /router to choose plan and build.", "warning");
|
|
113
|
+
}
|
|
114
|
+
|
|
97
115
|
pi.on("session_start", (_event, ctx) => {
|
|
98
116
|
register();
|
|
99
117
|
if (problem !== undefined && ctx.hasUI) ctx.ui.notify(problem, "error");
|
|
118
|
+
warnIfNotSetUp(ctx);
|
|
100
119
|
warnAboutJudge(ctx);
|
|
101
120
|
});
|
|
102
121
|
// Pi keeps virtual models across a reload, so one left here would outlive this extension being turned off.
|
|
@@ -105,18 +124,45 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
105
124
|
registered = false;
|
|
106
125
|
pi.unregisterVirtualModel(VIRTUAL_PROVIDER, VIRTUAL_ID);
|
|
107
126
|
});
|
|
108
|
-
pi.on("model_select", (_event, ctx) =>
|
|
127
|
+
pi.on("model_select", (_event, ctx) => {
|
|
128
|
+
warnIfNotSetUp(ctx);
|
|
129
|
+
warnAboutJudge(ctx);
|
|
130
|
+
});
|
|
109
131
|
|
|
110
132
|
function setRole(role: Role, ref: string | undefined): void {
|
|
111
133
|
// Another session may have changed it since this one read it.
|
|
112
134
|
const next: SavedConfig = { ...loadConfig(agentDir) };
|
|
113
135
|
if (ref === undefined) delete next[role];
|
|
114
136
|
else next[role] = ref;
|
|
137
|
+
// Another model may not have the same levels, so this role's level is chosen again.
|
|
138
|
+
if (next.thinking !== undefined) {
|
|
139
|
+
const { [role]: _dropped, ...others } = next.thinking;
|
|
140
|
+
if (Object.keys(others).length > 0) next.thinking = others;
|
|
141
|
+
else delete next.thinking;
|
|
142
|
+
}
|
|
115
143
|
saved = parseConfig(next);
|
|
116
144
|
saveConfig(agentDir, saved);
|
|
117
145
|
register();
|
|
118
146
|
}
|
|
119
147
|
|
|
148
|
+
function setThinking(role: Role, level: ThinkingLevel): void {
|
|
149
|
+
const next: SavedConfig = { ...loadConfig(agentDir) };
|
|
150
|
+
next.thinking = { ...next.thinking, [role]: level };
|
|
151
|
+
saved = parseConfig(next);
|
|
152
|
+
saveConfig(agentDir, saved);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Offers only what the chosen model can do. A model with no levels to choose has nothing to ask. */
|
|
156
|
+
async function chooseThinking(ctx: ExtensionContext, role: Role, ref: string): Promise<void> {
|
|
157
|
+
const { provider, id } = parseRef(ref);
|
|
158
|
+
const model = ctx.modelRegistry.find(provider, id);
|
|
159
|
+
const levels = model === undefined ? [] : getSupportedThinkingLevels(model);
|
|
160
|
+
if (levels.length < 2) return;
|
|
161
|
+
const picked = await ctx.ui.select(`Reasoning for ${role} on ${ref}`, [SELECTED_LEVEL, ...levels]);
|
|
162
|
+
if (picked === undefined || picked === SELECTED_LEVEL) return;
|
|
163
|
+
setThinking(role, picked as ThinkingLevel);
|
|
164
|
+
}
|
|
165
|
+
|
|
120
166
|
async function chooseJudge(ctx: ExtensionContext, models: readonly string[]): Promise<void> {
|
|
121
167
|
const picked = await ctx.ui.select(`${JUDGE} ${JUDGE_HELP}`, ["not set", ...models]);
|
|
122
168
|
if (picked === undefined) return;
|
|
@@ -131,7 +177,7 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
131
177
|
async function chooseRoles(ctx: ExtensionContext): Promise<void> {
|
|
132
178
|
if (problem !== undefined) {
|
|
133
179
|
// Saving over it would throw away whatever the file held.
|
|
134
|
-
ctx.ui.notify(`${problem}. Fix
|
|
180
|
+
ctx.ui.notify(`${problem}. Fix the router section of ~/.pi/agent/suite.json, then /reload.`, "error");
|
|
135
181
|
return;
|
|
136
182
|
}
|
|
137
183
|
const models = ctx.modelRegistry.getAvailable().filter((model) => model.api !== VIRTUAL_API).map((model) => `${model.provider}/${model.id}`);
|
|
@@ -141,7 +187,11 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
141
187
|
}
|
|
142
188
|
for (;;) {
|
|
143
189
|
const judge = judgeModel(agentDir);
|
|
144
|
-
const
|
|
190
|
+
const shown = (role: Role): string => {
|
|
191
|
+
const level = saved.thinking?.[role];
|
|
192
|
+
return `${saved[role] ?? UNSET[role] ?? "not set"}${saved[role] !== undefined && level !== undefined ? ` · ${level}` : ""}`;
|
|
193
|
+
};
|
|
194
|
+
const rows = [...ROLES.map((role) => `${role}: ${shown(role)}`), `${JUDGE}: ${judge ?? (judge === null ? "not set" : "not installed")}`];
|
|
145
195
|
const row = await ctx.ui.select("Router: which role? (Esc when done)", rows);
|
|
146
196
|
if (row === undefined) break;
|
|
147
197
|
const role = ROLES[rows.indexOf(row)];
|
|
@@ -153,15 +203,22 @@ export default function piRouter(pi: ExtensionAPI): void {
|
|
|
153
203
|
const picked = await ctx.ui.select(`${role} ${ROLE_HELP[role]}`, [...(unset === undefined ? [] : [unset]), ...models]);
|
|
154
204
|
if (picked === undefined) continue;
|
|
155
205
|
try {
|
|
156
|
-
|
|
206
|
+
const ref = picked === unset ? undefined : picked;
|
|
207
|
+
setRole(role, ref);
|
|
208
|
+
if (ref !== undefined) await chooseThinking(ctx, role, ref);
|
|
157
209
|
} catch (error) {
|
|
158
210
|
ctx.ui.notify((error as Error).message, "error");
|
|
159
211
|
}
|
|
160
212
|
}
|
|
161
|
-
if (isComplete(saved)) ctx.ui.notify(
|
|
162
|
-
else ctx.ui.notify(
|
|
213
|
+
if (!isComplete(saved)) ctx.ui.notify("router/auto needs both plan and build before it can answer.", "warning");
|
|
214
|
+
else if (refOf(ctx.model) !== `${VIRTUAL_PROVIDER}/${VIRTUAL_ID}`) ctx.ui.notify(`Choose ${VIRTUAL_PROVIDER}/${VIRTUAL_ID} in /model to use these.`, "info");
|
|
163
215
|
}
|
|
164
216
|
|
|
217
|
+
// /suite opens this screen too, without knowing how it works.
|
|
218
|
+
pi.events.on(SETTINGS_PROBE, (data) => {
|
|
219
|
+
(data as { open?: (ctx: ExtensionContext) => Promise<void> }).open = chooseRoles;
|
|
220
|
+
});
|
|
221
|
+
|
|
165
222
|
pi.registerCommand("router", {
|
|
166
223
|
description: "Choose which model does what for router/auto",
|
|
167
224
|
handler: async (_args, ctx) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Which real model answers a request. Pure, so every rule can be tested without Pi.
|
|
2
|
-
import type { RouterConfig } from "./config.ts";
|
|
2
|
+
import type { Role, RouterConfig, ThinkingLevel } from "./config.ts";
|
|
3
3
|
|
|
4
4
|
export interface RouterState {
|
|
5
5
|
phase: "plan" | "build";
|
|
@@ -15,12 +15,14 @@ export interface RouteInput {
|
|
|
15
15
|
subagent: boolean;
|
|
16
16
|
state: RouterState | undefined;
|
|
17
17
|
previous: string | undefined;
|
|
18
|
-
failed: { model: string; errorMessage: string | undefined } | undefined;
|
|
18
|
+
failed: { model: string; errorMessage: string | undefined; thinking?: ThinkingLevel } | undefined;
|
|
19
19
|
messages: readonly { role: string; toolName?: string; isError?: boolean }[];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export interface RouteChoice {
|
|
23
23
|
model: string;
|
|
24
|
+
/** Only when the role has its own. Otherwise the level selected with /effort applies. */
|
|
25
|
+
thinking?: ThinkingLevel;
|
|
24
26
|
/** Only when it changed, because Pi stores every new object it gets. */
|
|
25
27
|
state?: RouterState;
|
|
26
28
|
}
|
|
@@ -42,9 +44,13 @@ function nextState(input: RouteInput): RouterState {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export function chooseModel(config: RouterConfig, input: RouteInput): RouteChoice {
|
|
45
|
-
const
|
|
47
|
+
const forRole = (role: Role, model: string): Pick<RouteChoice, "model" | "thinking"> => {
|
|
48
|
+
const thinking = config.thinking?.[role];
|
|
49
|
+
return thinking === undefined ? { model } : { model, thinking };
|
|
50
|
+
};
|
|
51
|
+
const home = input.subagent && config.subagent !== undefined ? forRole("subagent", config.subagent) : forRole("build", config.build);
|
|
46
52
|
// Compaction summaries, and any other extension's call that names the virtual model.
|
|
47
|
-
if (input.reason === "direct") return
|
|
53
|
+
if (input.reason === "direct") return home;
|
|
48
54
|
|
|
49
55
|
// Worked out first, so an edit made on the fallback or during a retry still counts.
|
|
50
56
|
const state = input.subagent ? input.state : nextState(input);
|
|
@@ -52,16 +58,16 @@ export function chooseModel(config: RouterConfig, input: RouteInput): RouteChoic
|
|
|
52
58
|
|
|
53
59
|
if (input.reason === "retry" && input.failed) {
|
|
54
60
|
const overloaded = OVERLOADED.test(input.failed.errorMessage ?? "");
|
|
55
|
-
|
|
56
|
-
return { model:
|
|
61
|
+
if (overloaded && config.fallback !== undefined && config.fallback !== input.failed.model) return { ...forRole("fallback", config.fallback), ...changed };
|
|
62
|
+
return { model: input.failed.model, ...(input.failed.thinking === undefined ? {} : { thinking: input.failed.thinking }), ...changed };
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
// Back on the first choice at the next message, not on every tool call in between.
|
|
60
66
|
if (input.reason === "continuation" && input.previous !== undefined && input.previous === config.fallback) {
|
|
61
|
-
return {
|
|
67
|
+
return { ...forRole("fallback", input.previous), ...changed };
|
|
62
68
|
}
|
|
63
69
|
|
|
64
|
-
if (input.subagent) return
|
|
65
|
-
const
|
|
66
|
-
return {
|
|
70
|
+
if (input.subagent) return home;
|
|
71
|
+
const choice = input.mode === "plan" || state?.phase === "plan" ? forRole("plan", config.plan) : forRole("build", config.build);
|
|
72
|
+
return { ...choice, ...changed };
|
|
67
73
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hüseyin Sönmez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# pi-subagent
|
|
2
|
+
|
|
3
|
+
Part of [pi-suite](../../README.md). Turn it off with `/suite off subagent`.
|
|
4
|
+
|
|
5
|
+
One tool, `agent`, that hands a task to a fresh copy of Pi and gives back its final answer as text. Wide searches, heavy reading and independent reviews happen in their context rather than yours. There's nothing to set up, and no agents to define.
|
|
6
|
+
|
|
7
|
+
| `type` | What it can do |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `explore` (the default) | Only `read`, `grep`, `find` and `ls`. Also allowed in `plan` and `read-only` |
|
|
10
|
+
| `general` | Everything, under your permission rules. Blocked in `plan` and `read-only` |
|
|
11
|
+
|
|
12
|
+
- **Parallel:** several calls in one message run at once.
|
|
13
|
+
- **Model:** it uses the session's model and reasoning. With `router/auto`, that's the `subagent` role in `/router`, so a cheaper model can do the reading.
|
|
14
|
+
- **Permissions:** a child's asks come to your session, and `auto` carries over to it.
|
|
15
|
+
- **Limits:** a child can't start its own agents, its answer is cut at 30,000 characters, it isn't saved as a session, and it starts as fast as Pi does. For long work in the background, use [pi-board](../board/README.md).
|
|
16
|
+
|
|
17
|
+
If you have `pi-subagents` installed, remove it, or the model has two tools for the same job.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huseynsnmz/pi-subagent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "One simple tool for handing a task to a fresh agent.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"private": true,
|
|
8
|
+
"scripts": {
|
|
9
|
+
"check": "tsc --noEmit",
|
|
10
|
+
"test": "node --test test/*.test.ts"
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
14
|
+
"typebox": "*"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@earendil-works/pi-coding-agent": "1.5.0",
|
|
18
|
+
"@types/node": "26.6.6",
|
|
19
|
+
"typescript": "7.0.2",
|
|
20
|
+
"typebox": "1.4.3"
|
|
21
|
+
}
|
|
22
|
+
}
|