@huseynsnmz/pi-suite 0.0.0-stage → 0.2.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 +28 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/SECURITY.md +5 -0
- package/package.json +112 -4
- package/packages/board/CHANGELOG.md +340 -0
- package/packages/board/CONTRIBUTING.md +7 -0
- package/packages/board/LICENSE +21 -0
- package/packages/board/README.md +88 -0
- package/packages/board/SECURITY.md +19 -0
- package/packages/board/docs/how-it-works.md +31 -0
- package/packages/board/docs/reference.md +278 -0
- package/packages/board/package.json +81 -0
- package/packages/board/runner/job-runner-legacy.mjs +68 -0
- package/packages/board/runner/job-runner.mjs +834 -0
- package/packages/board/runner/pty-runner-legacy.mjs +50 -0
- package/packages/board/runner/pty-runner.mjs +2175 -0
- package/packages/board/runner/state-coordinator.mjs +673 -0
- package/packages/board/runner/state-runner.mjs +203 -0
- package/packages/board/runner/title-runner.mjs +80 -0
- package/packages/board/src/commands/agent-board.ts +394 -0
- package/packages/board/src/commands/attach-decision.mjs +66 -0
- package/packages/board/src/commands/attach-flow.ts +251 -0
- package/packages/board/src/commands/bg.ts +103 -0
- package/packages/board/src/commands/board-model.ts +21 -0
- package/packages/board/src/core/atomic.mjs +184 -0
- package/packages/board/src/core/attach-delete.mjs +24 -0
- package/packages/board/src/core/attach-frame-proof.mjs +870 -0
- package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
- package/packages/board/src/core/attach-frame-stream.mjs +266 -0
- package/packages/board/src/core/attach-view-proof.mjs +709 -0
- package/packages/board/src/core/auto-state.mjs +377 -0
- package/packages/board/src/core/background-runs.mjs +273 -0
- package/packages/board/src/core/code-refs-store.mjs +315 -0
- package/packages/board/src/core/code-refs.mjs +913 -0
- package/packages/board/src/core/config.mjs +77 -0
- package/packages/board/src/core/control-protocol.mjs +482 -0
- package/packages/board/src/core/coordinator-client.mjs +633 -0
- package/packages/board/src/core/coordinator-journal.mjs +282 -0
- package/packages/board/src/core/coordinator-protocol.mjs +14 -0
- package/packages/board/src/core/cwd-stats.mjs +114 -0
- package/packages/board/src/core/dashboard-render.mjs +10 -0
- package/packages/board/src/core/derive.mjs +117 -0
- package/packages/board/src/core/diagnostics.mjs +133 -0
- package/packages/board/src/core/double-press.mjs +26 -0
- package/packages/board/src/core/editor-state-reporter.mjs +112 -0
- package/packages/board/src/core/events.mjs +286 -0
- package/packages/board/src/core/evidence.mjs +245 -0
- package/packages/board/src/core/follow-up-queue.mjs +211 -0
- package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
- package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
- package/packages/board/src/core/frame-witness-update.mjs +35 -0
- package/packages/board/src/core/heuristics.mjs +313 -0
- package/packages/board/src/core/hold-input.mjs +27 -0
- package/packages/board/src/core/host-coordination.mjs +300 -0
- package/packages/board/src/core/host-crash.mjs +79 -0
- package/packages/board/src/core/host-probe.mjs +207 -0
- package/packages/board/src/core/host-protocol.mjs +73 -0
- package/packages/board/src/core/ids.mjs +35 -0
- package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
- package/packages/board/src/core/invocation.mjs +43 -0
- package/packages/board/src/core/launch-options.mjs +418 -0
- package/packages/board/src/core/launch.mjs +157 -0
- package/packages/board/src/core/load-config.mjs +4 -0
- package/packages/board/src/core/locks.mjs +530 -0
- package/packages/board/src/core/paths.mjs +183 -0
- package/packages/board/src/core/pid.mjs +75 -0
- package/packages/board/src/core/prewarm-schedule.mjs +41 -0
- package/packages/board/src/core/process-identity-probe.mjs +228 -0
- package/packages/board/src/core/prompt-transport.mjs +13 -0
- package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
- package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
- package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
- package/packages/board/src/core/pty-attach-render.mjs +229 -0
- package/packages/board/src/core/pty-links.mjs +81 -0
- package/packages/board/src/core/pty-scroll.mjs +522 -0
- package/packages/board/src/core/pty-support.mjs +327 -0
- package/packages/board/src/core/repo.mjs +103 -0
- package/packages/board/src/core/rows.mjs +414 -0
- package/packages/board/src/core/screen-log-gc.mjs +198 -0
- package/packages/board/src/core/screen-log.mjs +160 -0
- package/packages/board/src/core/session-view.mjs +174 -0
- package/packages/board/src/core/slash-commands.mjs +52 -0
- package/packages/board/src/core/state-commands.mjs +757 -0
- package/packages/board/src/core/status-consistency.mjs +98 -0
- package/packages/board/src/core/steering-prompts.mjs +34 -0
- package/packages/board/src/core/steering.mjs +133 -0
- package/packages/board/src/core/stop-latch.mjs +41 -0
- package/packages/board/src/core/store.mjs +523 -0
- package/packages/board/src/core/suspend.mjs +36 -0
- package/packages/board/src/core/terminal-attach-client.mjs +981 -0
- package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
- package/packages/board/src/core/terminal-model.mjs +222 -0
- package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
- package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
- package/packages/board/src/core/terminal-snapshot.mjs +440 -0
- package/packages/board/src/core/test-knobs.mjs +28 -0
- package/packages/board/src/core/title.mjs +43 -0
- package/packages/board/src/core/types.mjs +414 -0
- package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
- package/packages/board/src/core/worktree.mjs +65 -0
- package/packages/board/src/index.ts +303 -0
- package/packages/board/src/runtime/service.mjs +3222 -0
- package/packages/board/src/ui/chrome.ts +20 -0
- package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
- package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
- package/packages/board/src/ui/dashboard.ts +2237 -0
- package/packages/board/src/ui/double-left.ts +30 -0
- package/packages/board/src/ui/external-editor.ts +25 -0
- package/packages/board/src/ui/focus.ts +32 -0
- package/packages/board/src/ui/pty-attach.ts +3184 -0
- package/packages/board/src/ui/waiting.ts +30 -0
- package/packages/commands/LICENSE +21 -0
- package/packages/commands/README.md +19 -0
- package/packages/commands/package.json +22 -0
- package/packages/commands/src/index.ts +43 -0
- package/packages/commands/src/security-review.ts +109 -0
- package/packages/frame/CHANGELOG.md +113 -0
- package/packages/frame/CONTRIBUTING.md +7 -0
- package/packages/frame/LICENSE +22 -0
- package/packages/frame/README.md +105 -0
- package/packages/frame/SECURITY.md +16 -0
- package/packages/frame/docs/how-it-works.md +53 -0
- package/packages/frame/docs/reference.md +78 -0
- package/packages/frame/package.json +72 -0
- package/packages/frame/src/data/git.ts +90 -0
- package/packages/frame/src/data/github.ts +140 -0
- package/packages/frame/src/data/gitlab.ts +63 -0
- package/packages/frame/src/data/mode.ts +49 -0
- package/packages/frame/src/data/past-prompts.ts +57 -0
- package/packages/frame/src/data/runner.ts +32 -0
- package/packages/frame/src/data/snapshot.ts +223 -0
- package/packages/frame/src/data/start-notes.ts +104 -0
- package/packages/frame/src/data/telemetry.ts +337 -0
- package/packages/frame/src/data/todos.ts +162 -0
- package/packages/frame/src/data/totals.ts +94 -0
- package/packages/frame/src/data/visits.ts +45 -0
- package/packages/frame/src/index.ts +696 -0
- package/packages/frame/src/kit/LICENSE +21 -0
- package/packages/frame/src/kit/app/attribution.ts +30 -0
- package/packages/frame/src/kit/app/builder.ts +186 -0
- package/packages/frame/src/kit/app/claim.ts +27 -0
- package/packages/frame/src/kit/app/feature.ts +91 -0
- package/packages/frame/src/kit/app/host.ts +214 -0
- package/packages/frame/src/kit/app/renderers.ts +62 -0
- package/packages/frame/src/kit/app/scope.ts +125 -0
- package/packages/frame/src/kit/board/model.ts +337 -0
- package/packages/frame/src/kit/contract.ts +79 -0
- package/packages/frame/src/kit/contracts/accounts.ts +33 -0
- package/packages/frame/src/kit/contracts/ask.ts +120 -0
- package/packages/frame/src/kit/contracts/board.ts +6 -0
- package/packages/frame/src/kit/contracts/by.ts +22 -0
- package/packages/frame/src/kit/contracts/calls.ts +46 -0
- package/packages/frame/src/kit/contracts/events.ts +89 -0
- package/packages/frame/src/kit/contracts/mode.ts +26 -0
- package/packages/frame/src/kit/contracts/names.ts +12 -0
- package/packages/frame/src/kit/contracts/peers.ts +77 -0
- package/packages/frame/src/kit/contracts/prompts.ts +8 -0
- package/packages/frame/src/kit/contracts/screen.ts +75 -0
- package/packages/frame/src/kit/contracts/skills.ts +81 -0
- package/packages/frame/src/kit/contracts/sources.ts +81 -0
- package/packages/frame/src/kit/contracts/views.ts +42 -0
- package/packages/frame/src/kit/control.ts +59 -0
- package/packages/frame/src/kit/dark-theme.ts +41 -0
- package/packages/frame/src/kit/decode.ts +210 -0
- package/packages/frame/src/kit/events/log.ts +313 -0
- package/packages/frame/src/kit/format.ts +25 -0
- package/packages/frame/src/kit/host.ts +4 -0
- package/packages/frame/src/kit/index.ts +25 -0
- package/packages/frame/src/kit/legacy-tasks.ts +39 -0
- package/packages/frame/src/kit/overlay-images.ts +36 -0
- package/packages/frame/src/kit/paths.ts +16 -0
- package/packages/frame/src/kit/screen/client.ts +74 -0
- package/packages/frame/src/kit/screen/feature.ts +64 -0
- package/packages/frame/src/kit/screen/model.ts +182 -0
- package/packages/frame/src/kit/screen/view.ts +838 -0
- package/packages/frame/src/kit/settings/files.ts +90 -0
- package/packages/frame/src/kit/settings/setting.ts +57 -0
- package/packages/frame/src/kit/settings/store.ts +192 -0
- package/packages/frame/src/kit/sources/file.ts +129 -0
- package/packages/frame/src/kit/sources/follow.ts +155 -0
- package/packages/frame/src/kit/testing.ts +260 -0
- package/packages/frame/src/kit/totals.ts +72 -0
- package/packages/frame/src/kit/ui/entry.ts +75 -0
- package/packages/frame/src/kit/ui/frame.ts +212 -0
- package/packages/frame/src/kit/ui/glyphs.ts +132 -0
- package/packages/frame/src/kit/ui/scroll.ts +14 -0
- package/packages/frame/src/render/diff.ts +157 -0
- package/packages/frame/src/render/fit.ts +86 -0
- package/packages/frame/src/render/format.ts +108 -0
- package/packages/frame/src/render/frame.ts +106 -0
- package/packages/frame/src/render/glyphs.ts +189 -0
- package/packages/frame/src/render/keys.ts +43 -0
- package/packages/frame/src/render/lead.ts +37 -0
- package/packages/frame/src/render/paint.ts +135 -0
- package/packages/frame/src/render/safe.ts +19 -0
- package/packages/frame/src/render/segments.ts +735 -0
- package/packages/frame/src/render/streaming.ts +223 -0
- package/packages/frame/src/render/timing.ts +106 -0
- package/packages/frame/src/render/tools.ts +167 -0
- package/packages/frame/src/settings.ts +105 -0
- package/packages/frame/src/ui/editor.ts +272 -0
- package/packages/frame/src/ui/effort.ts +36 -0
- package/packages/frame/src/ui/footer.ts +165 -0
- package/packages/frame/src/ui/header.ts +362 -0
- package/packages/frame/src/ui/history-search.ts +23 -0
- package/packages/frame/src/ui/interrupt.ts +21 -0
- package/packages/frame/src/ui/outputs.ts +170 -0
- package/packages/frame/src/ui/pi-entries.ts +298 -0
- package/packages/frame/src/ui/previews.ts +72 -0
- package/packages/frame/src/ui/queue.ts +75 -0
- package/packages/frame/src/ui/screen.ts +59 -0
- package/packages/frame/src/ui/timings.ts +48 -0
- package/packages/frame/src/ui/todos.ts +205 -0
- package/packages/frame/src/ui/tool-frame.ts +595 -0
- package/packages/frame/src/ui/working.ts +25 -0
- package/packages/frame/src/view/state.ts +97 -0
- package/packages/frame/src/view/transcript.ts +175 -0
- package/packages/frame/src/view/view.ts +162 -0
- package/packages/memory/CONTRIBUTING.md +7 -0
- package/packages/memory/LICENSE +21 -0
- package/packages/memory/README.md +44 -0
- package/packages/memory/SECURITY.md +20 -0
- package/packages/memory/package.json +55 -0
- package/packages/memory/skills/review-memory/SKILL.md +48 -0
- package/packages/memory/src/index.ts +163 -0
- package/packages/memory/src/prompt.ts +25 -0
- package/packages/memory/src/review.ts +150 -0
- package/packages/memory/src/secrets.ts +22 -0
- package/packages/memory/src/store.ts +173 -0
- package/packages/modes/CHANGELOG.md +104 -0
- package/packages/modes/CONTRIBUTING.md +7 -0
- package/packages/modes/LICENSE +21 -0
- package/packages/modes/README.md +174 -0
- package/packages/modes/SECURITY.md +17 -0
- package/packages/modes/package.json +55 -0
- package/packages/modes/skills/review-permissions/SKILL.md +54 -0
- package/packages/modes/src/bash.ts +218 -0
- package/packages/modes/src/deny-off.ts +62 -0
- package/packages/modes/src/git-safety.ts +72 -0
- package/packages/modes/src/index.ts +338 -0
- package/packages/modes/src/interview.ts +206 -0
- package/packages/modes/src/judge-gate.ts +113 -0
- package/packages/modes/src/modes.ts +121 -0
- package/packages/modes/src/review/apply.mjs +156 -0
- package/packages/modes/src/review/collect.mjs +208 -0
- package/packages/modes/src/review/tools.ts +71 -0
- package/packages/modes/src/sandbox.ts +37 -0
- package/packages/router/CHANGELOG.md +8 -0
- package/packages/router/LICENSE +21 -0
- package/packages/router/README.md +90 -0
- package/packages/router/package.json +51 -0
- package/packages/router/src/config.ts +65 -0
- package/packages/router/src/index.ts +172 -0
- package/packages/router/src/judge.ts +51 -0
- package/packages/router/src/route.ts +67 -0
- package/packages/web/CHANGELOG.md +36 -0
- package/packages/web/LICENSE +21 -0
- package/packages/web/README.md +87 -0
- package/packages/web/SECURITY.md +13 -0
- package/packages/web/package.json +59 -0
- package/packages/web/src/config.ts +109 -0
- package/packages/web/src/dnsfilter.ts +97 -0
- package/packages/web/src/extract.ts +65 -0
- package/packages/web/src/index.ts +217 -0
- package/packages/web/src/markdown.ts +122 -0
- package/packages/web/src/net.ts +109 -0
- package/packages/web/src/reader.ts +73 -0
- package/packages/web/src/sanitize.ts +50 -0
- package/packages/web/src/search.ts +76 -0
- package/packages/web/src/secrets.ts +22 -0
- package/packages/web/src/validate.ts +186 -0
- package/src/index.ts +156 -0
- package/src/settings.ts +79 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# pi-router
|
|
2
|
+
|
|
3
|
+
Part of [pi-suite](../../README.md). Turn it off with `/suite off router`.
|
|
4
|
+
|
|
5
|
+
Adds `router/auto` to [Pi](https://pi.dev): one choice in `/model` that uses a strong model to plan and a cheaper one to build, and gives the page reader, the permission judge and subagents their own models.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
Requires Pi 1.5 or newer.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pi install npm:@huseynsnmz/pi-suite@0.1.0
|
|
13
|
+
```
|
|
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`.
|
|
16
|
+
|
|
17
|
+
## Roles
|
|
18
|
+
|
|
19
|
+
| Role | What it does | With none set |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `plan` | Reads and plans, up to the first edit, and everything in `plan` mode | Required |
|
|
22
|
+
| `build` | Everything after that, and compaction summaries | Required |
|
|
23
|
+
| `reader` | Reads web pages for [pi-web](https://github.com/huseynsnmz/pi-suite/tree/main/packages/web) | Same as `build` |
|
|
24
|
+
| `judge` | Decides permission asks for `pi-permission-classifier` | See below |
|
|
25
|
+
| `subagent` | Runs subagents that use `router/auto` | Same as `build` |
|
|
26
|
+
| `fallback` | Takes over when a provider is overloaded or rate limited | Retries on the same model |
|
|
27
|
+
|
|
28
|
+
`/router` saves them in `~/.pi/agent/router.json`, apart from `judge`, which goes in the judge's own config:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"plan": "anthropic/claude-opus-5-5",
|
|
33
|
+
"build": "anthropic/claude-haiku-5-5",
|
|
34
|
+
"fallback": "openai/gpt-5.6-luna"
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
There's no setting per project, so a repository can't choose where your code gets sent. The thinking level you pick with `/effort` applies to every role, and Pi lowers it for a model that can't do it.
|
|
39
|
+
|
|
40
|
+
## How it decides
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
new session ──> plan model ──── first successful edit ───> build model
|
|
44
|
+
▲ │
|
|
45
|
+
└── plan mode on ──────────────────────────┤
|
|
46
|
+
│
|
|
47
|
+
leaving plan mode ─────────────────────────┘
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- Moving from `plan` to `build` happens once in a session. It loses the prompt cache one time, as any change of model does.
|
|
51
|
+
- The session stays on `build` after that. For a hard follow-up, switch to `plan` mode, or start a new session.
|
|
52
|
+
- 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 does a broken `router.json`. The router doesn't pick another model for you.
|
|
54
|
+
- Leaving `plan` mode counts as finishing planning, whether you approved a plan or changed mode.
|
|
55
|
+
- 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
|
+
- `/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.
|
|
57
|
+
|
|
58
|
+
The phase follows the session tree, so a branch keeps its own.
|
|
59
|
+
|
|
60
|
+
## With other extensions
|
|
61
|
+
|
|
62
|
+
**`modes`:** tells the router the mode. With it off, the router only goes by the first edit.
|
|
63
|
+
|
|
64
|
+
**`web`:** your `readerModel` in `web.json` still wins over the `reader` role.
|
|
65
|
+
|
|
66
|
+
**`frame`:** shows who answered, such as `Auto → Haiku`. Pi's own footer already does.
|
|
67
|
+
|
|
68
|
+
**`pi-permission-classifier`:** it calls its model directly, which can't work with a virtual model. With `router/auto` selected and no judge model, every ask comes to you, and the router warns you. Setting `judge` in `/router` writes the model into the judge's own config, changing only its `provider` and `model`, so `/permission-model` shows the same choice. Run `/reload` afterwards. The `judge` role doesn't have to be the cheap model, and I'd choose carefully, because it decides what runs in `auto`. A project's own judge config can still override it.
|
|
69
|
+
|
|
70
|
+
**`pi-subagents`:** a subagent uses the `subagent` role only if its model is `router/auto`, for example through `model: "inherit"`. A subagent with its own model ignores the router. The router recognises a subagent by `PI_SUBAGENT_CHILD=1`, and that was tested by setting the variable rather than through a real subagent.
|
|
71
|
+
|
|
72
|
+
**`pi-board`:** its model list includes `router/auto`, but sessions started that way haven't been tested.
|
|
73
|
+
|
|
74
|
+
## Limits
|
|
75
|
+
|
|
76
|
+
- **It isn't a cost limit.** It chooses models and doesn't stop spending.
|
|
77
|
+
- **Changing model mid-session can lower quality.** The build model sees the plan model's work, but not its private reasoning.
|
|
78
|
+
- **It doesn't judge how hard a task is.** A one-line change still starts on the plan model.
|
|
79
|
+
- **Another extension that calls the virtual model directly gets the `build` model.**
|
|
80
|
+
|
|
81
|
+
## Development
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npm install --ignore-scripts
|
|
85
|
+
npm run check
|
|
86
|
+
npm test
|
|
87
|
+
npm run test:e2e
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The end-to-end run drives real Pi in a real terminal with a fake model, using all of pi-suite and the real permission extensions.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@huseynsnmz/pi-router",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A virtual model for Pi: a strong model to plan, a cheap one to build, and one model each for the page reader, the judge and subagents.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"pi-package",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"virtual-model",
|
|
11
|
+
"model-routing"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/huseynsnmz/pi-suite.git",
|
|
16
|
+
"directory": "packages/router"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"README.md",
|
|
24
|
+
"CHANGELOG.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"pi": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./src/index.ts"
|
|
30
|
+
]
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=22.18"
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"check": "tsc --noEmit",
|
|
37
|
+
"test": "node --test test/*.test.ts",
|
|
38
|
+
"test:e2e": "python3 test/e2e/run.py"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-ai": "*",
|
|
42
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@earendil-works/pi-ai": "1.5.0",
|
|
46
|
+
"@earendil-works/pi-coding-agent": "1.5.0",
|
|
47
|
+
"@types/node": "26.6.6",
|
|
48
|
+
"typescript": "7.0.2"
|
|
49
|
+
},
|
|
50
|
+
"private": true
|
|
51
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// ~/.pi/agent/router.json. Global only: a repository must not choose where your code gets sent.
|
|
2
|
+
import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** 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;
|
|
7
|
+
export type Role = (typeof ROLES)[number];
|
|
8
|
+
|
|
9
|
+
export interface RouterConfig {
|
|
10
|
+
plan: string;
|
|
11
|
+
build: string;
|
|
12
|
+
reader?: string;
|
|
13
|
+
subagent?: string;
|
|
14
|
+
fallback?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SavedConfig = Partial<Record<Role, string>>;
|
|
18
|
+
|
|
19
|
+
export const VIRTUAL_PROVIDER = "router";
|
|
20
|
+
export const VIRTUAL_ID = "auto";
|
|
21
|
+
|
|
22
|
+
export function configPath(agentDir: string): string {
|
|
23
|
+
return join(agentDir, "router.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseRef(ref: string): { provider: string; id: string } {
|
|
27
|
+
const at = ref.indexOf("/");
|
|
28
|
+
return { provider: ref.slice(0, at), id: ref.slice(at + 1) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Throws on anything it doesn't understand, so a typo can't quietly send work to the wrong model. */
|
|
32
|
+
export function parseConfig(raw: unknown): SavedConfig {
|
|
33
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error("router.json must be an object");
|
|
34
|
+
for (const [role, ref] of Object.entries(raw)) {
|
|
35
|
+
if (!(ROLES as readonly string[]).includes(role)) throw new Error(`router.json: unknown role "${role}". Use ${ROLES.join(", ")}`);
|
|
36
|
+
if (typeof ref !== "string" || !/^[^/\s]+\/\S+$/.test(ref)) throw new Error(`router.json: ${role} must look like "provider/model"`);
|
|
37
|
+
if (ref.startsWith(`${VIRTUAL_PROVIDER}/`)) throw new Error(`router.json: ${role} can't be a virtual model`);
|
|
38
|
+
}
|
|
39
|
+
return raw as SavedConfig;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isComplete(saved: SavedConfig): saved is RouterConfig {
|
|
43
|
+
return saved.plan !== undefined && saved.build !== undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** An absent file is a router nobody has set up yet. A broken one throws. */
|
|
47
|
+
export function loadConfig(agentDir: string): SavedConfig {
|
|
48
|
+
let text: string;
|
|
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));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function saveConfig(agentDir: string, saved: SavedConfig): void {
|
|
59
|
+
// A linked file, as in a dotfiles setup, stays linked.
|
|
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);
|
|
65
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { isComplete, loadConfig, parseConfig, parseRef, type Role, ROLES, type SavedConfig, saveConfig, VIRTUAL_ID, VIRTUAL_PROVIDER } from "./config.ts";
|
|
4
|
+
import { judgeModel, setJudgeModel } from "./judge.ts";
|
|
5
|
+
import { chooseModel, type RouterState } from "./route.ts";
|
|
6
|
+
|
|
7
|
+
/** Other extensions emit these with an object, and whoever knows fills it in, whatever the load order. */
|
|
8
|
+
const READER_PROBE = "pi-router:reader-model";
|
|
9
|
+
const MODE_PROBE = "pi-modes:mode";
|
|
10
|
+
|
|
11
|
+
const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
12
|
+
const VIRTUAL_API = "pi-virtual";
|
|
13
|
+
|
|
14
|
+
const JUDGE = "judge";
|
|
15
|
+
const JUDGE_HELP = "decides permission asks for pi-permission-classifier";
|
|
16
|
+
const ROLE_HELP: Record<Role, string> = {
|
|
17
|
+
plan: "reads and plans, until the first edit, and all of plan mode",
|
|
18
|
+
build: "the rest of the work, and compaction summaries",
|
|
19
|
+
reader: "reads web pages for pi-web",
|
|
20
|
+
subagent: "runs subagents that use this virtual model",
|
|
21
|
+
fallback: "takes over when a provider is overloaded",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** What a role shows, and offers to go back to, with no model of its own. */
|
|
25
|
+
const UNSET: Partial<Record<Role, string>> = {
|
|
26
|
+
reader: "same as build",
|
|
27
|
+
subagent: "same as build",
|
|
28
|
+
fallback: "none",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function refOf(model: { provider: string; id: string } | undefined): string | undefined {
|
|
32
|
+
return model === undefined ? undefined : `${model.provider}/${model.id}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function piRouter(pi: ExtensionAPI): void {
|
|
36
|
+
const agentDir = getAgentDir();
|
|
37
|
+
let saved: SavedConfig = {};
|
|
38
|
+
let problem: string | undefined;
|
|
39
|
+
try {
|
|
40
|
+
saved = loadConfig(agentDir);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
problem = `pi-router is off: ${(error as Error).message}`;
|
|
43
|
+
}
|
|
44
|
+
let registered = false;
|
|
45
|
+
let warnedJudge = false;
|
|
46
|
+
|
|
47
|
+
pi.events.on(READER_PROBE, (data) => {
|
|
48
|
+
if (!isComplete(saved)) return;
|
|
49
|
+
(data as { model?: string }).model = saved.reader ?? saved.build;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function currentMode(): string | undefined {
|
|
53
|
+
const probe: { mode?: string } = {};
|
|
54
|
+
pi.events.emit(MODE_PROBE, probe);
|
|
55
|
+
return probe.mode;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function register(): void {
|
|
59
|
+
// A broken file still registers, so router/auto stops with the reason rather than Pi picking another model.
|
|
60
|
+
if (registered || (!isComplete(saved) && problem === undefined)) return;
|
|
61
|
+
registered = true;
|
|
62
|
+
pi.registerVirtualModel<RouterState>({
|
|
63
|
+
provider: VIRTUAL_PROVIDER,
|
|
64
|
+
id: VIRTUAL_ID,
|
|
65
|
+
name: "Auto",
|
|
66
|
+
thinkingLevels: [...THINKING_LEVELS],
|
|
67
|
+
route(request, ctx) {
|
|
68
|
+
if (problem !== undefined) throw new Error(problem);
|
|
69
|
+
if (!isComplete(saved)) throw new Error("pi-router has no plan and build models. Run /router");
|
|
70
|
+
const choice = chooseModel(saved, {
|
|
71
|
+
reason: request.reason,
|
|
72
|
+
mode: currentMode(),
|
|
73
|
+
subagent: process.env.PI_SUBAGENT_CHILD === "1",
|
|
74
|
+
state: request.state,
|
|
75
|
+
previous: refOf(request.previous?.model),
|
|
76
|
+
failed: request.failed && { model: refOf(request.failed.model) as string, errorMessage: request.failed.message.errorMessage },
|
|
77
|
+
messages: request.messages,
|
|
78
|
+
});
|
|
79
|
+
const { provider, id } = parseRef(choice.model);
|
|
80
|
+
const model = ctx.modelRegistry.find(provider, id);
|
|
81
|
+
if (model === undefined) throw new Error(`pi-router: ${choice.model} isn't a known model. Run /router`);
|
|
82
|
+
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 };
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
register();
|
|
88
|
+
|
|
89
|
+
/** With a virtual model selected and no model of its own, the judge fails and every ask comes to you. */
|
|
90
|
+
function warnAboutJudge(ctx: ExtensionContext): void {
|
|
91
|
+
if (warnedJudge || !ctx.hasUI || refOf(ctx.model) !== `${VIRTUAL_PROVIDER}/${VIRTUAL_ID}`) return;
|
|
92
|
+
if (judgeModel(agentDir) !== null) return;
|
|
93
|
+
warnedJudge = true;
|
|
94
|
+
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
|
+
}
|
|
96
|
+
|
|
97
|
+
pi.on("session_start", (_event, ctx) => {
|
|
98
|
+
register();
|
|
99
|
+
if (problem !== undefined && ctx.hasUI) ctx.ui.notify(problem, "error");
|
|
100
|
+
warnAboutJudge(ctx);
|
|
101
|
+
});
|
|
102
|
+
// Pi keeps virtual models across a reload, so one left here would outlive this extension being turned off.
|
|
103
|
+
pi.on("session_shutdown", () => {
|
|
104
|
+
if (!registered) return;
|
|
105
|
+
registered = false;
|
|
106
|
+
pi.unregisterVirtualModel(VIRTUAL_PROVIDER, VIRTUAL_ID);
|
|
107
|
+
});
|
|
108
|
+
pi.on("model_select", (_event, ctx) => warnAboutJudge(ctx));
|
|
109
|
+
|
|
110
|
+
function setRole(role: Role, ref: string | undefined): void {
|
|
111
|
+
// Another session may have changed it since this one read it.
|
|
112
|
+
const next: SavedConfig = { ...loadConfig(agentDir) };
|
|
113
|
+
if (ref === undefined) delete next[role];
|
|
114
|
+
else next[role] = ref;
|
|
115
|
+
saved = parseConfig(next);
|
|
116
|
+
saveConfig(agentDir, saved);
|
|
117
|
+
register();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function chooseJudge(ctx: ExtensionContext, models: readonly string[]): Promise<void> {
|
|
121
|
+
const picked = await ctx.ui.select(`${JUDGE} ${JUDGE_HELP}`, ["not set", ...models]);
|
|
122
|
+
if (picked === undefined) return;
|
|
123
|
+
try {
|
|
124
|
+
setJudgeModel(agentDir, picked === "not set" ? undefined : picked);
|
|
125
|
+
ctx.ui.notify("Judge model set. Run /reload for the judge to use it.", "info");
|
|
126
|
+
} catch (error) {
|
|
127
|
+
ctx.ui.notify(`Couldn't set the judge, so nothing changed. Is pi-permission-classifier set up? ${(error as Error).message}`, "error");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function chooseRoles(ctx: ExtensionContext): Promise<void> {
|
|
132
|
+
if (problem !== undefined) {
|
|
133
|
+
// Saving over it would throw away whatever the file held.
|
|
134
|
+
ctx.ui.notify(`${problem}. Fix or delete ~/.pi/agent/router.json, then /reload.`, "error");
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const models = ctx.modelRegistry.getAvailable().filter((model) => model.api !== VIRTUAL_API).map((model) => `${model.provider}/${model.id}`);
|
|
138
|
+
if (models.length === 0) {
|
|
139
|
+
ctx.ui.notify("No models to choose from. Run /login first.", "warning");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
for (;;) {
|
|
143
|
+
const judge = judgeModel(agentDir);
|
|
144
|
+
const rows = [...ROLES.map((role) => `${role}: ${saved[role] ?? UNSET[role] ?? "not set"}`), `${JUDGE}: ${judge ?? (judge === null ? "not set" : "not installed")}`];
|
|
145
|
+
const row = await ctx.ui.select("Router: which role? (Esc when done)", rows);
|
|
146
|
+
if (row === undefined) break;
|
|
147
|
+
const role = ROLES[rows.indexOf(row)];
|
|
148
|
+
if (role === undefined) {
|
|
149
|
+
await chooseJudge(ctx, models);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const unset = UNSET[role];
|
|
153
|
+
const picked = await ctx.ui.select(`${role} ${ROLE_HELP[role]}`, [...(unset === undefined ? [] : [unset]), ...models]);
|
|
154
|
+
if (picked === undefined) continue;
|
|
155
|
+
try {
|
|
156
|
+
setRole(role, picked === unset ? undefined : picked);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
ctx.ui.notify((error as Error).message, "error");
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (isComplete(saved)) ctx.ui.notify(`Choose ${VIRTUAL_PROVIDER}/${VIRTUAL_ID} in /model to use these.`, "info");
|
|
162
|
+
else ctx.ui.notify("The router needs both plan and build before router/auto appears.", "warning");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
pi.registerCommand("router", {
|
|
166
|
+
description: "Choose which model does what for router/auto",
|
|
167
|
+
handler: async (_args, ctx) => {
|
|
168
|
+
if (!ctx.hasUI) return;
|
|
169
|
+
await chooseRoles(ctx);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// pi-permission-classifier calls its model directly, which can't work with a virtual model, and it
|
|
2
|
+
// has no way to ask another extension. So its own setting holds the judge's model.
|
|
3
|
+
import { fchmodSync, closeSync, fsyncSync, openSync, readFileSync, realpathSync, renameSync, statSync, writeSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { parseRef } from "./config.ts";
|
|
7
|
+
|
|
8
|
+
function judgeConfigPath(agentDir: string): string {
|
|
9
|
+
return join(agentDir, "extensions", "pi-permission-classifier", "config.json");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function readObject(path: string): Record<string, unknown> {
|
|
13
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
14
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`${path} is not a JSON object`);
|
|
15
|
+
return parsed as Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Undefined when the judge isn't set up here, so there's nothing to wire. */
|
|
19
|
+
export function judgeModel(agentDir: string): string | null | undefined {
|
|
20
|
+
let config: Record<string, unknown>;
|
|
21
|
+
try {
|
|
22
|
+
config = readObject(judgeConfigPath(agentDir));
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return typeof config.provider === "string" && typeof config.model === "string" ? `${config.provider}/${config.model}` : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Changes only these two settings. Throws, touching nothing, if the file is absent or broken. */
|
|
30
|
+
export function setJudgeModel(agentDir: string, ref: string | undefined): void {
|
|
31
|
+
const target = realpathSync(judgeConfigPath(agentDir));
|
|
32
|
+
const config = readObject(target);
|
|
33
|
+
delete config.provider;
|
|
34
|
+
delete config.model;
|
|
35
|
+
if (ref !== undefined) {
|
|
36
|
+
const { provider, id } = parseRef(ref);
|
|
37
|
+
config.provider = provider;
|
|
38
|
+
config.model = id;
|
|
39
|
+
}
|
|
40
|
+
const mode = statSync(target).mode & 0o777;
|
|
41
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
42
|
+
const file = openSync(temp, "w", mode);
|
|
43
|
+
try {
|
|
44
|
+
writeSync(file, `${JSON.stringify(config, null, 2)}\n`);
|
|
45
|
+
fchmodSync(file, mode);
|
|
46
|
+
fsyncSync(file);
|
|
47
|
+
} finally {
|
|
48
|
+
closeSync(file);
|
|
49
|
+
}
|
|
50
|
+
renameSync(temp, target);
|
|
51
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Which real model answers a request. Pure, so every rule can be tested without Pi.
|
|
2
|
+
import type { RouterConfig } from "./config.ts";
|
|
3
|
+
|
|
4
|
+
export interface RouterState {
|
|
5
|
+
phase: "plan" | "build";
|
|
6
|
+
/** Plan mode was on. Leaving it, by approving a plan or changing mode, starts building. */
|
|
7
|
+
planned?: true;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RouteInput {
|
|
11
|
+
reason: "user" | "continuation" | "retry" | "direct";
|
|
12
|
+
/** From pi-modes, when it is installed. */
|
|
13
|
+
mode: string | undefined;
|
|
14
|
+
/** This Pi was started by pi-subagents. */
|
|
15
|
+
subagent: boolean;
|
|
16
|
+
state: RouterState | undefined;
|
|
17
|
+
previous: string | undefined;
|
|
18
|
+
failed: { model: string; errorMessage: string | undefined } | undefined;
|
|
19
|
+
messages: readonly { role: string; toolName?: string; isError?: boolean }[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RouteChoice {
|
|
23
|
+
model: string;
|
|
24
|
+
/** Only when it changed, because Pi stores every new object it gets. */
|
|
25
|
+
state?: RouterState;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const OVERLOADED = /overload|rate.?limit|too many requests|capacity|\b(429|503|529)\b/i;
|
|
29
|
+
/** Includes pi-modes' yolo_edit and yolo_write. */
|
|
30
|
+
const CHANGES_FILES = /(^|_)(edit|write)$/;
|
|
31
|
+
|
|
32
|
+
/** Anywhere in the conversation, so a message sent right after an edit can't hide it. */
|
|
33
|
+
export function hasEdited(messages: RouteInput["messages"]): boolean {
|
|
34
|
+
return messages.some((message) => message.role === "toolResult" && !message.isError && CHANGES_FILES.test(message.toolName ?? ""));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function nextState(input: RouteInput): RouterState {
|
|
38
|
+
const state = input.state ?? { phase: "plan" };
|
|
39
|
+
if (input.mode === "plan") return state.phase === "plan" && state.planned ? state : { phase: "plan", planned: true };
|
|
40
|
+
if (state.phase === "plan" && (state.planned || hasEdited(input.messages))) return { phase: "build" };
|
|
41
|
+
return state;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function chooseModel(config: RouterConfig, input: RouteInput): RouteChoice {
|
|
45
|
+
const home = input.subagent ? (config.subagent ?? config.build) : config.build;
|
|
46
|
+
// Compaction summaries, and any other extension's call that names the virtual model.
|
|
47
|
+
if (input.reason === "direct") return { model: home };
|
|
48
|
+
|
|
49
|
+
// Worked out first, so an edit made on the fallback or during a retry still counts.
|
|
50
|
+
const state = input.subagent ? input.state : nextState(input);
|
|
51
|
+
const changed = state === input.state ? {} : { state };
|
|
52
|
+
|
|
53
|
+
if (input.reason === "retry" && input.failed) {
|
|
54
|
+
const overloaded = OVERLOADED.test(input.failed.errorMessage ?? "");
|
|
55
|
+
const moves = overloaded && config.fallback !== undefined && config.fallback !== input.failed.model;
|
|
56
|
+
return { model: moves ? (config.fallback as string) : input.failed.model, ...changed };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Back on the first choice at the next message, not on every tool call in between.
|
|
60
|
+
if (input.reason === "continuation" && input.previous !== undefined && input.previous === config.fallback) {
|
|
61
|
+
return { model: input.previous, ...changed };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (input.subagent) return { model: home };
|
|
65
|
+
const model = input.mode === "plan" || state?.phase === "plan" ? config.plan : config.build;
|
|
66
|
+
return { model, ...changed };
|
|
67
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.3]
|
|
4
|
+
|
|
5
|
+
### Changes
|
|
6
|
+
|
|
7
|
+
- **Virtual models**: pages can be read when the session's model is a virtual one, and the `/web reader` list leaves them out. With [pi-router](https://github.com/huseynsnmz/pi-router), the reader is the model it chooses, unless you set `readerModel`.
|
|
8
|
+
|
|
9
|
+
## [0.1.2] - 2026-11-04
|
|
10
|
+
|
|
11
|
+
### Changes
|
|
12
|
+
|
|
13
|
+
- **web_fetch**: the agent can follow any link on a page it has read, and write its own page addresses on sites you named or a search returned. It used to be refused anything you hadn't written or the tools hadn't returned, which stopped research. A new site asks you once, as does an address with a query string or encoded-looking text. `trustedDomains` skips the question for sites you choose, and `strictUrls` asks about every URL it wasn't given
|
|
14
|
+
- **site filter**: links in search results and answers are checked as well, so the agent isn't pointed at a blocked site
|
|
15
|
+
|
|
16
|
+
## [0.1.1] - 2026-11-04
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
- **reader**: `/web reader` picks the page reader from your models, saves it to `web.json`, and applies it straight away
|
|
21
|
+
|
|
22
|
+
## [0.1.0] - 2026-11-04
|
|
23
|
+
|
|
24
|
+
### Features
|
|
25
|
+
|
|
26
|
+
- **web_fetch**: finds something out from a page. A separate model with no tools reads it, and the agent only gets the answer
|
|
27
|
+
- **web_search**: searches through your own SearXNG server, or `markdown.new` if `companySearch` is on. It's off until you choose one
|
|
28
|
+
- **confidence**: the reader scores its answer from 1 to 10, and an answer below `minConfidence` is withheld
|
|
29
|
+
- **site filter**: Cloudflare's filtering DNS blocks known malware and adult sites before a fetch. It can be set to malware only, or off
|
|
30
|
+
|
|
31
|
+
### Security
|
|
32
|
+
|
|
33
|
+
- The agent can only fetch URLs you wrote or these tools returned, matched exactly
|
|
34
|
+
- Queries holding secrets, paths, email addresses, encoded text or your `privateWords` are refused
|
|
35
|
+
- Local and private addresses are refused, and checked again when connecting
|
|
36
|
+
- A wrong setting in `web.json` turns the web tools off
|
|
@@ -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,87 @@
|
|
|
1
|
+
# pi-web
|
|
2
|
+
|
|
3
|
+
Part of [pi-suite](../../README.md). Turn it off with `/suite off web`.
|
|
4
|
+
|
|
5
|
+
Web search and fetch for [Pi](https://pi.dev), built so that the agent never reads the web.
|
|
6
|
+
|
|
7
|
+
A web page can hold instructions aimed at the AI reading it. So a separate model with no tools reads every page and search result, and the agent only gets its answer. If that model isn't available, the page isn't read.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pi install npm:@huseynsnmz/pi-suite@0.1.0
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`web_fetch` works straight away. It reads pages with the session's model, unless you set `readerModel` or use [pi-router](https://github.com/huseynsnmz/pi-suite/tree/main/packages/router), which can choose the reader. `web_search` stays off until you choose where searches go.
|
|
16
|
+
|
|
17
|
+
## Search
|
|
18
|
+
|
|
19
|
+
Everything you search for leaves your machine, so nothing is chosen for you. Settings go in `~/.pi/agent/web.json`, and are read when Pi starts, so restart it after editing the file.
|
|
20
|
+
|
|
21
|
+
**Your own SearXNG server** needs no account, but it does pass your searches on to the search engines it uses:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{ "searxngUrl": "http://localhost:8888" }
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
SearXNG only answers in JSON if you list it under `search.formats` in its `settings.yml`. Public SearXNG servers almost always refuse this.
|
|
28
|
+
|
|
29
|
+
**markdown.new** is free and needs no key: 30 searches a minute and 500 a day for your IP, with a shared cap across everyone using it. Your searches go to its operator, then Serper and Google. This is behind a flag:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{ "companySearch": true }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Settings
|
|
36
|
+
|
|
37
|
+
| Setting | What it does |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `companySearch` | Allows searching through a company's service. Off by default |
|
|
40
|
+
| `provider` | `searxng` or `markdown.new`, if you've set up both |
|
|
41
|
+
| `searxngUrl` | Your SearXNG server |
|
|
42
|
+
| `readerModel` | The model that reads pages, as `provider/id`. A small one costs less. Defaults to the session's model |
|
|
43
|
+
| `allowDomains` | If set, only these domains and their subdomains can be fetched |
|
|
44
|
+
| `denyDomains` | These domains and their subdomains can't be fetched |
|
|
45
|
+
| `dnsFilter` | `cloudflare-family` blocks malware and adult sites, and is the default. `cloudflare-security` blocks malware only. `off` turns it off |
|
|
46
|
+
| `privateWords` | Words a search can never hold, such as client or project names |
|
|
47
|
+
| `minConfidence` | Answers the reader scores below this, out of 10, are withheld. Defaults to 4 |
|
|
48
|
+
| `trustedDomains` | Sites the agent can write its own addresses on without asking, such as docs you use a lot, and their subdomains. Other sites still work, with a prompt |
|
|
49
|
+
| `strictUrls` | Set to `true` to be asked about every URL the agent wasn't given |
|
|
50
|
+
| `askNewDomains` | Set to `true` to be asked before the first fetch from each site |
|
|
51
|
+
|
|
52
|
+
`/web` shows what's in use, and `/web reader` lets you pick the page reader from your models. It saves to `web.json` and applies straight away. If `web.json` can't be read or has a wrong value, the web tools stay off and Pi says why.
|
|
53
|
+
|
|
54
|
+
## What it checks
|
|
55
|
+
|
|
56
|
+
**Before anything leaves:**
|
|
57
|
+
|
|
58
|
+
- A query or URL that looks like it holds a secret is refused, as is a query holding a long encoded string.
|
|
59
|
+
- The agent can fetch URLs you wrote, and links from `web_search` and `web_fetch` pages. It can also write its own page addresses on sites you named, sites a search returned, and sites in `trustedDomains`.
|
|
60
|
+
- Any URL can carry data to whoever runs that site, so you get asked once before it writes an address on a new site, and about any address with a query string or encoded-looking text, or after 25 addresses of its own on one site. With nobody to ask, it's refused. `strictUrls` asks about every URL it wasn't given.
|
|
61
|
+
- Local and private addresses are refused, checked again at connection time, so a public name can't point at your network.
|
|
62
|
+
- A redirect to another host is reported, not followed. Fetching it goes through the same checks.
|
|
63
|
+
- Each site's name is checked with Cloudflare's filtering DNS before it's fetched, and links in search results and answers are checked before the agent sees them. If the filter can't answer, the page isn't fetched. With the filter on, bare IP addresses are refused, as they have no name to check.
|
|
64
|
+
|
|
65
|
+
**Before the reader sees a page:** scripts, images, forms, invisible characters, and elements hidden in the page or its own stylesheets are removed.
|
|
66
|
+
|
|
67
|
+
**Before the agent sees an answer:**
|
|
68
|
+
|
|
69
|
+
- The reader scores from 1 to 10 how well the page supports its answer, and the agent sees the score. Below `minConfidence` the answer is withheld. A page that fools the reader can fake this score, so it's a measure of quality, not safety.
|
|
70
|
+
- Links that weren't in the page are removed.
|
|
71
|
+
- An answer that reads like instructions to an AI is withheld.
|
|
72
|
+
- If the page itself holds such wording, the agent is told.
|
|
73
|
+
|
|
74
|
+
## What it can't do
|
|
75
|
+
|
|
76
|
+
- It can't stop a page lying. A page can still fool the reader into a false answer, so treat answers as claims from a stranger.
|
|
77
|
+
- It can't catch every attack. The wording checks are patterns, and text hidden by a stylesheet in another file isn't noticed. A page about prompt injection may also get its answer withheld.
|
|
78
|
+
- The site filter only knows the sites on Cloudflare's lists. It says nothing about a new or unlisted site, and every site name you fetch goes to Cloudflare.
|
|
79
|
+
- It can't stop every way of carrying data out. The checks catch secrets, paths and encoded text, but a few plain words in a search or a page address can still say something, on a site you've named or approved. A site you approve is trusted for the rest of the session.
|
|
80
|
+
- It can't tell a private word in a query from an ordinary one unless it's in `privateWords`, so the agent could still search for a project's name.
|
|
81
|
+
- The sandbox's domain list doesn't cover it, as it runs inside Pi. Use `allowDomains` and `denyDomains`.
|
|
82
|
+
- It doesn't run JavaScript or read PDFs, and sites that block bots return an error.
|
|
83
|
+
- It doesn't use a proxy, and it refuses `198.18.0.0/15`, so a VPN that hands out those addresses will block every fetch.
|
|
84
|
+
|
|
85
|
+
## Cost
|
|
86
|
+
|
|
87
|
+
Each `web_fetch` is one extra model call with up to 100,000 characters of web text, and each `web_search` one with up to 40,000. The same question on the same page within 15 minutes isn't asked again. Set `readerModel` to something small to keep the rest down.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
Please report a vulnerability through [GitHub's private reporting](https://github.com/huseynsnmz/pi-web/security/advisories/new), not in a public issue.
|
|
4
|
+
|
|
5
|
+
These count as vulnerabilities:
|
|
6
|
+
|
|
7
|
+
- Web text reaching the agent without passing through the page reader
|
|
8
|
+
- The agent getting a URL it wrote past the fetch check with a query string or encoded data in it
|
|
9
|
+
- Reaching a local or private address
|
|
10
|
+
- Getting a blocked site past the site filter, or the site filter failing open
|
|
11
|
+
- A company receiving searches while `companySearch` is off
|
|
12
|
+
|
|
13
|
+
A page fooling the reader into a false answer doesn't count, as the README says this can't be prevented.
|