From 05c77e8e8c24cf9b06b53424c0d792614b0d425c Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Tue, 20 Oct 2026 11:47:13 -0400 Subject: [PATCH 01/15] feat(pi): add the Pi agent type on Pi durable Ports the Pi plugin from #1103 onto current main as one commit. The plugin files are unchanged apart from following main's renames: - Me chats are Solo chats now: read `soloChat` from the agent's reads, and fake a Solo channel in the runtime test. - New agent's Instructions field is labelled optional. - Keep `buzz.pi` off by default beside `buzz.usage` in the bundled-default checks. Co-authored-by: Opus Co-Authored-By: Claude Opus 5.5 Signed-off-by: Salman Mohammed --- crates/plugin-manager/src/lib.rs | 2 + crates/plugin-manager/tests/management.rs | 2 +- scripts/design-system/check-icons.mjs | 4 + src/app/pages.integration.test.mjs | 2 +- src/bundled/claude-code/transcript.ts | 2 +- src/bundled/index.ts | 7 + src/bundled/pi/README.md | 128 ++ src/bundled/pi/deps/package-lock.json | 1527 +++++++++++++++++++++ src/bundled/pi/deps/package.json | 11 + src/bundled/pi/host.live.test.ts | 219 +++ src/bundled/pi/host.mjs | 754 ++++++++++ src/bundled/pi/index.ts | 93 ++ src/bundled/pi/manifest.json | 21 + src/bundled/pi/pi-sh.test.ts | 124 ++ src/bundled/pi/pi-testing.ts | 140 ++ src/bundled/pi/pi.sh | 64 + src/bundled/pi/pi.test.ts | 137 ++ src/bundled/pi/pi.ts | 222 +++ src/bundled/pi/runtime.test.ts | 724 ++++++++++ src/bundled/pi/runtime.ts | 796 +++++++++++ src/bundled/pi/setup.ts | 102 ++ src/bundled/pi/tabs.test.tsx | 456 ++++++ src/bundled/pi/tabs.tsx | 945 +++++++++++++ src/bundled/pi/transcript.test.ts | 204 +++ src/bundled/pi/transcript.ts | 243 ++++ 25 files changed, 6926 insertions(+), 3 deletions(-) create mode 100644 src/bundled/pi/README.md create mode 100644 src/bundled/pi/deps/package-lock.json create mode 100644 src/bundled/pi/deps/package.json create mode 100644 src/bundled/pi/host.live.test.ts create mode 100644 src/bundled/pi/host.mjs create mode 100644 src/bundled/pi/index.ts create mode 100644 src/bundled/pi/manifest.json create mode 100644 src/bundled/pi/pi-sh.test.ts create mode 100644 src/bundled/pi/pi-testing.ts create mode 100644 src/bundled/pi/pi.sh create mode 100644 src/bundled/pi/pi.test.ts create mode 100644 src/bundled/pi/pi.ts create mode 100644 src/bundled/pi/runtime.test.ts create mode 100644 src/bundled/pi/runtime.ts create mode 100644 src/bundled/pi/setup.ts create mode 100644 src/bundled/pi/tabs.test.tsx create mode 100644 src/bundled/pi/tabs.tsx create mode 100644 src/bundled/pi/transcript.test.ts create mode 100644 src/bundled/pi/transcript.ts diff --git a/crates/plugin-manager/src/lib.rs b/crates/plugin-manager/src/lib.rs index cc446c3056..cff7a104c3 100644 --- a/crates/plugin-manager/src/lib.rs +++ b/crates/plugin-manager/src/lib.rs @@ -282,6 +282,8 @@ pub fn bundled_manifests() -> Vec { .expect("codex manifest"), serde_json::from_str(include_str!("../../../src/bundled/goose/manifest.json")) .expect("goose manifest"), + serde_json::from_str(include_str!("../../../src/bundled/pi/manifest.json")) + .expect("pi manifest"), serde_json::from_str(include_str!("../../../src/bundled/workflows/manifest.json")) .expect("workflows manifest"), serde_json::from_str(include_str!("../../../src/bundled/feedback/manifest.json")) diff --git a/crates/plugin-manager/tests/management.rs b/crates/plugin-manager/tests/management.rs index 37fd556609..8be9e2ac73 100644 --- a/crates/plugin-manager/tests/management.rs +++ b/crates/plugin-manager/tests/management.rs @@ -1117,7 +1117,7 @@ fn bundled_defaults_preserve_saved_choices_and_only_channels_is_required() { .any(|plugin| plugin.manifest.id == "buzz.inbox")); for plugin in &catalog.plugins { let id = plugin.manifest.id.as_str(); - let default = !matches!(id, "buzz.bestie" | "buzz.todos" | "buzz.usage"); + let default = !matches!(id, "buzz.bestie" | "buzz.pi" | "buzz.todos" | "buzz.usage"); assert_eq!(plugin.enabled, default, "{id}"); if id == "buzz.channels" { assert!(manager.change("disable", id).is_err()); diff --git a/scripts/design-system/check-icons.mjs b/scripts/design-system/check-icons.mjs index 602854dc11..6a40b32792 100644 --- a/scripts/design-system/check-icons.mjs +++ b/scripts/design-system/check-icons.mjs @@ -14,6 +14,9 @@ const nonIconDependencies = new Set([ "@buzz/author", "@deepseek-ai/cordis", "@dnd-kit/core", // Pointer drag and drop for sidebar channel moves. + "@earendil-works/chord", // The Pi host's packages, which pi.sh installs. + "@earendil-works/pi-ai", + "@earendil-works/pi-durable", "@emoji-mart/data", "@fontsource-variable/inter", "@fontsource/jetbrains-mono", @@ -56,6 +59,7 @@ const nonIconDependencies = new Set([ "nostr-tools", "pele", "postcss", + "proper-lockfile", // The Pi host's lock on each agent's storage. "prosemirror-commands", "prosemirror-history", "prosemirror-model", diff --git a/src/app/pages.integration.test.mjs b/src/app/pages.integration.test.mjs index c285728611..6a786dce04 100644 --- a/src/app/pages.integration.test.mjs +++ b/src/app/pages.integration.test.mjs @@ -70,7 +70,7 @@ test("the app runtime exposes ready bundled pages and removes them on disable", ); assert.equal( plugin.enabledByDefault, - !["buzz.bestie", "buzz.todos", "buzz.usage"].includes( + !["buzz.bestie", "buzz.pi", "buzz.todos", "buzz.usage"].includes( plugin.manifest.id, ), plugin.manifest.id, diff --git a/src/bundled/claude-code/transcript.ts b/src/bundled/claude-code/transcript.ts index 9f7eb5f2b2..43c63dbe5d 100644 --- a/src/bundled/claude-code/transcript.ts +++ b/src/bundled/claude-code/transcript.ts @@ -64,7 +64,7 @@ const KINDS: Record = { }; /** A Buzz prompt, or a steer wrapping one, as the turn's opening item. */ -function prompt(id: string, at: number, raw: string): TranscriptItem { +export function prompt(id: string, at: number, raw: string): TranscriptItem { let sections = promptSections(raw); const steer = sections.find((section) => section.tag === STEER); if (steer) sections = promptSections(steer.body); diff --git a/src/bundled/index.ts b/src/bundled/index.ts index e9a992c92c..e433ed5bd2 100644 --- a/src/bundled/index.ts +++ b/src/bundled/index.ts @@ -34,6 +34,8 @@ import codexManifest from "./codex/manifest.json"; import * as codex from "./codex"; import gooseManifest from "./goose/manifest.json"; import * as goose from "./goose"; +import piManifest from "./pi/manifest.json"; +import * as pi from "./pi"; import channelsManifest from "./channels/manifest.json"; import githubManifest from "./github/manifest.json"; import * as channels from "./channels"; @@ -191,6 +193,11 @@ export const bundledPlugins: readonly BundledPlugin[] = [ module: goose, enabledByDefault: true, }, + { + manifest: { ...piManifest, apiVersion: 1 }, + module: pi, + enabledByDefault: false, + }, { manifest: { ...workflowsManifest, apiVersion: 1 }, module: workflows, diff --git a/src/bundled/pi/README.md b/src/bundled/pi/README.md new file mode 100644 index 0000000000..8a4d7ac372 --- /dev/null +++ b/src/bundled/pi/README.md @@ -0,0 +1,128 @@ +# Pi for Agents + +A desktop plugin that ships with Buzz on macOS and Linux, off until turned on +in **Settings → Plugins**. Its agents run on +[Pi durable](https://earendil.com/posts/pi-1-0/) (`@earendil-works/pi-durable`), +not the Pi coding agent, with any provider Pi supports. +Agents owns the identity, admission, configuration, and signed Buzz actions. This +plugin owns Pi work after delivery handover. One Pi host process ([`host.mjs`](host.mjs)) +runs every Pi agent here. Each agent has its own SQLite storage, and each Buzz +conversation the agent is in is one durable Pi conversation. + +## Try it + +From the feature worktree: + +```sh +source bin/activate-hermit +BUZZODZ_PROFILE=pi-agents BUZZ_DEV_VIEWER= bin/just desktop +``` + +Pi needs Node.js 22.19 or later on this computer (`node:sqlite`). The app's PATH +is searched first, then a login shell's, so Homebrew, nvm, fnm and similar +installs work. + +1. Turn on **Pi** in **Settings → Plugins**. Then open **Agents → New agent** + and choose **Pi** as the **Harness**. Its **Model** starts on **Pi default**: + the `defaultProvider` and `defaultModel` in `~/.pi/agent/settings.json`, as + the `pi` command sets them. The first time Buzz asks Pi anything, it + installs Pi's packages (a few seconds). +2. Click the model (marked with Pi, and with the provider when Buzz has its + mark) and choose a provider. A provider Pi has no key for shows an + **API key** field; **Save to Pi** stores it in + `~/.pi/agent/auth.json`, shared with the `pi` command. A provider that signs + in some other way (OAuth, cloud credentials) says to run `pi`, then `/login`. + Choose a model, click **Test connection**, then **Use**. Name the agent and + click **Create agent**. Its library card shows the same control. +3. Open the agent. Its **Identity** tab has the **Provider**, **Model**, + **Thinking** level (Pi's default, or one the model has) and + **Instructions**. Its **Settings** tab has the provider's key with **Test + connection**, the **Workspace** (default `~/.buzz`), and thread or channel + **Conversations**. +4. In a channel, send: `@Pi create hello.txt containing hello in the workspace, + read it back, and report what you found` (use the agent's actual name). + Expect steps under your message, one reply in the thread, and the file in the + workspace. Then reply in the same thread: `@Pi what did you write?`. It + remembers, because the thread is the same Pi conversation. +5. While it works, send another mention in the thread. It steers the running + turn: Pi reads it at its next step. The activity moves to the newer message. +6. Quit Buzz during a long turn, start it again, and mention the agent in that + thread. Pi continues the interrupted run from its storage, and the new + mention steers it. +7. Choose **View activity** on one of its messages in that thread, or open the + agent's **Activity** tab from there. **This thread** is its Pi conversation, + with the agent's other recent ones below. **View transcript** shows each + turn's prompt, thinking, tool calls with their output, and what it sent, + read from Pi's storage. + +The composer's model picker offers the agent's provider's models, and each +model's thinking levels, for one turn. Without a pick, a turn runs the +agent's saved model and Thinking level. + +## Checks + +```sh +bin/pnpm exec vitest run src/bundled/pi +PI_DEPS="$HOME/Library/Application Support/Buzz/pi/deps/" bin/pnpm exec vitest run src/bundled/pi/host.live.test.ts +``` + +The unit tests cover the stdio protocol (`pi.ts`), the runtime against a fake +host, the install script in a throwaway home, and the New agent and Settings +fields. The opt-in live test runs the real `host.mjs` on Pi durable, using Pi's +faux provider as the model. It checks that a final `send` ends the run, that a +plain answer comes back as text, that a request steers a working run, and that +a run cut off by a stop continues in the next host. `PI_DEPS` is any folder +`pi.sh` installed Pi's packages in. + +## Buzz's copy of Pi's packages + +[`pi.sh`](pi.sh) runs the host on this computer's Node. On first use it runs +`npm ci` with [`deps/package-lock.json`](deps/package-lock.json), which pins +every package and its integrity hash. Install scripts are off, and npm uses the +registry it is configured for. Each lock gets its own folder under +`~/Library/Application Support/Buzz/pi/deps/` +(`${XDG_DATA_HOME:-~/.local/share}/buzz/pi/deps/` on Linux). The folder appears +whole or not at all, and is shared by every Buzz build. An install removes the +folders no build has started for 30 days. Agents' storage is in `agents/.sqlite` +beside it. A lock on each agent's storage keeps a second Buzz build from opening +it; that build's turns fail and say so. It also keeps Pi's `auth.json` writes +from colliding with the `pi` command's. + +To update Pi, change the versions in [`deps/package.json`](deps/package.json), +then regenerate the lock without registry URLs: + +```sh +cd src/bundled/pi/deps +npm install --package-lock-only --ignore-scripts --omit-lockfile-registry-resolved +``` + +## Limits and boundaries + +- Only the owner's events, or ones the owner delegated, start work. Watches, + timers and catch-ups run as standalone turns, each a one-off Pi + conversation, in the lanes and with the 5-minute deadline Buzz's agent + runner gives every harness. +- Pi has the coding tools `read`, `write`, `edit` and `bash`, in the workspace, + with no sandbox or approvals, like Claude's `bypassPermissions`. Buzz's tools + are offered as `buzz_` (`buzz_send`, `buzz_read`, …), since `read` and + `edit` would collide. They run inside Buzz through the agent's native-held + handle, so the host holds no Buzz key. A final `buzz_send` ends the run. +- A turn that answered in text and never called `buzz_send` has its answer + posted in the thread, as goose does. A turn that fails posts `⚠️ I couldn't + finish that: …` and tags the owner. +- A Buzz tool call that a stop cuts off runs again when the run continues, so a + `send` may be posted twice. +- Pi's storage keeps every conversation, including each one-off watch or timer + turn; nothing prunes it. The Activity tab lists thread and channel + conversations only, and rereads an open transcript when its turn ends, not + as it streams. +- A host that stops while a turn waits is started again once and waits for it + there. A run resumed that way, or at the next delivery after a restart, shows + no steps. +- A provider that needs more than a key, such as Bedrock or Vertex, is set up + with the `pi` command. Signing in from Buzz is not supported. +- Only Pi's built-in providers run. One that a Pi package adds (`packages` in + Pi's settings), such as databricks, is not loaded: the provider field, the + New agent Model field and a turn's error say so, and Use and Save wait for another. +- Windows is unsupported. Linux, packaged-app behavior, and human acceptance need + separate validation. diff --git a/src/bundled/pi/deps/package-lock.json b/src/bundled/pi/deps/package-lock.json new file mode 100644 index 0000000000..19ca28689f --- /dev/null +++ b/src/bundled/pi/deps/package-lock.json @@ -0,0 +1,1527 @@ +{ + "name": "buzz-pi-host", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "buzz-pi-host", + "dependencies": { + "@earendil-works/chord": "1.1.0", + "@earendil-works/pi-ai": "1.1.0", + "@earendil-works/pi-durable": "1.1.0", + "proper-lockfile": "4.1.2" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.129.0", + "integrity": "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1127.0", + "integrity": "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/eventstream-handler-node": "^3.972.34", + "@aws-sdk/middleware-eventstream": "^3.972.29", + "@aws-sdk/middleware-websocket": "^3.972.52", + "@aws-sdk/token-providers": "3.1127.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.2", + "integrity": "sha512-ViOBLuGSSyPAmkKGJ6KJXtVN7MOEp3rnI7uH+kWxnHccvo8C9cvunEAnN5/oaZH1laQfsTBw7IOnmNkWlFZl2A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.35.2", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.73", + "integrity": "sha512-PxlFZiF5IPUsPnuVSqjM9jlmRwZhL0xx40ccWfZiLQS8TRnHQpegpK1uAidatWuEqbJtbbY97A2Sezikz2AMUw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.75", + "integrity": "sha512-Q/uIDy8eAVN7JgSKDg16F/f+hmNJj/ZyLJN8AkW2ofVdDgJVh8fO/A26iZ7YsvM2/k76hsf/u2lLAtyXsK8Ziw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.18", + "integrity": "sha512-KoyjZ9vdjuxEQ8obAH06xKP8H0WKqGhwUZLMqAOiS7G0xgUAht4X8f0ASLC/vhNLCWGblZOD3VQHWyTROGJHLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/credential-provider-env": "^3.972.73", + "@aws-sdk/credential-provider-http": "^3.972.75", + "@aws-sdk/credential-provider-login": "^3.972.80", + "@aws-sdk/credential-provider-process": "^3.972.73", + "@aws-sdk/credential-provider-sso": "^3.973.17", + "@aws-sdk/credential-provider-web-identity": "^3.972.79", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.80", + "integrity": "sha512-xF9A0gaO7k+1dYPFoo69OQatA9Ov0QuDZfqE8H0FLyIoOph+mlEC+I1Qo9UULupGuDfSYh+kVpfcDSxSYf/2fg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.85", + "integrity": "sha512-CrFcML/xImiSlquH4W3V6Npz3aSkPXHDVciAU1JOBN0vqjmkcz9H9up2sCHQqN76J/WrT+TJ580L8h2joksQrg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.73", + "@aws-sdk/credential-provider-http": "^3.972.75", + "@aws-sdk/credential-provider-ini": "^3.973.18", + "@aws-sdk/credential-provider-process": "^3.972.73", + "@aws-sdk/credential-provider-sso": "^3.973.17", + "@aws-sdk/credential-provider-web-identity": "^3.972.79", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.73", + "integrity": "sha512-tyNnT4aVuOEXmKKKZPExBi828vRd9heshobBsKF0bov5EORHSJc1AGsHOMRP57sDt+dIuVASUOywaixiX26t4g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.17", + "integrity": "sha512-gxKTWaUk2gSbBOH4m3NPRfEKcGXeDAOgHr7nNG5JktR1xZLo0R+DpMjbcleoHbB0uJREOG7hjIRE635IuBWsRQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/token-providers": "3.1149.0", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1149.0", + "integrity": "sha512-CAwYyA8eVk1w6h8YgLvkjIOakWPwURaUFrP7BRVgkxTFUM+O87thyKsGKJ6rVPbP8eoEV10cBWQ5YfaYl3qSVQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.79", + "integrity": "sha512-/jn0Gs/qm6fs6nTIA5yncHayybz42SATZ3rNxCEBYPJ9v69PLrDRgk07E9xTT2gpRcNmDXXbi27ZbuU06N0Z6Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.36", + "integrity": "sha512-HFaKXA6ugPVvpR7W/3UT3hylWIPndqGMwduwNVrmGsRT5+OESHN8/ven4eeofraQF5Kxp8A8QgGVrv7iQsTRKQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.31", + "integrity": "sha512-4yvdTVwPGKCnrwuwlYEmwLqQXKwk0unx3f/FvBi4FA9ruK/WGoPJyqfK7hOYurheIyQL44FR0ovgOHqvkfdpAQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.55", + "integrity": "sha512-dHlEl3sCn1Cu+qddPoz5RxjgGCXEzs/pbrh/rDRaOeb/Tz//FF59hacDvy7kvUn19mkm2nQRjhpzBnX+Ye37Qw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.47", + "integrity": "sha512-bovCKvDsLocNTzaj6VrrMVJ2BGROqO0JlwcGseO67a+I9Bo+08nUJhSQfZUIx4+A4a7I4101GEJd/AYm7dQ68w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/signature-v4-multi-region": "^3.996.48", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.48", + "integrity": "sha512-cFDXfEbXaGwbnpvR/DyqfxfjxpjXVRVthQLgkwvcfDS5u3+8G9WSj1X1Ag/pTRg/QOeOno1Tq3ycEdrKsCUcOA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1127.0", + "integrity": "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.6", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/chord": { + "version": "1.1.0", + "integrity": "sha512-gsHzKfyQ3t0ZIQ3cLBjJ5Vqy1TYnFyruHIV3g3BwmulCDYe5BW4yT9vaJk9QzN6YANILIO75uzuq+Gdxtzz1og==", + "license": "MIT", + "dependencies": { + "esbuild": "0.28.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "1.1.0", + "integrity": "sha512-1T7LAkc/5Bvc0v6w4vAGVdCrli0o/E0pEmYKTnixu95vSFArBjvbhS/G4ZwI0RUePgf0Imcu0VyqlM4EcXxqfw==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.129.0", + "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@earendil-works/pi-telemetry": "^1.1.0", + "@google/genai": "2.21.0", + "@smithy/node-http-handler": "4.12.1", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "openai": "7.19.0", + "partial-json": "0.1.7", + "typebox": "1.3.27" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-durable": { + "version": "1.1.0", + "integrity": "sha512-NtuB3RY24vQ+0/rWYpeHupyFWBfXuKq17to3VMYUYqfD/uuGnz6F6UTPBEm51VSxxA6LZwRcDwLX2LtRehnIgQ==", + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^1.1.0", + "@earendil-works/pi-ai": "^1.1.0", + "diff": "8.0.4", + "typebox": "1.3.27" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "1.1.0", + "integrity": "sha512-8gAK05/2pPozZZz6hCLOi8x2vsqzvsmO9gG92kbvuvTPm0qMHaFlaXX98ndXsrOi5pmzha7vNLguuG4+0Rgxjw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "2.21.0", + "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@smithy/core": { + "version": "3.35.2", + "integrity": "sha512-ejTukBuP/Pg3t9N8Ux+0zKsXeHWm7p6W3HbXFOyltKasSxZwCFmvTut7kBdZSCF3m9y1bXXyVu6p2xuMFcsTyQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "9.0.0", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "8.0.4", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/http-proxy-agent": { + "version": "9.1.0", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "7.19.0", + "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/retry": { + "version": "0.13.1", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typebox": { + "version": "1.3.27", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.9.0", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/src/bundled/pi/deps/package.json b/src/bundled/pi/deps/package.json new file mode 100644 index 0000000000..218c05f085 --- /dev/null +++ b/src/bundled/pi/deps/package.json @@ -0,0 +1,11 @@ +{ + "name": "buzz-pi-host", + "private": true, + "type": "module", + "dependencies": { + "@earendil-works/chord": "1.1.0", + "@earendil-works/pi-ai": "1.1.0", + "@earendil-works/pi-durable": "1.1.0", + "proper-lockfile": "4.1.2" + } +} diff --git a/src/bundled/pi/host.live.test.ts b/src/bundled/pi/host.live.test.ts new file mode 100644 index 0000000000..1c2efe8b71 --- /dev/null +++ b/src/bundled/pi/host.live.test.ts @@ -0,0 +1,219 @@ +// @vitest-environment node +// The real host.mjs on Pi durable, with Pi's faux provider for a model: run +// with PI_DEPS set to a folder pi.sh installed Pi's packages in, such as +// "~/Library/Application Support/Buzz/pi/deps/". +import { execFile } from "node:child_process"; +import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterAll, describe, expect, it } from "vitest"; +import host from "./host.mjs?raw"; + +const DEPS = process.env.PI_DEPS; +const run = promisify(execFile); + +/** Serves the plugin's side over in-process streams, as Buzz would. */ +const DRIVER = ` +import { writeFile } from "node:fs/promises"; +import { PassThrough } from "node:stream"; +import { createInterface } from "node:readline"; +import { createModels } from "@earendil-works/pi-ai/models"; +import { fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai/providers/faux"; +import { AuthFile, serve } from "./host.mjs"; +const data = process.argv[2]; +const faux = fauxProvider(); +const models = createModels(); +models.setProvider(faux.provider); +const agent = "b".repeat(64); +const tools = [{ name: "send", description: "Post", inputSchema: { type: "object", properties: { text: { type: "string" }, final: { type: "boolean" } } } }]; +const out = {}; +function start() { + const input = new PassThrough(), output = new PassThrough(); + const events = [], tools = [], pending = new Map(); + let id = 0; + createInterface({ input: output }).on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "event") events.push(message.params); + else if (message.method === "tool") { + tools.push(message.params); + const final = message.params.arguments.final === true; + input.write(JSON.stringify({ id: message.id, result: { content: [{ type: "text", text: "sent" }], ...(final ? { final } : {}) } }) + "\\n"); + } else if (message.id !== undefined) { + const call = pending.get(message.id); + pending.delete(message.id); + if (message.error) call.reject(new Error(message.error)); + else call.resolve(message.result); + } + }); + const rpc = (method, params) => new Promise((resolve, reject) => { + pending.set(++id, { resolve, reject }); + input.write(JSON.stringify({ id, method, params }) + "\\n"); + }); + const { stop } = serve({ data, models, dir: data, input, output, exit: () => {} }); + return { rpc, events, tools, stop }; +} +const turn = (request, text) => ({ agent, key: "chan/root", at: 100, context: { channel: "chan", root: "root" }, text, steer: "steer: " + text, request, provider: "faux", model: "faux-1", instructions: "Test.", cwd: data }); +const held = () => { let release; const gate = new Promise((resolve) => (release = resolve)); return { gate, release }; }; +const abortable = (gate, signal) => Promise.race([gate, new Promise((_, reject) => signal?.addEventListener("abort", () => reject(new Error("aborted")), { once: true }))]); +const started = (events, request) => new Promise((resolve) => { const check = () => events.some((event) => event.type === "placed" && event.request === request) ? resolve() : setTimeout(check, 5); check(); }); + +let pi = start(); +await pi.rpc("open", { agent, tools }); +// A provider only a Pi package adds is not one Buzz's Pi has. +out.unknown = await pi.rpc("test", { provider: "databricks", model: "m" }).catch((error) => error.message); +// A final send ends the run without another model call. +faux.setResponses([fauxAssistantMessage(fauxToolCall("buzz_send", { text: "hi", final: true }), { stopReason: "toolUse" })]); +const a = await pi.rpc("deliver", turn("A", "first")); +out.a = { ...(await pi.rpc("wait", { agent, submission: a.submission })), calls: faux.state.callCount, tools: pi.tools.splice(0), conversation: a.conversation }; +// A plain answer, in the same conversation. +faux.setResponses([fauxAssistantMessage("Plain answer")]); +const b = await pi.rpc("deliver", turn("B", "second")); +out.b = { ...(await pi.rpc("wait", { agent, submission: b.submission })), conversation: b.conversation }; +// What a transcript reads: the places, and the entries without schemas or usage. +out.places = (await pi.rpc("places", { agent })).places; +out.entries = (await pi.rpc("entries", { agent, conversation: a.conversation })).entries.map((entry) => [entry.kind, ...(entry.model ?? []).map((message) => message.role + (message.usage ? "+usage" : ""))]); +// A request while the run works steers it. +const c = held(); +let saw = []; +faux.setResponses([ + async () => { await c.gate; return fauxAssistantMessage(fauxToolCall("buzz_send", { text: "working" }), { stopReason: "toolUse" }); }, + (context) => { saw = context.messages.filter((m) => m.role === "user").map((m) => m.content); return fauxAssistantMessage("Answer to both"); }, +]); +const c1 = await pi.rpc("deliver", turn("C1", "third")); +await started(pi.events, "C1"); +const c2 = await pi.rpc("deliver", turn("C2", "fourth")); +c.release(); +out.c = { waits: await Promise.all([c1, c2].map((x) => pi.rpc("wait", { agent, submission: x.submission }))), saw, placed: pi.events.filter((e) => e.type === "placed").map((e) => [e.request, e.entry]), steps: pi.events.filter((e) => e.type === "step").map((e) => e.tool) }; +// A run the host stops in continues in the next host. +const d = held(), calling = held(); +faux.setResponses([ + async (_context, options) => { calling.release(); await abortable(d.gate, options?.signal); return fauxAssistantMessage("first try"); }, + () => fauxAssistantMessage("Resumed answer"), +]); +const d1 = await pi.rpc("deliver", turn("D", "fifth")); +await calling.gate; +const stopped = pi.rpc("wait", { agent, submission: d1.submission }); +await pi.stop(); +out.stopped = await stopped; +pi = start(); +await pi.rpc("open", { agent, tools }); +out.resumed = await pi.rpc("wait", { agent, submission: d1.submission }); +// A run that fails leaves no request it was steered with waiting for the next. +const e = held(); +faux.setResponses([async () => { await e.gate; return fauxAssistantMessage("", { stopReason: "error", errorMessage: "400 Bad model" }); }]); +const e1 = await pi.rpc("deliver", turn("E1", "sixth")); +await started(pi.events, "E1"); +const e2 = await pi.rpc("deliver", turn("E2", "seventh")); +e.release(); +out.e = await Promise.all([e1, e2].map((x) => pi.rpc("wait", { agent, submission: x.submission }))); +out.place = await pi.rpc("place", { agent, key: "chan/root" }); +// A turn Pi could not start leaves what the conversation was shown. +out.unstarted = await pi.rpc("deliver", { ...turn("F", "eighth"), at: 200, model: "nope" }).catch((error) => error.message); +out.unstartedPlace = await pi.rpc("place", { agent, key: "chan/root" }); +// Pi's settings as they are now, which the pi command changes. +await writeFile(data + "/settings.json", JSON.stringify({ defaultProvider: "faux", defaultModel: "faux-1" })); +out.defaults = await pi.rpc("defaults", {}); +await pi.stop(); +// Pi's auth.json: a change that returns nothing, as pi-ai's refresh does when +// another process refreshed first, keeps the sign-in. +const auth = new AuthFile(data + "/auth.json"); +const signIn = { type: "oauth", access: "a", refresh: "r", expires: 1 }; +await auth.modify("p", async () => signIn); +out.auth = { result: await auth.modify("p", async () => undefined), stored: await auth.read("p") }; +console.log(JSON.stringify(out)); +`; + +describe.skipIf(!DEPS)("the Pi host on Pi durable", () => { + const folders: string[] = []; + afterAll(async () => { + await Promise.all( + folders.map((folder) => rm(folder, { recursive: true, force: true })), + ); + }); + + it("runs, steers, ends and resumes turns", async () => { + const folder = await mkdtemp(join(tmpdir(), "buzz-pi-live-")); + folders.push(folder); + await symlink( + join(DEPS ?? "", "node_modules"), + join(folder, "node_modules"), + ); + await writeFile(join(folder, "host.mjs"), host); + await writeFile(join(folder, "driver.mjs"), DRIVER); + const { stdout } = await run( + process.execPath, + [ + "--disable-warning=ExperimentalWarning", + join(folder, "driver.mjs"), + folder, + ], + { cwd: folder, timeout: 60_000 }, + ); + const out = JSON.parse(stdout.trim().split("\n").at(-1) ?? "{}"); + expect(out.unknown).toMatch( + /^Buzz can use only Pi's built-in providers, and databricks isn't one/, + ); + expect(out.a).toMatchObject({ status: "done", calls: 1 }); + expect(out.a.tools).toEqual([ + { + agent: "b".repeat(64), + conversation: out.a.conversation, + context: { channel: "chan", root: "root" }, + name: "send", + arguments: { text: "hi", final: true }, + }, + ]); + expect(out.places).toEqual([ + { key: "chan/root", conversation: out.a.conversation, seen: 100 }, + ]); + // Newest first; a system entry's tool schemas are left out. + expect(out.entries).toEqual([ + ["pi.assistant", "assistant"], + ["pi.user", "user"], + ["pi.tool-result", "toolResult"], + ["pi.assistant", "assistant"], + ["pi.system"], + ["pi.user", "user"], + ]); + expect(out.b).toMatchObject({ + status: "done", + text: "Plain answer", + conversation: out.a.conversation, + }); + const [first, second] = out.c.waits; + expect(first).toMatchObject({ status: "done", text: "Answer to both" }); + expect(second).toEqual(first); + expect(out.c.saw.slice(-2)).toEqual(["third", "steer: fourth"]); + expect(out.c.placed.map(([request]: [string]) => request)).toEqual([ + "A", + "B", + "C1", + "C2", + ]); + // Each placed at an entry before the run's answer, which the runtime + // compares to find the latest request a run took. + const [, c1] = out.c.placed[2]; + const [, c2] = out.c.placed[3]; + expect(c1).toBeLessThan(c2); + expect(c2).toBeLessThan(first.answer); + expect(out.c.steps).toContain("send"); + expect(out.stopped).toEqual({ status: "closed" }); + expect(out.resumed).toMatchObject({ + status: "done", + text: "Resumed answer", + }); + const [failed, withdrawn] = out.e; + expect(failed).toMatchObject({ + status: "unanswered", + detail: expect.stringContaining("400 Bad model"), + }); + expect(withdrawn).toEqual(failed); + expect(out.place).toEqual({ seen: 100 }); + expect(out.unstarted).toMatch(/Pi has no model nope/); + expect(out.unstartedPlace).toEqual({ seen: 100 }); + expect(out.defaults).toEqual({ provider: "faux", model: "faux-1" }); + const signIn = { type: "oauth", access: "a", refresh: "r", expires: 1 }; + expect(out.auth).toEqual({ result: signIn, stored: signIn }); + }, 90_000); +}); diff --git a/src/bundled/pi/host.mjs b/src/bundled/pi/host.mjs new file mode 100644 index 0000000000..0cc9d34bc4 --- /dev/null +++ b/src/bundled/pi/host.mjs @@ -0,0 +1,754 @@ +// Buzz's Pi host: Pi durable for this computer's Pi agents, driven by the Pi +// plugin over stdio, one JSON message a line. Each agent has its own Harness +// in its own SQLite file, and each Buzz conversation is one Pi conversation +// in it, found again through the `buzz.places` document. The Buzz tools run +// in the app, which holds the agent's key: a call to one here is a request +// back to the plugin. `pi.sh` runs this with Node in the folder its packages +// are installed in. +import { execSync } from "node:child_process"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { createInterface } from "node:readline"; +import { + awaitWithContext, + BACKGROUND_CONTEXT, +} from "@earendil-works/chord/context"; +import { + clampThinkingLevel, + getSupportedThinkingLevels, +} from "@earendil-works/pi-ai"; +import { builtinModels } from "@earendil-works/pi-ai/providers/all"; +import { + AssistantEntry, + createRegistry, + defineDoc, + defineExtension, + defineTool, + Harness, + section, + watchEvents, +} from "@earendil-works/pi-durable"; +import { NodeExecutionEnv } from "@earendil-works/pi-durable/env/node"; +import { openNodeSqliteStorage } from "@earendil-works/pi-durable/storage/sqlite/node"; +import { CodingTools } from "@earendil-works/pi-durable/tools"; +import lockfile from "proper-lockfile"; + +const context = BACKGROUND_CONTEXT; +/** Each Buzz conversation key's Pi conversation, the tools' default + * destination for it, and the newest message it has been shown. */ +const Places = defineDoc({ + kind: "buzz.places", + version: 1, + scope: "session", + initial: () => ({}), +}); +/** Messages that arrive while Pi works join its run at the next boundary. */ +const SETTINGS = { steeringMode: "all" }; +/** Pi's own default thinking level, for a model that thinks. */ +const THINKING = "medium"; +/** A lock left by a host that was killed is taken over after this long. */ +const STALE_MS = 30_000; +const TEST_TIMEOUT_MS = 30_000; +/** Entries a transcript reads at a time. */ +const PAGE = 200; +/** Buzz tools are `buzz_`, apart from Pi's own `read` and `edit`. */ +const PREFIX = "buzz_"; +const TOOL_NAMES = `The Buzz tools are named \`${PREFIX}\`: where your instructions name a Buzz tool such as \`send\` or \`read\`, call \`${PREFIX}send\` or \`${PREFIX}read\`. The unprefixed \`read\`, \`write\`, \`edit\` and \`bash\` work on files in the workspace.`; + +/** Pi's folder, shared with the `pi` command: its keys and settings. */ +const piDir = () => { + const dir = process.env.PI_CODING_AGENT_DIR; + return dir + ? dir.replace(/^~(?=$|\/)/, homedir()) + : join(homedir(), ".pi", "agent"); +}; + +/** Pi's `auth.json`, read and written as the `pi` command does, under the + * same lock, so keys saved in either work in both. */ +export class AuthFile { + commands = new Map(); + constructor(path) { + this.path = path; + } + async load() { + let text; + try { + text = await readFile(this.path, "utf8"); + } catch (error) { + if (error.code === "ENOENT") return {}; + throw error; + } + const parsed = JSON.parse(text.replace(/^\uFEFF/, "")); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) + throw new Error(`${this.path} is not a JSON object`); + return parsed; + } + async read(id) { + const credential = (await this.load())[id]; + return credential?.type === "api_key" && credential.key + ? { ...credential, key: this.resolve(credential.key) } + : credential; + } + async list() { + return Object.entries(await this.load()).map(([providerId, value]) => ({ + providerId, + type: value.type, + })); + } + /** As pi-ai's stores do: a change that returns nothing, such as a refresh + * another process already made, leaves the entry. */ + modify(id, change) { + return this.locked(async (all) => { + const next = await change(all[id]); + if (next !== undefined) all[id] = next; + return all[id]; + }); + } + async delete(id) { + await this.locked((all) => { + delete all[id]; + }); + } + /** As in `pi`: `!command` runs it for the key, and `$NAME` or `${NAME}` + * reads it from the environment. A command that gave no key, such as one + * for a locked password manager, runs again next time. */ + resolve(key) { + if (key.startsWith("!")) { + if (this.commands.has(key)) return this.commands.get(key); + let value; + try { + value = execSync(key.slice(1), { + encoding: "utf8", + timeout: 10_000, + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch {} + if (value) this.commands.set(key, value); + return value || undefined; + } + const name = /^\$(?:\{(\w+)\}|(\w+))$/.exec(key); + return name ? process.env[name[1] ?? name[2]] : key; + } + async locked(change) { + await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }); + await writeFile(this.path, "{}", { flag: "wx", mode: 0o600 }).catch( + (error) => { + if (error.code !== "EEXIST") throw error; + }, + ); + const release = await lockfile.lock(this.path, { + realpath: false, + stale: STALE_MS, + retries: { retries: 30, minTimeout: 20, maxTimeout: 2_000 }, + }); + try { + const all = await this.load(); + const result = await change(all); + await writeFile(this.path, `${JSON.stringify(all, null, 2)}\n`, { + mode: 0o600, + }); + return result; + } finally { + await release(); + } + } +} + +/** Why a provider that a Pi package adds, which Buzz doesn't load, can't run. */ +const notBuiltIn = (id) => + `Buzz can use only Pi's built-in providers, and ${id} isn't one. It may come from a Pi package. Choose another provider in the agent's Settings.`; + +/** The default provider, model and thinking level in Pi's settings. */ +async function piDefaults(dir) { + try { + const settings = JSON.parse( + await readFile(join(dir, "settings.json"), "utf8"), + ); + const text = (value) => + typeof value === "string" && value ? value : undefined; + return { + provider: text(settings.defaultProvider), + model: text(settings.defaultModel), + thinking: text(settings.defaultThinkingLevel), + }; + } catch { + return {}; + } +} + +/** A message as a transcript shows it: no usage, image data or signatures. */ +const slim = ({ usage, ...message }) => + Array.isArray(message.content) + ? { + ...message, + content: message.content.map((part) => + part.type === "image" + ? { type: "image" } + : part.type === "thinking" + ? { type: "thinking", thinking: part.thinking } + : part, + ), + } + : message; + +const textOf = (message) => + (message?.content ?? []) + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("") + .trim(); + +/** What a step shows of a tool call, as Claude's steps do. */ +const TARGETS = ["path", "file_path", "pattern", "url", "query", "command"]; +const targetOf = (args) => { + const value = TARGETS.map((key) => args?.[key]).find( + (item) => typeof item === "string" && item, + ); + return value?.startsWith("/") ? value.split("/").at(-1) : value; +}; + +/** Serves the plugin on `input` and `output` until `input` closes. */ +export function serve({ + data, + models, + dir = piDir(), + input = process.stdin, + output = process.stdout, + exit = () => process.exit(0), +}) { + /** Each agent's open Harness, by pubkey. */ + const agents = new Map(); + const calls = new Map(); + let called = 0; + const write = (message) => output.write(`${JSON.stringify(message)}\n`); + const notify = (method, params) => write({ method, params }); + const call = (method, params) => + new Promise((resolve, reject) => { + const id = `h${++called}`; + calls.set(id, { resolve, reject }); + write({ id, method, params }); + }); + + /** The model a turn runs on: the owner's pick, the agent's, or else Pi's + * default. */ + async function choose({ provider, model, thinking, pick = {} }) { + const defaults = await piDefaults(dir); + const id = provider || defaults.provider; + const modelId = + pick.model || + model || + (id === defaults.provider ? defaults.model : undefined); + if (id && !models.getProvider(id)) throw new Error(notBuiltIn(id)); + if (!id || !modelId) + throw new Error( + "This agent has no model. Choose a provider and model in its Settings.", + ); + const found = models.getModel(id, modelId); + if (!found) throw new Error(`Pi has no model ${modelId} from ${id}`); + return { + found, + model: { provider: id, modelId }, + thinkingLevel: clampThinkingLevel( + found, + pick.thinking || thinking || defaults.thinking || THINKING, + ), + }; + } + + async function open(agent) { + if (!/^[0-9a-f]{64}$/.test(agent)) throw new Error("Not an agent key"); + const path = join(data, "agents", `${agent}.sqlite`); + await mkdir(dirname(path), { recursive: true }); + // One host at a time per agent, across every Buzz build here. A lock that + // a killed host left is taken over once it is stale. + const release = await lockfile + .lock(path, { + realpath: false, + stale: STALE_MS, + retries: { retries: 12, minTimeout: 1_000, maxTimeout: 5_000 }, + onCompromised: (error) => + console.error("Pi lost its lock on an agent's storage", error), + }) + .catch((error) => { + throw error.code === "ELOCKED" + ? new Error("This agent is open in another Buzz app on this computer") + : error; + }); + try { + const registry = createRegistry(); + registry.install(CodingTools); + const harness = await Harness.open( + await openNodeSqliteStorage(path), + { + models, + registry, + settings: SETTINGS, + env: ({ cwd }) => new NodeExecutionEnv({ cwd: cwd ?? homedir() }), + onReport: (error) => console.error("Pi reported", error), + }, + context, + ); + const opened = { + agent, + harness, + registry, + release, + tools: "", + /** Each conversation's tool context, for runs resumed after a restart. */ + contexts: new Map(), + /** Each watched conversation, and whether a run is in it. */ + watched: new Map(), + /** Deliveries to each conversation key, one after another. */ + queues: new Map(), + /** Why each request withdrawn with a failed run is unanswered. */ + failed: new Map(), + closing: false, + }; + const places = (await harness.snapshot(Places, context)) ?? {}; + for (const place of Object.values(places)) + opened.contexts.set(place.conversation, place.context); + return opened; + } catch (error) { + await release().catch(() => undefined); + throw error; + } + } + + /** The agent's storage, opened once. */ + function opening(agent) { + let started = agents.get(agent); + if (!started) { + started = open(agent); + agents.set(agent, started); + started.catch(() => { + if (agents.get(agent) === started) agents.delete(agent); + }); + } + return started; + } + + async function agentOf(agent) { + const opened = await agents.get(agent); + if (!opened || opened.closing) throw new Error("The agent is not open"); + return opened; + } + + /** The agent's Buzz tools, served by the plugin. */ + function buzz(opened, tools) { + return defineExtension({ + name: "buzz", + sections: [section("buzz-tools", () => TOOL_NAMES)], + tools: tools.map((tool) => + defineTool({ + name: `${PREFIX}${tool.name}`, + description: tool.description ?? "", + parameters: tool.inputSchema ?? { type: "object" }, + async execute(args, api, signalled) { + // Given up when the run is stopped, so closing the agent does + // not wait on Buzz. + const result = await awaitWithContext( + call("tool", { + agent: opened.agent, + conversation: api.conversationId, + context: opened.contexts.get(api.conversationId) ?? {}, + name: tool.name, + arguments: args, + }), + signalled, + ); + return { + content: (result.content ?? []).map((part) => + part.type === "image" || part.type === "text" + ? part + : { type: "text", text: JSON.stringify(part) }, + ), + ...(result.isError ? { isError: true } : {}), + // A final `send` answered the run; Pi ends it without another + // model call, and starts a new one for what arrived meanwhile. + ...(result.final ? { control: { terminate: true } } : {}), + }; + }, + }), + ), + }); + } + + /** Watches a conversation for its steps, whether it is working, and when + * each request joins its run. */ + async function watch(opened, conversation) { + const watched = opened.watched.get(conversation); + if (watched) return watched; + const stream = await watchEvents(opened.harness, conversation, context); + const state = { busy: Boolean(stream.snapshot.run), stream }; + opened.watched.set(conversation, state); + const send = (event) => + notify("event", { agent: opened.agent, conversation, ...event }); + stream.start(async (events) => { + for (const event of events) + if (event.type === "run_start") state.busy = true; + else if (event.type === "run_end") state.busy = false; + else if ( + event.type === "submission" && + event.record.status === "placed" && + event.record.requestId + ) + send({ + type: "placed", + request: event.record.requestId, + entry: event.record.entry, + }); + else if (event.type === "tool_execution_start") + send({ + type: "step", + tool: event.toolName.startsWith(PREFIX) + ? event.toolName.slice(PREFIX.length) + : event.toolName, + target: targetOf(event.args), + }); + else if (event.type === "message_update") + for (const change of event.changes) + if (change.type === "thinking_start") + send({ type: "step", tool: "Thinking" }); + else if (change.type === "text_start") + send({ type: "step", tool: "Writing reply" }); + }); + return state; + } + + async function settle(opened, id) { + const submission = await opened.harness.submission(id, context); + if (!submission) throw new Error("Pi has no record of this request"); + let record; + try { + record = await submission.wait(context); + } catch (error) { + if (opened.closing) return { status: "closed" }; + throw error; + } + if (record.status !== "done") { + const failed = opened.failed.get(id) ?? { + status: "unanswered", + reason: record.reason, + ...(record.detail ? { detail: String(record.detail) } : {}), + }; + opened.failed.delete(id); + if (record.reason !== "aborted") + await withdraw(opened, record.conversationId, failed); + return failed; + } + const entry = await opened.harness.commit( + (tx) => tx.entry(AssistantEntry, record.answer), + context, + ); + return { + status: "done", + answer: record.answer, + text: textOf(entry?.model?.at(-1)), + }; + } + + /** A failed run leaves what was queued for it waiting for a later request: + * it is withdrawn instead, unanswered for the same reason. */ + async function withdraw(opened, conversation, failed) { + const { submissions } = await opened.harness.inspect(context); + for (const { id, status, conversationId } of submissions) { + if (status !== "queued" || conversationId !== conversation) continue; + opened.failed.set(id, failed); + const result = await opened.harness.abortSubmission( + id, + context, + conversation, + ); + if (result !== "aborted") opened.failed.delete(id); + } + } + + /** Starts a request in a conversation configured for this turn. One that + * steers a run working in it, `busy`, changes only what its owner picked, + * as in Claude. */ + async function submit(conversation, turn, busy) { + const steers = busy && turn.steer; + const chosen = await choose(turn); + const change = steers + ? { + ...(turn.pick?.model ? { model: chosen.model } : {}), + ...(turn.pick?.thinking + ? { thinkingLevel: chosen.thinkingLevel } + : {}), + } + : { + model: chosen.model, + thinkingLevel: chosen.thinkingLevel, + instructions: turn.instructions, + cwd: turn.cwd.replace(/^~(?=$|\/)/, homedir()), + }; + if (Object.keys(change).length) + await conversation.configure(change, context); + const submission = await conversation.submit( + { + type: "input", + content: steers ? turn.steer : turn.text, + whenBusy: "steer", + requestId: turn.request, + }, + context, + ); + return { submission: submission.id, conversation: conversation.id }; + } + + const methods = { + async providers() { + const providers = []; + for (const provider of models.getProviders()) { + if (!models.getModels(provider.id).length) continue; + const auth = await models.checkAuth(provider.id).catch(() => undefined); + providers.push({ + id: provider.id, + name: provider.name, + key: Boolean(provider.auth.apiKey), + signsIn: Boolean(provider.auth.oauth), + ...(auth ? { configured: auth.source ?? auth.type } : {}), + }); + } + providers.sort((a, b) => a.name.localeCompare(b.name)); + return { providers, defaults: await piDefaults(dir) }; + }, + /** What Pi's settings pick now, which the `pi` command changes. */ + defaults() { + return piDefaults(dir); + }, + models({ provider }) { + return models.getModels(provider).map((model) => ({ + id: model.id, + name: model.name, + thinking: getSupportedThinkingLevels(model), + })); + }, + /** Saves a key with Pi's own login for the provider, which takes only a + * key here: one that asks for more is set up with `pi`. */ + async login({ provider, key }) { + const name = models.getProvider(provider)?.name ?? provider; + let given = false; + await models.login(provider, "api_key", { + signal: AbortSignal.timeout(TEST_TIMEOUT_MS), + notify() {}, + async prompt(prompt) { + if (prompt.type === "secret" && !given) { + given = true; + return key; + } + throw new Error( + `${name} needs more than an API key. Set it up with the pi command's /login, then choose it here.`, + ); + }, + }); + }, + /** Asks the model for one short reply. */ + async test(turn) { + const { found, model } = await choose(turn); + const reply = await models.completeSimple( + found, + { + messages: [ + { role: "user", content: "Reply with OK.", timestamp: Date.now() }, + ], + }, + { + maxTokens: 32, + maxRetries: 0, + signal: AbortSignal.timeout(TEST_TIMEOUT_MS), + }, + ); + return reply.stopReason === "error" || reply.stopReason === "aborted" + ? { + ok: false, + message: reply.errorMessage || "The provider did not answer", + } + : { ok: true, message: `Connected to ${model.modelId}` }; + }, + /** Opens the agent's storage, once, with its Buzz tools as they are now. */ + async open({ agent, tools }) { + const opened = await opening(agent); + const listed = JSON.stringify(tools); + if (opened.tools !== listed) { + opened.registry.install(buzz(opened, tools)); + opened.tools = listed; + } + }, + /** The agent's Buzz conversations: each key's Pi conversation, and the + * newest message it has been shown. Storage opened only to be read runs + * nothing: Pi durable resumes work only when asked for progress. */ + async places({ agent }) { + const { harness } = await opening(agent); + const places = (await harness.snapshot(Places, context)) ?? {}; + return { + places: Object.entries(places).map(([key, place]) => ({ + key, + conversation: place.conversation, + seen: place.seen, + })), + }; + }, + /** A page of a conversation's entries, newest first, as a transcript + * shows them. */ + async entries({ agent, conversation, cursor }) { + const { harness } = await opening(agent); + const found = await harness.conversation(conversation, context); + if (!found) return { entries: [] }; + const page = await found.entries({}, PAGE, cursor, context); + return { + entries: page.items.map(({ id, kind, model }) => ({ + id, + kind, + // System entries hold every tool's schema; a transcript shows none. + ...(model && kind !== "pi.system" ? { model: model.map(slim) } : {}), + })), + ...(page.next ? { next: page.next } : {}), + }; + }, + /** The newest message a conversation key's Pi conversation has been + * shown; 0 when it has none yet. */ + async place({ agent, key }) { + const { harness } = await agentOf(agent); + const place = (await harness.snapshot(Places, context))?.[key]; + return { + seen: + place && (await harness.conversation(place.conversation, context)) + ? place.seen + : 0, + }; + }, + /** A turn for a conversation key: its Pi conversation, made the first + * time, takes the request, or steers its run if one is working. */ + async deliver({ agent, key, at, context: tools, ...turn }) { + const opened = await agentOf(agent); + const prior = opened.queues.get(key); + const delivered = (async () => { + await prior?.catch(() => undefined); + const { harness } = opened; + const place = (await harness.snapshot(Places, context))?.[key]; + const conversation = + (place && + (await harness.conversation(place.conversation, context))) || + (await harness.createConversation( + { ownership: { kind: "ownerless" } }, + context, + )); + opened.contexts.set(conversation.id, tools); + const { busy } = await watch(opened, conversation.id); + const submitted = await submit(conversation, turn, busy); + // Recorded once Pi has the turn: one that could not start leaves the + // messages it would have been shown for the next. + await harness.commit(async (tx) => { + const places = await tx.doc(Places); + places[key] = { + conversation: conversation.id, + context: tools, + seen: Math.max(at, places[key]?.seen ?? 0), + }; + }, context); + return submitted; + })(); + opened.queues.set(key, delivered); + const done = () => { + if (opened.queues.get(key) === delivered) opened.queues.delete(key); + }; + void delivered.then(done, done); + return delivered; + }, + /** A one-off turn in a conversation of its own, with no default + * destination for its tools. Nothing watches it: it has no other + * requests, and no Activity. */ + async once({ agent, ...turn }) { + const opened = await agentOf(agent); + const conversation = await opened.harness.createConversation( + { ownership: { kind: "ownerless" } }, + context, + ); + return submit(conversation, turn, false); + }, + /** How a request settled: its answer's text, or why it has none. */ + async wait({ agent, submission }) { + return settle(await agentOf(agent), submission); + }, + async abort({ agent, conversation }) { + const { harness } = await agentOf(agent); + await (await harness.conversation(conversation, context))?.abort(context); + }, + /** Closes the agent's storage. Unfinished runs stay, and continue when it + * opens again. */ + async close({ agent }) { + const opening = agents.get(agent); + agents.delete(agent); + await shut(await opening?.catch(() => undefined)); + }, + }; + + async function shut(opened) { + if (!opened || opened.closing) return; + opened.closing = true; + for (const { stream } of opened.watched.values()) + await stream.stop().catch(() => undefined); + await opened.harness.close(context).catch((error) => { + console.error("Pi could not close an agent", error); + }); + await opened.release().catch(() => undefined); + } + + async function handle({ id, method, params }) { + try { + const run = methods[method]; + if (!run) throw new Error(`Unknown method ${method}`); + write({ id, result: (await run(params ?? {})) ?? {} }); + } catch (error) { + write({ + id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + let stopping; + const stop = () => { + stopping ??= Promise.all( + [...agents.values()].map((opening) => + opening.then(shut, () => undefined), + ), + ).finally(exit); + return stopping; + }; + const lines = createInterface({ input }); + lines.on("line", (line) => { + let message; + try { + message = JSON.parse(line); + } catch { + return; + } + if (message.method) void handle(message); + else { + const pending = calls.get(message.id); + calls.delete(message.id); + if (message.error) pending?.reject(new Error(message.error)); + else pending?.resolve(message.result ?? {}); + } + }); + // The plugin closes stdin to stop the host; each agent's storage closes + // first, so the next host can open it at once. + lines.on("close", stop); + notify("ready", {}); + return { stop }; +} + +if (process.env.BUZZ_PI_HOST === "1") { + const dir = piDir(); + const { stop } = serve({ + data: process.argv[1], + models: builtinModels({ + credentials: new AuthFile(join(dir, "auth.json")), + }), + dir, + }); + process.on("SIGTERM", stop); +} diff --git a/src/bundled/pi/index.ts b/src/bundled/pi/index.ts new file mode 100644 index 0000000000..31b080d266 --- /dev/null +++ b/src/bundled/pi/index.ts @@ -0,0 +1,93 @@ +// Pi agents: an agent type whose agents run on Pi durable on this computer, +// with any provider Pi supports. One Pi host runs them all, each with its own +// storage; each conversation the agent is in is one continuing, durable Pi +// conversation. Pi answers with the in-process Buzz tools. +import { isTauri } from "@tauri-apps/api/core"; +import { ownerReads } from "../../features/agents/reads"; +import type { PluginModule } from "../../plugins/api"; +import { PiLogoIcon } from "../../shared/design-system/icons"; +import { type Config, config, DEFAULT_CONFIG, PiRuntime } from "./runtime"; +import { createTabs } from "./tabs"; + +export const TYPE_ID = "pi"; +/** A turn's run returns once Pi has it, so this bounds only the hand-over, + * which includes the first install of Pi's packages. */ +const HANDOVER_MS = 2 * 60_000; + +export const inject = ["agents", "host"]; +export const apply: PluginModule["apply"] = (ctx) => { + // Only the desktop app can start the Pi host, and file uploads run `base64`, + // which stock Windows lacks; no unusable type elsewhere. + if (!isTauri() || !/Mac|Linux/i.test(navigator.platform)) return; + const { agents, host } = ctx; + const typeKey = `${ctx.pluginOwner?.id ?? "buzz.pi"}/${TYPE_ID}`; + const runtime = new PiRuntime( + host, + (pubkey) => ownerReads(agents.relay(pubkey), pubkey), + (pubkey, signal) => agents.core(pubkey, signal), + () => agents.tools(), + ); + const { ActivityTab, Create, CreateModel, SettingsTab } = createTabs({ + setup: runtime.setup, + runtime, + }); + const sync = () => { + const snapshot = agents.snapshot(); + if (snapshot.status === "loading") return; + runtime.sync(snapshot.running.filter((agent) => agent.type === typeKey)); + }; + if (host.spawn) { + const unsubscribe = agents.subscribe(sync); + sync(); + ctx.effect(() => () => { + unsubscribe(); + runtime.dispose(); + }); + } + + agents.register({ + id: TYPE_ID, + title: "Pi", + icon: PiLogoIcon, + description: + "Pi on this computer, with any provider Pi supports. Each thread it is mentioned in is one continuing conversation.", + defaults: () => ({ config: DEFAULT_CONFIG }), + Create, + CreateModel, + adopt: (carry, saved) => ({ + ...saved, + instructions: carry.instructions ?? saved.instructions, + workspace: carry.workspace ?? saved.workspace, + }), + carry: (saved) => { + const { instructions, workspace } = config(saved); + return { + ...(instructions.trim() && { instructions }), + ...(workspace !== DEFAULT_CONFIG.workspace && { workspace }), + }; + }, + summary: (agent) => { + const { model, provider } = config(agent.config); + return [provider, model].filter(Boolean).join(" · ") || "Pi"; + }, + tabs: [ + { + id: "settings", + title: "Settings", + role: "settings", + splitSettings: true, + component: SettingsTab, + }, + { + id: "activity", + title: "Activity", + role: "activity", + component: ActivityTab, + }, + ], + models: (agent) => runtime.models(agent), + run: (delivery) => runtime.run(delivery), + runOnce: (turn) => runtime.runOnce(turn), + timeoutMs: HANDOVER_MS, + }); +}; diff --git a/src/bundled/pi/manifest.json b/src/bundled/pi/manifest.json new file mode 100644 index 0000000000..56ef5a2d1f --- /dev/null +++ b/src/bundled/pi/manifest.json @@ -0,0 +1,21 @@ +{ + "id": "buzz.pi", + "name": "Pi", + "apiVersion": 1, + "description": "Run local Pi agents in Buzz.", + "discovery": { "category": "agents" }, + "host": { + "processes": [ + { + "id": "pi-sh", + "program": "bash", + "args": ["-c"] + }, + { + "id": "read", + "program": "base64", + "args": ["-i"] + } + ] + } +} diff --git a/src/bundled/pi/pi-sh.test.ts b/src/bundled/pi/pi-sh.test.ts new file mode 100644 index 0000000000..f6a8c980ea --- /dev/null +++ b/src/bundled/pi/pi-sh.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment node +// The real pi.sh in a throwaway home, with a fake npm, starting a stand-in +// host on this Node. +import { execFile } from "node:child_process"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, expect, it } from "vitest"; +import script from "./pi.sh?raw"; + +const run = promisify(execFile); +const homes: string[] = []; +afterEach(async () => { + await Promise.all( + homes.splice(0).map((home) => rm(home, { recursive: true, force: true })), + ); +}); + +/** What the stand-in host says of where it runs. */ +const HOST = + "console.log(JSON.stringify({ cwd: process.cwd(), data: process.argv[1], host: process.env.BUZZ_PI_HOST }))"; +const PACKAGE = '{"name":"buzz-pi-host","private":true}'; +const LOCK = '{"lockfileVersion":3}'; + +async function disk(npm = 'mkdir -p "$prefix/node_modules"') { + const home = await mkdtemp(join(tmpdir(), "buzz-pi-")); + homes.push(home); + const bin = join(home, "bin"); + await mkdir(bin); + await symlink(process.execPath, join(bin, "node")); + // Notes each install, then does what the test says, in the folder it got. + await writeFile( + join(bin, "npm"), + `#!/bin/sh\necho "$*" >>"${home}/npm.log"\nwhile [ "$1" != --prefix ]; do shift; done\nprefix=$2\n${npm}\n`, + ); + await chmod(join(bin, "npm"), 0o755); + const dir = + process.platform === "darwin" + ? join(home, "Library/Application Support/Buzz/pi") + : join(home, ".local/share/buzz/pi"); + const step = async ( + env: Readonly> = {}, + lock = LOCK, + ) => + ( + await run("bash", ["-c", script, "pi.sh", HOST, PACKAGE, lock], { + env: { + PATH: `${bin}:/usr/bin:/bin`, + HOME: home, + SHELL: "/bin/false", + ...env, + }, + }) + ).stdout.trim(); + return { + home, + bin, + dir, + step, + installs: async () => + (await readFile(join(home, "npm.log"), "utf8").catch(() => "")) + .split("\n") + .filter(Boolean), + folders: async () => (await readdir(join(dir, "deps"))).sort(), + }; +} + +it("installs the locked packages once, and runs the host in them", async () => { + const pi = await disk(); + const first = JSON.parse(await pi.step()); + const [folder, ...others] = await pi.folders(); + expect(others).toEqual([]); + expect(first).toEqual({ + cwd: expect.stringMatching(new RegExp(`/deps/${folder}$`)), + data: pi.dir, + host: "1", + }); + expect(await pi.installs()).toEqual([ + expect.stringMatching(/^ci --prefix .+ --ignore-scripts --omit=dev /), + ]); + // The next start uses them as they are. + expect(JSON.parse(await pi.step())).toEqual(first); + expect(await pi.installs()).toHaveLength(1); + // Packages pinned otherwise go beside them. + await pi.step({}, '{"lockfileVersion":3,"packages":{}}'); + expect(await pi.folders()).toHaveLength(2); +}); + +it("leaves nothing of an install that failed, and installs again next time", async () => { + const failing = await disk('mkdir -p "$prefix/node_modules"; exit 1'); + await expect(failing.step()).rejects.toThrow(); + expect(await failing.folders()).toEqual([]); + await writeFile( + join(failing.bin, "npm"), + `#!/bin/sh\necho "$*" >>"${failing.home}/npm.log"\n`, + ); + await failing.step(); + expect(await failing.installs()).toHaveLength(2); + expect(await failing.folders()).toHaveLength(1); +}); + +it("says Pi needs a newer Node when the one it finds is too old", async () => { + const pi = await disk(); + await rm(join(pi.bin, "node")); + await writeFile(join(pi.bin, "node"), "#!/bin/sh\nexit 1\n"); + await chmod(join(pi.bin, "node"), 0o755); + const failed = await pi + .step() + .catch((error: { code: number; stderr: string }) => error); + expect(failed).toMatchObject({ + code: 127, + stderr: expect.stringContaining("Pi needs Node.js 22.19 or later."), + }); +}); diff --git a/src/bundled/pi/pi-testing.ts b/src/bundled/pi/pi-testing.ts new file mode 100644 index 0000000000..b8fe5fee01 --- /dev/null +++ b/src/bundled/pi/pi-testing.ts @@ -0,0 +1,140 @@ +// A fake Pi host for tests: each process it spawns answers the plugin's +// requests with the test's handlers, one JSON message a line, as host.mjs +// does, and can call the plugin's tools and send it events. +import type { + HostProcess, + HostProcessOptions, +} from "../../features/host/service"; + +export type Request = Readonly<{ + id: number; + method: string; + params: Record; +}>; +export type Handler = ( + params: Record, + process: FakeProcess, +) => unknown; + +export type FakeProcess = { + id: string; + options: HostProcessOptions; + requests: Request[]; + ended: boolean; + killed: boolean; + /** Sends the plugin one message, as stdout. */ + send(message: object): void; + /** Calls one of the plugin's tools and resolves with its answer. */ + tool(params: object): Promise<{ result?: unknown; error?: string }>; + /** Exits, saying `stderr` last. */ + exit(code: number | null, stderr?: string): void; +}; + +export function fakeHost( + handlers: Readonly> = {}, + options: Readonly<{ ready?: boolean }> = {}, +) { + const processes: FakeProcess[] = []; + const spawn = async ( + id: string, + spawnOptions: HostProcessOptions = {}, + ): Promise => { + let exit!: (code: number | null) => void; + const exited = new Promise((resolve) => { + exit = resolve; + }); + let gone = false; + let buffer = ""; + let called = 0; + const answers = new Map< + string, + (answer: { result?: unknown; error?: string }) => void + >(); + const process: FakeProcess = { + id, + options: spawnOptions, + requests: [], + ended: false, + killed: false, + send: (message) => { + if (!gone) spawnOptions.onStdout?.(`${JSON.stringify(message)}\n`); + }, + tool: (params) => + new Promise((resolve) => { + const id = `h${++called}`; + answers.set(id, resolve); + process.send({ id, method: "tool", params }); + }), + exit: (code, stderr) => { + if (gone) return; + if (stderr) spawnOptions.onStderr?.(stderr); + gone = true; + exit(code); + }, + }; + processes.push(process); + const handle = async (request: Request) => { + process.requests.push(request); + const handler = handlers[request.method]; + try { + process.send({ + id: request.id, + result: (await handler?.(request.params, process)) ?? {}, + }); + } catch (error) { + process.send({ + id: request.id, + error: error instanceof Error ? error.message : String(error), + }); + } + }; + const write = async (data: string) => { + if (gone) throw new Error("closed"); + buffer += data; + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const message = JSON.parse(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + newline = buffer.indexOf("\n"); + if (message.method) void handle(message); + else { + answers.get(message.id)?.(message); + answers.delete(message.id); + } + } + }; + if (options.ready !== false) + queueMicrotask(() => process.send({ method: "ready", params: {} })); + return { + write, + end: async () => { + process.ended = true; + }, + kill: async () => { + process.killed = true; + process.exit(null); + }, + exited, + }; + }; + return { + spawn, + processes, + /** Every request the plugin made, of `method`, across processes. */ + requests: (method: string) => + processes.flatMap((item) => + item.requests.filter((request) => request.method === method), + ), + }; +} + +/** A promise the test settles. */ +export function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((done, fail) => { + resolve = done; + reject = fail; + }); + return { promise, resolve, reject }; +} diff --git a/src/bundled/pi/pi.sh b/src/bundled/pi/pi.sh new file mode 100644 index 0000000000..b65e11ec34 --- /dev/null +++ b/src/bundled/pi/pi.sh @@ -0,0 +1,64 @@ +# Buzz's Pi host, on this computer's Node, with its own copy of Pi's +# packages, apart from any `pi` the user installed. The plugin runs it as +# `bash -c` (see `start` in pi.ts) with HOST PACKAGE LOCK: it installs the +# packages LOCK pins, once, then runs the host source HOST with Node. +set -eu + +# Shared by every Buzz build on this computer, so there is one install of +# each set of packages, and each agent's storage is in one place. +if [ "$(uname)" = Darwin ]; then + dir="$HOME/Library/Application Support/Buzz/pi" +else + dir="${XDG_DATA_HOME:-$HOME/.local/share}/buzz/pi" +fi + +# Pi's packages need Node 22.19 for `node:sqlite`. The app's PATH has +# Homebrew's folders; a version manager's are set up by a login shell. +usable() { + [ -n "$1" ] && [ -x "$1" ] && "$1" -e ' + const [major, minor] = process.versions.node.split(".").map(Number); + process.exit(major > 22 || (major === 22 && minor >= 19) ? 0 : 1)' 2>/dev/null +} +node=$(command -v node || true) +usable "$node" || + node=$("${SHELL:-/bin/sh}" -lc 'command -v node' /dev/null || true) +usable "$node" || { + echo "Pi needs Node.js 22.19 or later. Install it from nodejs.org or with Homebrew, then try again." >&2 + exit 127 +} + +host="$1" package="$2" lock="$3" +# A folder for each set of packages, named by what pins them, so a build +# that pins others installs its own beside this one. +sum=$(printf '%s\n%s' "$package" "$lock" | + (shasum -a 256 2>/dev/null || sha256sum) | cut -c1-16) +deps="$dir/deps/$sum" +if [ ! -d "$deps" ]; then + echo "Installing Pi's packages…" >&2 + PATH="$(dirname "$node"):$PATH" + command -v npm >/dev/null || { echo "Pi needs npm to install its packages" >&2; exit 127; } + # Each install has its own folder, so two Buzz builds installing at once + # do not write over each other, and a stopped one leaves nothing. + part="$dir/deps/.$sum.$$" + trap 'rm -rf "$part"' EXIT + trap 'exit 143' INT TERM + mkdir -p "$part" + printf '%s' "$package" >"$part/package.json" + printf '%s' "$lock" >"$part/package-lock.json" + # Exactly what the lock pins, each package checked against its hash, from + # the registry npm is set up for. Their install scripts do not run. + npm ci --prefix "$part" --ignore-scripts --omit=dev --no-audit --no-fund \ + --loglevel=error >&2 + # A rename, so a host starting now sees no folder or a whole one. It fails + # when another build's rename came first, and this copy is removed. + "$node" -e 'require("fs").renameSync(process.argv[1], process.argv[2])' \ + "$part" "$deps" 2>/dev/null || rm -rf "$part" + [ -d "$deps" ] || { echo "Pi could not install its packages" >&2; exit 1; } + trap - EXIT INT TERM + # Folders no build has started for a month are old builds' packages. + find "$dir/deps" -mindepth 1 -maxdepth 1 -mtime +30 -exec rm -rf {} + 2>/dev/null || true +fi +touch "$deps" +cd "$deps" +BUZZ_PI_HOST=1 exec "$node" --disable-warning=ExperimentalWarning \ + --input-type=module -e "$host" "$dir" diff --git a/src/bundled/pi/pi.test.ts b/src/bundled/pi/pi.test.ts new file mode 100644 index 0000000000..02d79279d7 --- /dev/null +++ b/src/bundled/pi/pi.test.ts @@ -0,0 +1,137 @@ +import { expect, it, vi } from "vitest"; +import { Pi, PiStopped } from "./pi"; +import { deferred, fakeHost } from "./pi-testing"; + +const handlers = () => ({ tool: vi.fn(), event: vi.fn() }); + +it("starts the host once, with the install script, and pairs each answer with its request", async () => { + const slow = deferred(); + const host = fakeHost({ + first: () => slow.promise, + second: (params) => ({ echo: params.value }), + }); + const pi = new Pi(() => host.spawn, handlers()); + const first = pi.request("first"); + const second = pi.request("second", { value: 2 }); + await expect(second).resolves.toEqual({ echo: 2 }); + slow.resolve({ done: 1 }); + await expect(first).resolves.toEqual({ done: 1 }); + expect(host.processes).toHaveLength(1); + const [process] = host.processes; + expect(process?.id).toBe("pi-sh"); + expect(process?.options.cwd).toBe("~"); + expect(process?.options.args?.[1]).toBe("pi.sh"); + // The packages it installs are the ones the lock pins. + expect(process?.options.args?.at(-1)).toContain('"lockfileVersion"'); +}); + +it("reads messages split across chunks, and several in one", async () => { + // Its answer comes from the test, in pieces. + const host = fakeHost( + { first: () => new Promise(() => {}) }, + { ready: false }, + ); + const events = handlers(); + const pi = new Pi(() => host.spawn, events); + const started = pi.request("first"); + await vi.waitFor(() => expect(host.processes).toHaveLength(1)); + const stdout = host.processes[0]?.options.onStdout; + stdout?.('{"method":"rea'); + stdout?.( + 'dy","params":{}}\n{"method":"event","params":{"agent":"a","conversation":1,"type":"step","tool":"bash"}}\n', + ); + await vi.waitFor(() => expect(host.requests("first")).toHaveLength(1)); + stdout?.('{"id":1,"result":{"ok":true}}'); + stdout?.("\n"); + await expect(started).resolves.toEqual({ ok: true }); + expect(events.event).toHaveBeenCalledWith({ + agent: "a", + conversation: 1, + type: "step", + tool: "bash", + }); +}); + +it("says why a host could not start, and starts another for the next request", async () => { + const host = fakeHost({ ping: () => ({ pong: true }) }, { ready: false }); + const pi = new Pi(() => host.spawn, handlers()); + const failed = pi.request("ping"); + await vi.waitFor(() => expect(host.processes).toHaveLength(1)); + host.processes[0]?.exit( + 127, + "Installing…\nPi needs Node.js 22.19 or later. Install it from nodejs.org or with Homebrew, then try again.\n", + ); + const error = await failed.catch((reason: unknown) => reason); + expect(error).not.toBeInstanceOf(PiStopped); + expect((error as Error).message).toBe( + "Pi needs Node.js 22.19 or later. Install it from nodejs.org or with Homebrew, then try again.", + ); + const retried = pi.request("ping"); + await vi.waitFor(() => expect(host.processes).toHaveLength(2)); + host.processes[1]?.send({ method: "ready", params: {} }); + await expect(retried).resolves.toEqual({ pong: true }); +}); + +it("fails what a running host had not answered when it stops", async () => { + const host = fakeHost({ wait: () => new Promise(() => {}) }); + const pi = new Pi(() => host.spawn, handlers()); + const waiting = pi.request("wait"); + await vi.waitFor(() => expect(host.requests("wait")).toHaveLength(1)); + host.processes[0]?.exit(1, "FATAL: out of memory\n"); + const error = await waiting.catch((reason: unknown) => reason); + expect(error).toBeInstanceOf(PiStopped); + expect((error as Error).message).toBe("Pi stopped: FATAL: out of memory"); +}); + +it("names Node's error, not its version, when a host crashes", async () => { + const host = fakeHost({ wait: () => new Promise(() => {}) }); + const pi = new Pi(() => host.spawn, handlers()); + const waiting = pi.request("wait"); + await vi.waitFor(() => expect(host.requests("wait")).toHaveLength(1)); + host.processes[0]?.exit( + 1, + "file:///host.mjs:9\n throw new Error(x);\n ^\n\nTypeError: x is not a function\n at file:///host.mjs:9:3\n\nNode.js v22.19.0\n", + ); + const error = await waiting.catch((reason: unknown) => reason); + expect((error as Error).message).toBe( + "Pi stopped: TypeError: x is not a function", + ); +}); + +it("runs the tools the host calls, and sends back each result or error", async () => { + const host = fakeHost(); + const tool = vi + .fn() + .mockResolvedValueOnce({ content: [{ type: "text", text: "sent" }] }) + .mockRejectedValueOnce(new Error("No such channel")); + const pi = new Pi(() => host.spawn, { tool, event: vi.fn() }); + await pi.request("open"); + const process = host.processes[0]; + const call = { + agent: "a", + conversation: 3, + context: { channel: "c" }, + name: "send", + arguments: { text: "hi" }, + }; + await expect(process?.tool(call)).resolves.toMatchObject({ + result: { content: [{ type: "text", text: "sent" }] }, + }); + expect(tool).toHaveBeenCalledWith(call); + await expect(process?.tool(call)).resolves.toMatchObject({ + error: "No such channel", + }); +}); + +it("closes the host's stdin to stop it, and refuses requests after", async () => { + const host = fakeHost(); + const pi = new Pi(() => host.spawn, handlers()); + await pi.request("open"); + const process = host.processes[0]; + const disposed = pi.dispose(); + await vi.waitFor(() => expect(process?.ended).toBe(true)); + process?.exit(0); + await disposed; + expect(process?.killed).toBe(false); + await expect(pi.request("open")).rejects.toThrow("Pi has stopped"); +}); diff --git a/src/bundled/pi/pi.ts b/src/bundled/pi/pi.ts new file mode 100644 index 0000000000..16bc366f10 --- /dev/null +++ b/src/bundled/pi/pi.ts @@ -0,0 +1,222 @@ +// The Pi host (host.mjs): one process for every Pi agent here, started when +// something needs it, spoken to over stdio, one JSON message a line. Requests +// go both ways: the plugin asks the host to run turns, and the host asks the +// plugin to run the Buzz tools, so the process holds no Buzz key. +import type { Context } from "../../buzz-mcp/client"; +import type { + HostProcess, + HostProcessOptions, +} from "../../features/host/service"; +import packageJson from "./deps/package.json?raw"; +import packageLock from "./deps/package-lock.json?raw"; +import host from "./host.mjs?raw"; +import script from "./pi.sh?raw"; + +type Spawn = (id: string, options?: HostProcessOptions) => Promise; + +export type ToolCall = Readonly<{ + agent: string; + /** The Pi conversation whose run called. */ + conversation: number; + /** Where the tools default to for that conversation. */ + context: Context; + name: string; + arguments: Record; +}>; +/** An MCP tool result; `final` ends the run that called it. */ +export type ToolResult = Readonly<{ + content: readonly unknown[]; + isError?: boolean; + final?: boolean; +}>; +export type PiEvent = Readonly< + { agent: string; conversation: number } & ( + | { type: "placed"; request: string; entry: number } + | { type: "step"; tool: string; target?: string } + ) +>; +type Handlers = Readonly<{ + tool(call: ToolCall): Promise; + event(event: PiEvent): void; +}>; + +/** The host stopped after it started; its runs continue in the next one. */ +export class PiStopped extends Error {} + +/** What the host's stderr says last, for an error that names a cause. */ +const TAIL = 2_000; +/** The line of the host's stderr that says why it stopped: Node's error, + * else its last line, past Node's version and npm's log path. */ +const cause = (stderr: string) => { + const lines = stderr + .split("\n") + .map((line) => line.trim()) + .filter( + (line) => line && !/^Node\.js v|^npm error A complete log/.test(line), + ); + return lines.find((line) => /^[A-Z]\w*Error\b/.test(line)) ?? lines.at(-1); +}; +/** How long a stopping host has to close each agent's storage. */ +const STOP_MS = 2_000; + +type Message = { + id?: number | string; + method?: string; + params?: unknown; + result?: unknown; + error?: string; +}; +type Connection = { + process: HostProcess; + calls: Map< + number, + { resolve(value: unknown): void; reject(error: Error): void } + >; + /** Set once it has exited, with why. */ + gone?: Error; +}; + +export class Pi { + private connection: Promise | undefined; + private requests = 0; + private stopped = false; + + constructor( + private readonly spawn: () => Spawn, + private readonly handlers: Handlers, + ) {} + + /** Asks the host, starting it first if it is not running. */ + async request(method: string, params: object = {}): Promise { + const connection = await this.connect(); + if (connection.gone) throw connection.gone; + const id = ++this.requests; + const answer = new Promise((resolve, reject) => + connection.calls.set(id, { resolve, reject }), + ); + void connection.process + .write(`${JSON.stringify({ id, method, params })}\n`) + .catch((error: unknown) => { + connection.calls + .get(id) + ?.reject(new Error(`Pi did not take that: ${error}`)); + connection.calls.delete(id); + }); + return answer as Promise; + } + + /** Stops the host: it closes each agent's storage, then it is killed. */ + async dispose() { + this.stopped = true; + const connection = await this.connection?.catch(() => undefined); + if (!connection || connection.gone) return; + await connection.process.end().catch(() => undefined); + const timer = setTimeout(() => void connection.process.kill(), STOP_MS); + await connection.process.exited; + clearTimeout(timer); + } + + private connect(): Promise { + if (this.stopped) return Promise.reject(new Error("Pi has stopped")); + if (!this.connection) { + const connecting = this.start(); + this.connection = connecting; + // One that failed to start is tried again by the next request. + connecting.catch(() => { + if (this.connection === connecting) this.connection = undefined; + }); + } + return this.connection; + } + + private async start(): Promise { + let ready!: () => void; + let isReady = false; + const started = new Promise((resolve) => { + ready = () => { + isReady = true; + resolve(); + }; + }); + let buffer = ""; + let stderr = ""; + const connection: Connection = { + calls: new Map(), + process: undefined as unknown as HostProcess, + }; + const handle = (message: Message) => { + if (message.method === "ready") ready(); + else if (message.method === "event") + this.handlers.event(message.params as PiEvent); + else if (message.method === "tool" && message.id !== undefined) + void this.answer(connection, message.id, message.params as ToolCall); + else if (typeof message.id === "number") { + const call = connection.calls.get(message.id); + connection.calls.delete(message.id); + if (message.error) call?.reject(new Error(message.error)); + else call?.resolve(message.result); + } + }; + connection.process = await this.spawn()("pi-sh", { + args: [script, "pi.sh", host, packageJson, packageLock], + cwd: "~", + onStdout: (data) => { + buffer += data; + let newline = buffer.indexOf("\n"); + while (newline >= 0) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + newline = buffer.indexOf("\n"); + if (!line) continue; + try { + handle(JSON.parse(line)); + } catch (error) { + console.warn("Pi sent a message Buzz could not read", error); + } + } + }, + onStderr: (data) => { + stderr = (stderr + data).slice(-TAIL); + }, + }); + void connection.process.exited.then((code) => { + // Before it is ready, its last words are why it could not start, such + // as Node missing. + const why = cause(stderr); + const exit = `exit ${code ?? "signal"}`; + connection.gone = isReady + ? new PiStopped(why ? `Pi stopped: ${why}` : `Pi stopped (${exit})`) + : new Error(why ?? `Pi could not start (${exit})`); + for (const call of connection.calls.values()) + call.reject(connection.gone); + connection.calls.clear(); + // The next request starts another. + this.connection = undefined; + ready(); + }); + await started; + if (connection.gone) throw connection.gone; + return connection; + } + + /** Runs a Buzz tool the host called, and sends it the result. */ + private async answer( + connection: Connection, + id: number | string, + call: ToolCall, + ) { + let message: Message; + try { + message = { id, result: await this.handlers.tool(call) }; + } catch (error) { + message = { + id, + error: error instanceof Error ? error.message : String(error), + }; + } + if (!connection.gone) + await connection.process + .write(`${JSON.stringify(message)}\n`) + .catch(() => undefined); + } +} diff --git a/src/bundled/pi/runtime.test.ts b/src/bundled/pi/runtime.test.ts new file mode 100644 index 0000000000..717e523377 --- /dev/null +++ b/src/bundled/pi/runtime.test.ts @@ -0,0 +1,724 @@ +import { afterEach, expect, it, vi } from "vitest"; +import type { AddedTool } from "../../buzz-mcp/session-tools"; +import { ownerReads } from "../../features/agents/reads"; +import type { MemoryEntry } from "../../features/agents/memory"; +import type { + Agent, + Delivery, + StandaloneTurn, +} from "../../features/agents/service"; +import type { EventData, RelayEvent } from "../../features/relay/events"; +import type { RelayData } from "../../features/relay/service"; +import { deferred, fakeHost, type Handler } from "./pi-testing"; +import { type Config, DEFAULT_CONFIG, PiRuntime } from "./runtime"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +const self = "c".repeat(64); +const alice = "a".repeat(64); +const bob = "b".repeat(64); +const channel = "chan-1"; +const CONVERSATION = 7; +const message = ( + id: string, + content: string, + extra: Partial = {}, +): RelayEvent => + ({ + id: id.repeat(64).slice(0, 64), + pubkey: alice, + kind: 9, + created_at: 100, + content, + tags: [["h", channel]], + sig: "", + ...extra, + }) as RelayEvent; +const root = message("1", "Build is red"); +const reply = (id: string, content: string, created_at = 110) => + message(id, content, { + created_at, + tags: [ + ["h", channel], + ["e", root.id, "", "reply"], + ["p", self], + ], + }); + +function setup( + handlers: Readonly> = {}, + added: readonly AddedTool[] = [], +) { + let submissions = 0; + const host = fakeHost({ + deliver: () => ({ + submission: ++submissions, + conversation: CONVERSATION, + }), + once: () => ({ submission: ++submissions, conversation: 99 }), + place: () => ({ seen: 0 }), + wait: () => new Promise(() => {}), + ...handlers, + }); + /** Channels that are the owner's own Solo chats; the owner is the viewer. */ + const solo = new Set(); + const session = { + read: vi.fn(async () => [root]), + agentMemories: { + read: vi.fn(async () => ({ + entries: [] as readonly MemoryEntry[], + partial: false, + })), + }, + channels: { + list: () => ({ + channels: [ + { id: channel, name: "general", channelType: "stream", members: [] }, + ...[...solo].map((id) => ({ id, channelType: "solo", members: [] })), + ], + }), + }, + names: { + resolve: (pubkey: string) => (pubkey === alice ? "Alice" : undefined), + }, + profiles: { + ensure: vi.fn(async (): Promise => {}), + snapshot: () => new Map(), + event: vi.fn((_key: string): RelayEvent | undefined => undefined), + }, + }; + const relay = { + snapshot: () => ({ status: "ready", session, viewer: alice }), + } as unknown as RelayData; + const core = vi.fn(async () => null as string | null | undefined); + const runtime = new PiRuntime( + { spawn: host.spawn }, + (pubkey) => ownerReads(relay, pubkey), + core, + () => added, + ); + const published: { kind: number; content: string; tags: string[][] }[] = []; + const agent = { + pubkey: self, + name: "Pi", + owner: alice, + publish: vi.fn(async (template: (typeof published)[number]) => { + published.push(template); + return { ...message(String(published.length), template.content) }; + }), + query: vi.fn(async () => [] as RelayEvent[]), + upload: vi.fn(), + media: vi.fn(), + remember: vi.fn(), + attention: { + enabled: () => true, + classifier: () => "unavailable" as const, + show: vi.fn(), + interests: vi.fn(() => ["ops"]), + watches: vi.fn(() => []), + write: vi.fn(), + }, + }; + const deliver = ( + trigger: Delivery["trigger"], + extra: Partial> = {}, + ) => { + const receipt = { + workingUntil: vi.fn(), + ignored: vi.fn(), + step: vi.fn(), + acknowledged: vi.fn(), + superseded: vi.fn(), + }; + const run = runtime.run({ + trigger, + channelId: channel, + agent, + config: DEFAULT_CONFIG, + signal: new AbortController().signal, + ...receipt, + ...extra, + } as unknown as Delivery); + return { run, ...receipt }; + }; + /** What the turn `workingUntil` was given settled with. */ + const settled = (receipt: { + workingUntil: { mock: { calls: unknown[][] } }; + }) => + receipt.workingUntil.mock.calls[0]?.[0] as Promise<{ + ok: boolean; + error?: string; + }>; + const runOnce = ( + key: string, + text: string | undefined, + signal = new AbortController().signal, + ) => + runtime.runOnce({ + delivery: { agent, config: DEFAULT_CONFIG }, + key, + signal, + prompt: async () => text, + } as unknown as StandaloneTurn); + return { + runtime, + host, + deliver, + runOnce, + settled, + published, + agent, + session, + solo, + }; +} + +it("hands an owner's mention to its thread's Pi conversation", async () => { + const { host, deliver, published } = setup(); + const mention = reply("2", "@Pi can you look?"); + const turn = deliver( + { type: "mention", event: mention }, + { model: "claude-x", effort: "high" }, + ); + await turn.run; + const [open] = host.requests("open"); + const tools = ((open?.params.tools ?? []) as { name: string }[]).map( + (tool) => tool.name, + ); + expect(tools).toEqual(expect.arrayContaining(["send", "read"])); + const [delivered] = host.requests("deliver"); + expect(delivered?.params).toMatchObject({ + agent: self, + key: `${channel}/${root.id}`, + at: 110, + context: { channel, root: root.id }, + request: mention.id, + provider: "", + model: "", + // The owner's pick, apart, so a steer changes only what it names. + pick: { model: "claude-x", thinking: "high" }, + cwd: "~/.buzz", + }); + const text = String(delivered?.params.text); + expect(text).toContain(`Thread root: ${root.id}`); + expect(text).toContain(`Alice (${alice})`); + expect(text).toContain("Build is red"); + expect(text).toContain("Content: @Pi can you look?"); + expect(String(delivered?.params.steer)).toContain("@Pi can you look?"); + expect(String(delivered?.params.instructions)).toContain(self); + expect(turn.workingUntil).toHaveBeenCalledTimes(1); + expect(published).toEqual([]); +}); + +it("shows a conversation only the messages it was not shown, nor its own replies", async () => { + const { host, deliver, session } = setup({ place: () => ({ seen: 105 }) }); + const later = (id: string, content: string, pubkey: string, at: number) => ({ + ...reply(id, content, at), + pubkey, + }); + session.read.mockResolvedValue([ + root, + later("5", "Earlier from Pi", self, 107), + later("6", "Bob checked it", bob, 108), + ]); + await deliver({ type: "mention", event: reply("2", "@Pi look") }).run; + const text = String(host.requests("deliver")[0]?.params.text); + expect(text).toContain("Bob checked it"); + expect(text).not.toContain("Build is red"); + expect(text).not.toContain("Earlier from Pi"); +}); + +it("ignores a mention from someone who is not the owner", async () => { + const { host, deliver } = setup(); + const turn = deliver({ + type: "mention", + event: { ...reply("2", "@Pi rm -rf"), pubkey: bob }, + }); + await turn.run; + expect(turn.ignored).toHaveBeenCalled(); + expect(host.processes).toEqual([]); +}); + +it("shows a conversation's activity on the request Pi took last", async () => { + let entries = 0; + const { host, deliver } = setup({ + // Pi says it took each request before it answers. + deliver: (params, process) => { + process.send({ + method: "event", + params: { + agent: self, + conversation: CONVERSATION, + type: "placed", + request: params.request, + entry: ++entries, + }, + }); + return { submission: 1, conversation: CONVERSATION }; + }, + }); + const first = deliver({ type: "mention", event: reply("2", "@Pi look") }); + await first.run; + await vi.waitFor(() => expect(first.acknowledged).toHaveBeenCalled()); + const second = deliver({ type: "mention", event: reply("3", "@Pi also") }); + await vi.waitFor(() => expect(second.acknowledged).toHaveBeenCalled()); + expect(first.superseded).toHaveBeenCalled(); + host.processes[0]?.send({ + method: "event", + params: { + agent: self, + conversation: CONVERSATION, + type: "step", + tool: "bash", + target: "npm test", + }, + }); + await vi.waitFor(() => + expect(second.step).toHaveBeenCalledWith("bash", "npm test"), + ); + expect(first.step).not.toHaveBeenCalled(); +}); + +it("says which conversations a request works in, and lists the agent's places", async () => { + const answered = deferred(); + const { runtime, deliver, settled } = setup({ + deliver: (params, process) => { + process.send({ + method: "event", + params: { + agent: self, + conversation: CONVERSATION, + type: "placed", + request: params.request, + entry: 1, + }, + }); + return { submission: 1, conversation: CONVERSATION }; + }, + wait: () => answered.promise, + places: () => ({ + places: [ + { key: `${channel}/${root.id}`, conversation: CONVERSATION, seen: 110 }, + { key: "dm-1", conversation: 8, seen: 120 }, + ], + }), + }); + const changed = vi.fn(); + runtime.subscribe(changed); + expect(runtime.working(self)).toEqual([]); + const turn = deliver({ type: "mention", event: reply("2", "@Pi look") }); + await turn.run; + await vi.waitFor(() => expect(runtime.working(self)).toEqual([CONVERSATION])); + expect(changed).toHaveBeenCalled(); + // One view until it changes, as useSyncExternalStore needs. + expect(runtime.working(self)).toBe(runtime.working(self)); + answered.resolve({ status: "done", answer: 1, text: "" }); + await settled(turn); + expect(runtime.working(self)).toEqual([]); + + expect(await runtime.places(self)).toEqual([ + { key: "dm-1", conversation: 8, channelId: "dm-1", at: 120_000 }, + { + key: `${channel}/${root.id}`, + conversation: CONVERSATION, + channelId: channel, + root: root.id, + name: "general", + at: 110_000, + }, + ]); +}); + +it("posts the answer of a turn that did not send, once for every request it answered", async () => { + const answered = deferred(); + const { deliver, settled, published } = setup({ + wait: async () => { + await answered.promise; + return { status: "done", answer: 5, text: "The build is green." }; + }, + }); + const first = deliver({ type: "mention", event: reply("2", "@Pi look") }); + await first.run; + const second = deliver({ type: "mention", event: reply("3", "@Pi also") }); + await vi.waitFor(() => expect(second.workingUntil).toHaveBeenCalled()); + answered.resolve(); + await expect(settled(first)).resolves.toEqual({ ok: true }); + await expect(settled(second)).resolves.toEqual({ ok: true }); + expect(published).toEqual([ + { + kind: 9, + content: "The build is green.", + tags: [ + ["h", channel], + ["e", root.id, "", "reply"], + ], + }, + ]); +}); + +it("posts a run's answer for a request it took after a send", async () => { + const answered = deferred(); + const { host, deliver, settled, published } = setup({ + wait: async () => { + await answered.promise; + return { status: "done", answer: 5, text: "The build is green." }; + }, + }); + const first = deliver({ type: "mention", event: reply("2", "@Pi look") }); + await first.run; + const steer = reply("3", "@Pi also"); + const second = deliver({ type: "mention", event: steer }); + // Its receipt is known once it is handed over. + await vi.waitFor(() => expect(host.requests("deliver")).toHaveLength(2)); + // The run sends for the first request, then takes the second. + await host.processes[0]?.tool({ + agent: self, + conversation: CONVERSATION, + context: { channel, root: root.id }, + name: "send", + arguments: { text: "On it", audience: "everyone" }, + }); + host.processes[0]?.send({ + method: "event", + params: { + agent: self, + conversation: CONVERSATION, + type: "placed", + request: steer.id, + entry: 3, + }, + }); + await vi.waitFor(() => expect(second.acknowledged).toHaveBeenCalled()); + answered.resolve(); + await expect(settled(first)).resolves.toEqual({ ok: true }); + await expect(settled(second)).resolves.toEqual({ ok: true }); + expect(published.map((event) => event.content)).toEqual([ + "On it", + "The build is green.", + ]); +}); + +it("posts a run's answer where the latest request it took is, not one placed after it", async () => { + const answered = deferred(); + let submissions = 0; + const { deliver, settled, published } = setup({ + // Requests are placed at entries 10, 20 and 30; the run that took the + // first two answers at 25. + deliver: (params, process) => { + const submission = ++submissions; + process.send({ + method: "event", + params: { + agent: self, + conversation: CONVERSATION, + type: "placed", + request: params.request, + entry: submission * 10, + }, + }); + return { submission, conversation: CONVERSATION }; + }, + wait: async ({ submission }) => { + if (submission === 3) return new Promise(() => {}); + await answered.promise; + return { status: "done", answer: 25, text: "Both done." }; + }, + }); + // One conversation for the channel, each message its own thread. + const config = { ...DEFAULT_CONFIG, scope: "channel" as const }; + const top = (id: string, created_at: number) => + message(id, "@Pi", { created_at, tags: [["h", channel]] }); + const latest = top("3", 120); + const first = deliver({ type: "mention", event: top("2", 110) }, { config }); + const second = deliver({ type: "mention", event: latest }, { config }); + const third = deliver({ type: "mention", event: top("4", 130) }, { config }); + await vi.waitFor(() => expect(third.acknowledged).toHaveBeenCalled()); + answered.resolve(); + await expect(settled(first)).resolves.toEqual({ ok: true }); + await expect(settled(second)).resolves.toEqual({ ok: true }); + expect(published).toEqual([ + { + kind: 9, + content: "Both done.", + tags: [ + ["h", channel], + ["e", latest.id, "", "reply"], + ], + }, + ]); +}); + +it("runs Pi's Buzz tools in the conversation's thread, and posts nothing more after a send", async () => { + const answered = deferred(); + const { host, deliver, settled, published } = setup({ + wait: async () => { + await answered.promise; + return { status: "done", answer: 5, text: "Posted it." }; + }, + }); + const turn = deliver({ type: "mention", event: reply("2", "@Pi look") }); + await turn.run; + const sent = await host.processes[0]?.tool({ + agent: self, + conversation: CONVERSATION, + context: { channel, root: root.id }, + name: "send", + arguments: { text: "On it", audience: "everyone", final: true }, + }); + // A final send ends Pi's run. + expect(sent?.result).toMatchObject({ final: true }); + expect(sent?.result).not.toMatchObject({ isError: true }); + answered.resolve(); + await expect(settled(turn)).resolves.toEqual({ ok: true }); + expect(published).toHaveLength(1); + expect(published[0]).toMatchObject({ kind: 9, content: "On it" }); + expect(published[0]?.tags).toContainEqual(["e", root.id, "", "reply"]); +}); + +it("lists the tools plugins add, and runs them for Pi", async () => { + const lookup = vi.fn(async (_client, _context, args) => { + if (args.q === "bad") throw new Error("No such thing"); + return `found ${String(args.q)}`; + }); + const { host, deliver } = setup({}, [ + { + name: "lookup", + description: "Look it up", + inputSchema: { type: "object" }, + call: lookup, + }, + ]); + await deliver({ type: "mention", event: reply("2", "@Pi look") }).run; + const listed = host.requests("open")[0]?.params.tools as { name: string }[]; + expect(listed.at(-1)?.name).toBe("lookup"); + const call = async (q: string) => + ( + await host.processes[0]?.tool({ + agent: self, + conversation: CONVERSATION, + context: { channel, root: root.id }, + name: "lookup", + arguments: { q }, + }) + )?.result; + await expect(call("logs")).resolves.toEqual({ + content: [{ type: "text", text: "found logs" }], + }); + expect(lookup).toHaveBeenCalledWith( + expect.anything(), + { channel, root: root.id }, + { q: "logs" }, + ); + await expect(call("bad")).resolves.toEqual({ + content: [{ type: "text", text: "No such thing" }], + isError: true, + }); +}); + +it("reports a turn Pi left unanswered to the owner, in its thread", async () => { + const { deliver, settled, published } = setup({ + wait: () => ({ + status: "unanswered", + reason: "model_error", + detail: "401 invalid x-api-key", + }), + }); + const turn = deliver({ type: "mention", event: reply("2", "@Pi look") }); + await turn.run; + await expect(settled(turn)).resolves.toEqual({ + ok: false, + error: "401 invalid x-api-key", + }); + await vi.waitFor(() => expect(published).toHaveLength(1)); + expect(published[0]).toEqual({ + kind: 9, + content: "⚠️ I couldn't finish that: 401 invalid x-api-key", + tags: [ + ["h", channel], + ["e", root.id, "", "reply"], + ["p", alice], + ], + }); +}); + +it("continues an owner's Solo chat at the top level, answers and failures alike", async () => { + const { host, deliver, settled, published, solo } = setup({ + wait: ({ submission }) => + submission === 1 + ? { status: "done", answer: 5, text: "Here it is." } + : { status: "unanswered", reason: "model_error", detail: "Overloaded" }, + }); + solo.add("solo-1"); + const me = (id: string) => message(id, "@Pi", { tags: [["h", "solo-1"]] }); + const answered = deliver( + { type: "mention", event: me("2") }, + { channelId: "solo-1" }, + ); + await answered.run; + await expect(settled(answered)).resolves.toEqual({ ok: true }); + expect(host.requests("deliver")[0]?.params).toMatchObject({ + key: "solo-1", + context: { channel: "solo-1", topLevel: true }, + }); + const failed = deliver( + { type: "mention", event: me("3") }, + { channelId: "solo-1" }, + ); + await vi.waitFor(() => expect(failed.workingUntil).toHaveBeenCalled()); + await expect(settled(failed)).resolves.toMatchObject({ ok: false }); + await vi.waitFor(() => expect(published).toHaveLength(2)); + expect(published.map((event) => event.tags)).toEqual([ + [["h", "solo-1"]], + [ + ["h", "solo-1"], + ["p", alice], + ], + ]); +}); + +it("waits for a turn again in a new host when the host stops", async () => { + let hosts = 0; + const { host, deliver, settled, published } = setup({ + // The first host stops before it answers; the next one, which continues + // the run from Pi's storage, does. + wait: (_params, process) => { + if (++hosts === 1) { + queueMicrotask(() => process.exit(1, "Killed\n")); + return new Promise(() => {}); + } + return { status: "done", answer: 5, text: "Resumed." }; + }, + }); + const turn = deliver({ type: "mention", event: reply("2", "@Pi look") }); + await turn.run; + await expect(settled(turn)).resolves.toEqual({ ok: true }); + expect(host.processes).toHaveLength(2); + expect(host.processes[1]?.requests.map((request) => request.method)).toEqual([ + "open", + "wait", + ]); + expect(published.map((item) => item.content)).toEqual(["Resumed."]); +}); + +it("leaves a removed agent's turn in Pi's storage without a report", async () => { + const closing = deferred(); + const { runtime, host, deliver, settled, published } = setup({ + // Closed, the agent is not open for the wait. + wait: async () => { + await closing.promise; + throw new Error("The agent is not open"); + }, + close: () => closing.resolve(), + }); + const turn = deliver({ type: "mention", event: reply("2", "@Pi look") }); + await turn.run; + await vi.waitFor(() => expect(host.requests("wait")).toHaveLength(1)); + runtime.sync([]); + await expect(settled(turn)).resolves.toEqual({ ok: true }); + expect(host.requests("close")[0]?.params).toEqual({ agent: self }); + expect(published).toEqual([]); +}); + +it("runs a standalone turn in a one-off conversation", async () => { + const { host, runOnce, published } = setup({ + wait: () => ({ status: "done", answer: 5, text: "Nothing to report." }), + }); + await expect(runOnce("timer/t1", "Run t1")).resolves.toEqual({ ok: true }); + const [once] = host.requests("once"); + expect(String(once?.params.request)).toMatch(/^timer\/t1\//); + expect(once?.params.text).toBe("Run t1"); + expect(host.requests("deliver")).toEqual([]); + // A wake's answer is for Pi alone; it acts with its tools. + expect(published).toEqual([]); +}); + +it("starts no standalone turn once its prompt is no longer wanted", async () => { + const { host, runOnce } = setup(); + await expect(runOnce("watch/w1", undefined)).resolves.toEqual({ ok: true }); + expect(host.requests("once")).toEqual([]); +}); + +it("stops a standalone turn's conversation when its signal aborts", async () => { + const waiting = deferred(); + const { host, runOnce } = setup({ + wait: () => waiting.promise, + abort: () => { + waiting.resolve({ status: "unanswered", reason: "aborted" }); + return {}; + }, + }); + const stop = new AbortController(); + const turn = runOnce("timer/t1", "Run t1", stop.signal); + await vi.waitFor(() => expect(host.requests("wait")).toHaveLength(1)); + stop.abort(); + await expect(turn).resolves.toEqual({ + ok: false, + error: "The turn was stopped", + }); + expect(host.requests("abort")[0]?.params).toEqual({ + agent: self, + conversation: 99, + }); +}); + +it("offers the provider's models with the thinking levels each supports", async () => { + let defaults = { provider: "anthropic", model: "claude-x", thinking: "high" }; + const { runtime } = setup({ + defaults: () => defaults, + models: () => [ + { id: "claude-x", name: "Claude X", thinking: ["off", "low", "high"] }, + { id: "claude-y", name: "Claude Y", thinking: ["off"] }, + ], + }); + const agent = { pubkey: self, config: DEFAULT_CONFIG } as Agent; + await expect(runtime.models(agent)).resolves.toEqual({ + current: "claude-x", + models: [ + { + id: "claude-x", + label: "Claude X", + efforts: [ + { id: "off", label: "Off" }, + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + ], + effort: "high", + }, + { + id: "claude-y", + label: "Claude Y", + efforts: [{ id: "off", label: "Off" }], + effort: "off", + }, + ], + }); + // The agent's saved level is Pi's, or the nearest a model has. + const saved = { + pubkey: self, + config: { ...DEFAULT_CONFIG, effort: "medium" }, + } as Agent; + const efforts = (await runtime.models(saved)).models.map( + (model) => model.effort, + ); + expect(efforts).toEqual(["high", "off"]); + // Pi's settings as they are now: the pi command changes them. + defaults = { provider: "anthropic", model: "claude-y", thinking: "low" }; + await expect(runtime.models(agent)).resolves.toMatchObject({ + current: "claude-y", + models: [{ effort: "low" }, { effort: "off" }], + }); +}); + +it("runs a turn at the agent's saved thinking level, apart from a pick", async () => { + const { host, deliver } = setup(); + await deliver( + { type: "mention", event: reply("2", "@Pi look") }, + { config: { ...DEFAULT_CONFIG, effort: "low" } }, + ).run; + expect(host.requests("deliver")[0]?.params).toMatchObject({ + thinking: "low", + pick: {}, + }); +}); diff --git a/src/bundled/pi/runtime.ts b/src/bundled/pi/runtime.ts new file mode 100644 index 0000000000..5074b49dcf --- /dev/null +++ b/src/bundled/pi/runtime.ts @@ -0,0 +1,796 @@ +// Turns Agents deliveries into Pi turns. Every Pi agent here runs in the one +// Pi host (pi.ts), each with its own Pi durable storage, where each Buzz +// conversation is one Pi conversation. A delivery becomes a prompt in the +// harness format, submitted to its conversation; one that arrives while Pi +// works steers the run. A watch or timer runs as a standalone turn +// (`runOnce`), in a one-off conversation. Pi acts in Buzz with the in-process +// Buzz tools, signed by the agent's native-held key. The runtime reports a +// turn that fails, and posts the answer of a mention's turn that never used +// `send`. +import { appClient } from "../../buzz-mcp/app-client"; +import type { Context } from "../../buzz-mcp/client"; +import { content, errorText } from "../../buzz-mcp/rpc"; +import { + type AddedTool, + callSessionTool, + sessionTools, + turnNote, +} from "../../buzz-mcp/session-tools"; +import { + MemoryReads, + READ_TIMEOUT_MS, +} from "../../features/agent-harness/core-memory"; +import { fromOwner, workflowOf } from "../../features/agents/owner-attestation"; +import type { AgentReads } from "../../features/agents/reads"; +import type { + Agent, + AgentHandle, + Agents, + Delivery, + Models, + StandaloneTurn, + TurnResult, +} from "../../features/agents/service"; +import type { Host } from "../../features/host/service"; +import type { EventData } from "../../features/relay/events"; +import { threadReference } from "../../features/relay/thread-reference"; +import { + CONTEXT_LIMIT, + type Scope, + steerPrompt, + systemPrompt, + turnPrompt, +} from "../claude-code/prompt"; +import { effortLabel } from "../claude-code/runtime"; +import { + Pi, + type PiEvent, + PiStopped, + type ToolCall, + type ToolResult, +} from "./pi"; +import { DEFAULT_THINKING, PiSetup, runsAt } from "./setup"; +import { readTranscript } from "./transcript"; + +export type Config = Readonly<{ + /** A Pi provider; empty uses the one Pi's settings name. */ + provider: string; + /** One of the provider's models; empty uses the one Pi's settings name. */ + model: string; + /** A thinking level the model has; empty uses the one Pi's settings name. */ + effort: string; + instructions: string; + /** The agent's working directory, absolute or `~/…`. */ + workspace: string; + /** Whether each thread is its own conversation, or the whole channel is one. */ + scope: Scope; +}>; +export const DEFAULT_CONFIG: Config = Object.freeze({ + provider: "", + model: "", + effort: "", + instructions: "", + workspace: "~/.buzz", + scope: "thread", +}); +export const config = (value: unknown): Config => ({ + ...DEFAULT_CONFIG, + ...(value && typeof value === "object" ? (value as Partial) : {}), +}); + +const NAMES_TIMEOUT_MS = 2_000; +const CHAT = [9, 40002]; + +/** Where a mention's turn replies: its thread, or the top of a Solo chat. */ +type Where = Readonly<{ channel: string; root: string; topLevel: boolean }>; +const replyTags = ({ channel, root, topLevel }: Where) => [ + ["h", channel], + ...(topLevel ? [] : [["e", root, "", "reply"]]), +]; +/** A Buzz thread or channel the agent has a Pi conversation for. */ +export type Place = Readonly<{ + key: string; + conversation: number; + channelId: string; + root?: string; + name?: string; + /** When the newest message it was shown was sent. */ + at: number; +}>; +/** How a request settled in Pi: answered, unanswered and why, or not yet, + * because the agent was closed and its run waits to continue. */ +type Waited = + | { status: "done"; answer: number; text: string } + | { status: "unanswered"; reason?: string; detail?: string } + | { status: "closed" }; +type Receipt = Pick & { + /** Where its activity shows: a thread, or a Solo chat's channel. */ + scope: string; + /** Where a run's answer for it is posted. */ + where: Where; + /** Where Pi placed it in a run: its conversation, its entry there, and the + * conversation's `send` calls then. */ + placed?: Readonly<{ conversation: number; entry: number; sends: number }>; +}; + +type Entry = { + config: Config; + /** The team sections of the latest run. */ + team: string; + name?: string; + /** The agent's handle from its latest delivery, for the tools to act with. */ + handle?: AgentHandle; + /** Each conversation's latest posting policy, as in Claude: when its turns + * were handed over, and when all of them settled. */ + policies: Map< + string, + { topLevel: boolean; handed: Promise; settled: Promise } + >; + /** Mentions handed to Pi and not settled, by request. */ + receipts: Map; + /** The request each conversation's activity shows, by its scope. */ + active: Map>; + /** `send` calls that succeeded in each conversation. */ + sends: Map; + /** The answer each conversation last posted for a turn without `send`. */ + posted: Map; + /** The agent was removed: its turns wait in its storage. */ + closed: boolean; +}; + +export class PiRuntime { + private readonly agents = new Map(); + private readonly memoryReads = new MemoryReads(); + private readonly listeners = new Set<() => void>(); + /** Each agent's working conversations, as last read, for a stable view. */ + private readonly views = new Map(); + + /** The Pi host every agent of this type runs in, started when needed. */ + private readonly pi: Pi; + readonly setup: PiSetup; + + constructor( + private readonly host: Pick, + /** What each agent's turns read of its community, while it can be read. */ + private readonly reads: (pubkey: string) => AgentReads | undefined, + /** The agent's `core` memory as the host keeps it (`Agents.core`). */ + private readonly core: Agents["core"], + /** The tools plugins add to every conversation now. */ + private readonly added: () => readonly AddedTool[] = () => [], + ) { + this.pi = new Pi(() => this.spawn, { + tool: (call) => this.tool(call), + event: (event) => this.event(event), + }); + this.setup = new PiSetup(this.pi); + } + + /** Brings the runtime in line with this type's agents. A removed one's + * storage closes; its unfinished runs continue when it is back. */ + sync(agents: readonly Agent[]) { + const current = new Set(agents.map((agent) => agent.pubkey)); + for (const [pubkey, entry] of this.agents) + if (!current.has(pubkey)) { + entry.closed = true; + this.agents.delete(pubkey); + void this.pi.request("close", { agent: pubkey }).catch(() => undefined); + } + for (const agent of agents) { + const entry = this.agents.get(agent.pubkey); + if (!entry) continue; + entry.config = config(agent.config); + entry.name = agent.name; + } + } + + /** The models of the agent's provider, with the thinking levels each has. + * Without a pick, a turn runs the configured model and level. */ + async models(agent: Agent): Promise { + const { provider, model, effort } = config(agent.config); + const defaults = await this.setup.defaults(); + const id = provider || defaults.provider; + if (!id) return { current: "", models: [] }; + const listed = await this.setup.models(id); + const level = effort || defaults.thinking || DEFAULT_THINKING; + return { + current: + model || (id === defaults.provider ? (defaults.model ?? "") : ""), + models: listed.map((item) => ({ + id: item.id, + label: item.label, + efforts: item.thinking.map((id) => ({ id, label: effortLabel(id) })), + effort: runsAt(item.thinking, level), + })), + }; + } + + /** The agent's Buzz conversations, most recently active first. */ + async places(pubkey: string): Promise { + const { places } = await this.pi.request<{ + places: { key: string; conversation: number; seen: number }[]; + }>("places", { agent: pubkey }); + return places + .map(({ key, conversation, seen }) => { + const [channelId = key, root] = key.split("/"); + const name = this.reads(pubkey)?.channelName(channelId); + return { + key, + conversation, + channelId, + ...(root ? { root } : {}), + ...(name ? { name } : {}), + at: seen * 1000, + }; + }) + .sort((a, b) => b.at - a.at); + } + /** The conversations a Buzz request of the agent's is working in. */ + working(pubkey: string) { + let view = this.views.get(pubkey); + if (!view) { + view = [...(this.agents.get(pubkey)?.active.keys() ?? [])]; + this.views.set(pubkey, view); + } + return view; + } + transcript( + pubkey: string, + place: Place, + working: boolean, + signal: AbortSignal, + ) { + return readTranscript(this.pi, pubkey, place, working, signal); + } + subscribe(listener: () => void) { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + private notify() { + this.views.clear(); + for (const listener of this.listeners) listener(); + } + + dispose() { + for (const entry of this.agents.values()) entry.closed = true; + this.agents.clear(); + void this.pi.dispose(); + } + + async run({ + trigger, + agent, + config: raw, + channelId, + model, + effort, + team, + workingUntil, + ignored, + signal, + step, + acknowledged, + superseded, + }: Delivery) { + // Watches and timers come as standalone turns (`runOnce`). + if (trigger.type !== "mention") return ignored?.(); + // Pi runs commands as you; mentions need owner or delegated authority. + if ( + !(await fromOwner( + trigger.event, + agent.owner, + workflowOf(this.reads(agent.pubkey), agent.pubkey), + )) + ) + return ignored?.(); + signal.throwIfAborted(); + const entry = this.entry(agent, config(raw)); + entry.handle = agent; + entry.team = team ?? ""; + const { event } = trigger; + const channel = channelId ?? event.tags.find((tag) => tag[0] === "h")?.[1]; + if (!channel) return; + const topLevel = + (await this.reads(agent.pubkey)?.soloChat( + agent.owner, + channel, + signal, + )) ?? false; + // Frozen with the policy: a scope change while this waits must not move + // its prompt to another conversation than the one it is delivered to. + const key = await this.key(entry, agent.pubkey, event, channel, topLevel); + const root = threadReference(event)?.rootId ?? event.id; + const where = { channel, root, topLevel }; + // A turn keeps the posting policy it was admitted with: a request under + // the other policy waits for the conversation's turns to settle rather + // than steering into them and changing their tools' context. + const prior = entry.policies.get(key); + const same = prior?.topLevel === topLevel; + const wait = prior && (same ? prior.handed : prior.settled); + const request = event.id; + let submitted!: { submission: number; conversation: number }; + let sends = 0; + const handed = (async () => { + await wait; + await this.open(agent.pubkey); + const { seen } = await this.pi.request<{ seen: number }>("place", { + agent: agent.pubkey, + key, + }); + const text = this.review( + entry, + turnPrompt({ + ...(await this.turn( + agent.pubkey, + event, + channel, + key, + topLevel, + seen, + )), + agent: agent.pubkey, + label: "@mention", + }), + ); + // A conversation's tools default to its thread; a conversation shared + // by several threads is told which to reply in instead. + const context: Context = topLevel + ? { channel, topLevel } + : key === channel + ? { channel } + : { channel, root }; + // Known before Pi has it, as it can say it joined a run first. + entry.receipts.set(request, { + scope: topLevel ? channel : root, + where, + ...(step ? { step } : {}), + ...(acknowledged ? { acknowledged } : {}), + ...(superseded ? { superseded } : {}), + }); + submitted = await this.pi.request("deliver", { + agent: agent.pubkey, + key, + at: event.created_at, + context, + text, + steer: steerPrompt(text), + request, + ...(await this.turnOf(entry, agent.pubkey, model, effort)), + }); + sends = entry.sends.get(submitted.conversation) ?? 0; + })(); + const settled = handed + .then(async (): Promise => { + const waited = await this.settle( + entry, + agent.pubkey, + submitted.submission, + ); + if (waited.status === "done") { + await this.answer( + entry, + agent, + submitted.conversation, + { where, sends }, + waited, + ); + return { ok: true }; + } + return waited.status === "closed" + ? { ok: true } + : { ok: false, error: unanswered(waited) }; + }) + .catch( + (error: unknown): TurnResult => ({ + ok: false, + error: errorText(error), + }), + ) + .then((result) => { + this.forget(entry, request); + // A removed agent's turn continues when it is back. + return entry.closed ? { ok: true } : result; + }); + const policy = { + topLevel, + handed: handed.catch(() => undefined), + settled: Promise.all([same ? prior?.settled : undefined, settled]).then( + () => undefined, + ), + }; + entry.policies.set(key, policy); + void policy.settled.then(() => { + if (entry.policies.get(key) === policy) entry.policies.delete(key); + }); + // Deliveries keep their order: a later one waits on this handover, and + // one with nothing to wait for is handed over before `run` returns. + if (!wait) await policy.handed; + // The turn outlives `run`, which the host is told so it shows it as working. + workingUntil(settled); + void settled.then(async (result) => { + if (!result.ok) await this.report(agent, where, result.error ?? ""); + }); + } + + /** Runs a standalone turn, such as a watch's or a timer's, in a one-off + * Pi conversation, stopped when `signal` aborts. */ + async runOnce({ + delivery, + key, + signal, + prompt, + }: StandaloneTurn): Promise { + const { agent, team } = delivery; + const entry = this.entry(agent, config(delivery.config)); + entry.handle = agent; + entry.team = team ?? ""; + let stop: (() => void) | undefined; + try { + await this.open(agent.pubkey); + signal.throwIfAborted(); + const text = await prompt(); + if (text === undefined) return { ok: true }; + const turn = await this.turnOf( + entry, + agent.pubkey, + delivery.model, + delivery.effort, + ); + signal.throwIfAborted(); + const { submission, conversation } = await this.pi.request<{ + submission: number; + conversation: number; + }>("once", { + agent: agent.pubkey, + text, + request: `${key}/${crypto.randomUUID()}`, + ...turn, + }); + // A removed agent's storage is closed, and stays so. + stop = () => { + if (!entry.closed) + void this.pi + .request("abort", { agent: agent.pubkey, conversation }) + .catch(() => undefined); + }; + signal.addEventListener("abort", stop, { once: true }); + if (signal.aborted) stop(); + const waited = await this.settle(entry, agent.pubkey, submission); + return waited.status === "unanswered" + ? { ok: false, error: unanswered(waited) } + : { ok: true }; + } catch (error) { + return { ok: false, error: errorText(error) }; + } finally { + if (stop) signal.removeEventListener("abort", stop); + } + } + + /** Runs a Buzz tool for Pi, for the conversation that called it. */ + async tool(call: ToolCall): Promise { + const entry = this.agents.get(call.agent); + const client = + entry?.handle && + appClient(entry.handle, { + spawn: this.spawn, + cwd: entry.config.workspace, + memories: (slug) => this.memories(call.agent, slug), + remembered: () => this.forgetMemory(call.agent), + }); + try { + const reply = await callSessionTool( + client, + call.context, + call.name, + call.arguments, + this.added(), + ); + const sent = entry && call.name === "send"; + if (sent) + entry.sends.set( + call.conversation, + (entry.sends.get(call.conversation) ?? 0) + 1, + ); + return { + content: content(reply), + ...(sent && call.arguments.final === true ? { final: true } : {}), + }; + } catch (error) { + return { + content: [{ type: "text", text: errorText(error) }], + isError: true, + }; + } + } + + /** What Pi says of its conversations: a request joining a run takes that + * conversation's activity from the one before it, and steps go to it. */ + event(event: PiEvent) { + const entry = this.agents.get(event.agent); + if (!entry) return; + let active = entry.active.get(event.conversation); + if (event.type === "placed") { + const receipt = entry.receipts.get(event.request); + if (!receipt) return; + active ??= new Map(); + entry.active.set(event.conversation, active); + const before = active.get(receipt.scope); + if (before !== receipt) before?.superseded?.(); + active.set(receipt.scope, receipt); + receipt.placed ??= { + conversation: event.conversation, + entry: event.entry, + sends: entry.sends.get(event.conversation) ?? 0, + }; + receipt.acknowledged?.(); + this.notify(); + } else + for (const receipt of active?.values() ?? []) + receipt.step?.(event.tool, event.target); + } + + private forget(entry: Entry, request: string) { + const receipt = entry.receipts.get(request); + entry.receipts.delete(request); + for (const [conversation, active] of entry.active) + for (const [scope, shown] of active) + if (shown === receipt) { + active.delete(scope); + if (!active.size) entry.active.delete(conversation); + } + this.notify(); + } + + /** Opens the agent's storage in the host, with its Buzz tools as they are + * now. The host does nothing for one already open with these tools. */ + private async open(pubkey: string) { + await this.pi.request("open", { + agent: pubkey, + tools: sessionTools(this.added()), + }); + } + + /** How a request settles. A host that stopped is started again once: its + * run continues there, from Pi's storage. */ + private async settle(entry: Entry, pubkey: string, submission: number) { + for (let attempt = 0; ; attempt++) + try { + if (attempt) await this.open(pubkey); + return await this.pi.request("wait", { + agent: pubkey, + submission, + }); + } catch (error) { + if (attempt || !(error instanceof PiStopped) || entry.closed) + throw error; + } + } + + /** What a turn runs with: the agent's model and thinking level or the + * owner's pick, its prompt with fresh `core` memory, and its workspace. */ + private async turnOf( + entry: Entry, + pubkey: string, + model?: string, + effort?: string, + ) { + const { workspace, instructions, scope } = entry.config; + const memory = await this.core(pubkey); + return { + provider: entry.config.provider, + model: entry.config.model, + thinking: entry.config.effort, + // Apart, so a request that steers a working run changes only these. + pick: { model, thinking: effort }, + cwd: workspace, + instructions: systemPrompt({ + scope, + cwd: workspace, + ...(entry.name ? { agent: { name: entry.name, pubkey } } : {}), + instructions, + team: entry.team, + ...(memory === undefined ? {} : { memory }), + }), + }; + } + + /** Posts the answer of a run that never sent anything itself, as goose + * does: once for all the requests it answered, in reply to the latest it + * took. */ + private async answer( + entry: Entry, + agent: AgentHandle, + conversation: number, + own: Readonly<{ where: Where; sends: number }>, + waited: Extract, + ) { + // The latest request Pi placed before the answer, `own` until one is + // known; one placed after it is another run's. Sends after it was placed + // answered it; ones before were for earlier requests. + let latest = { ...own, at: -1 }; + for (const { placed, where } of entry.receipts.values()) + if ( + placed?.conversation === conversation && + placed.entry < waited.answer && + placed.entry > latest.at + ) + latest = { where, sends: placed.sends, at: placed.entry }; + const { where, sends } = latest; + if ( + !waited.text || + (entry.sends.get(conversation) ?? 0) !== sends || + entry.posted.get(conversation) === waited.answer + ) + return; + entry.posted.set(conversation, waited.answer); + await agent.publish({ + kind: 9, + content: waited.text, + tags: replyTags(where), + }); + } + + /** `text` with the Buzz tools' end-of-turn note, when they have one. */ + private review(entry: Entry, text: string) { + const note = turnNote(entry.handle); + return note ? `${text}\n\n${note}` : text; + } + + /** Says in the thread that a turn failed, so nobody waits on it. Tells the + * owner even when a sibling asked, as Claude does: tagging the sibling would + * wake it, and two agents failing alike would wake each other without end. */ + private report(agent: AgentHandle, where: Where, error: string) { + return agent + .publish({ + kind: 9, + content: `⚠️ I couldn't finish that: ${error.slice(0, 500)}`, + tags: [...replyTags(where), ["p", agent.owner]], + }) + .catch((failure) => + console.warn("Pi could not report a failed turn", failure), + ); + } + + /** The conversation the event's turn goes to: a Solo chat or DM is one + * conversation, as is a channel for an agent with channel scope. */ + private async key( + entry: Entry, + self: string, + event: EventData, + channelId: string, + topLevel: boolean, + ) { + const summary = await this.reads(self)?.channel(channelId); + return topLevel || summary?.dm === true || entry.config.scope === "channel" + ? channelId + : `${channelId}/${threadReference(event)?.rootId ?? event.id}`; + } + + /** What the event's prompt needs: its channel and thread, and the messages + * before it that the conversation, shown up to `seen`, has not been shown. */ + private async turn( + self: string, + event: EventData, + channelId: string, + key: string, + topLevel: boolean, + seen: number, + ) { + const reads = this.reads(self); + const summary = await reads?.channel(channelId); + const dm = summary?.dm === true; + const thread = threadReference(event); + const rootId = thread?.rootId ?? event.id; + // The conversation it was admitted to, not the scope as it is now. + const scope: Scope = key === channelId ? "channel" : "thread"; + let earlier: readonly EventData[] = []; + if (reads && (thread || dm || topLevel)) + try { + const events = await reads.read( + thread && !topLevel + ? [ + { ids: [rootId], limit: 1 }, + { kinds: CHAT, "#h": [channelId], "#e": [rootId], limit: 200 }, + ] + : [{ kinds: CHAT, "#h": [channelId], limit: CONTEXT_LIMIT + 1 }], + AbortSignal.timeout(NAMES_TIMEOUT_MS * 2), + ); + earlier = events + .filter( + (item) => + CHAT.includes(item.kind) && + item.id !== event.id && + item.created_at <= event.created_at, + ) + .sort((a, b) => a.created_at - b.created_at); + } catch (error) { + console.warn("Pi could not read the conversation", error); + } + // A conversation already has its own replies; a new one is shown them too. + const unseen = earlier.filter( + (item) => item.created_at > seen && (!seen || item.pubkey !== self), + ); + const pubkeys = [ + ...new Set([ + event.pubkey, + ...unseen.map((item) => item.pubkey), + ...event.tags + .filter((tag) => tag[0] === "p") + .map((tag) => tag[1] ?? ""), + ]), + ].filter(Boolean); + if (reads) + await Promise.race([ + reads.profiles(pubkeys).catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, NAMES_TIMEOUT_MS)), + ]); + const members = summary?.members ?? []; + return { + event, + scope, + topLevel, + channel: { + id: channelId, + ...(summary?.name ? { name: summary.name } : {}), + dm, + }, + ...(thread ? { thread } : {}), + context: unseen, + total: earlier.length + 1, + name: (pubkey: string) => reads?.name(pubkey, members), + }; + } + + private entry(agent: AgentHandle, initial: Config): Entry { + const existing = this.agents.get(agent.pubkey); + if (existing) return existing; + const entry: Entry = { + config: initial, + team: "", + name: agent.name, + policies: new Map(), + receipts: new Map(), + active: new Map(), + sends: new Map(), + posted: new Map(), + closed: false, + }; + this.agents.set(agent.pubkey, entry); + return entry; + } + + private get spawn() { + const spawn = this.host.spawn?.bind(this.host); + if (!spawn) throw new Error("Pi agents run only in the desktop app"); + return spawn; + } + + /** The agent's memory, or only entry `slug`, as its community's reads give + * it. */ + private memories(pubkey: string, slug?: string) { + const reads = this.reads(pubkey); + if (!reads) return Promise.reject(new Error("Buzz is not connected")); + return this.memoryReads.read( + reads, + pubkey, + AbortSignal.timeout(READ_TIMEOUT_MS), + slug, + ); + } + private forgetMemory(pubkey: string) { + const reads = this.reads(pubkey); + if (reads) this.memoryReads.forget(reads.connection, pubkey); + } +} + +/** Why Pi left a request unanswered, in words. */ +function unanswered({ + reason, + detail, +}: Extract) { + if (detail) return detail; + if (reason === "no_model") return "Pi has no model for this agent"; + if (reason === "aborted") return "The turn was stopped"; + return `Pi did not answer (${reason ?? "unknown"})`; +} diff --git a/src/bundled/pi/setup.ts b/src/bundled/pi/setup.ts new file mode 100644 index 0000000000..cb4dec08cb --- /dev/null +++ b/src/bundled/pi/setup.ts @@ -0,0 +1,102 @@ +// Pi's providers, as Pi itself has them: the ones it offers, their models, +// saving a provider's key, and testing that a model answers. Keys go to Pi's +// own `~/.pi/agent/auth.json`, shared with the `pi` command, never into Buzz. +import type { ModelChoice } from "../../features/agents/ModelPicker"; +import type { Pi } from "./pi"; + +export type Provider = Readonly<{ + id: string; + name: string; + /** Takes an API key, saved here. */ + key: boolean; + /** Signs in through the `pi` command rather than with a key. */ + signsIn: boolean; + /** Where Pi finds its credentials, when it has them. */ + configured?: string; +}>; + +export type Providers = Readonly<{ + providers: readonly Provider[]; + /** What Pi's settings pick when an agent names no provider or model. */ + defaults: Readonly<{ provider?: string; model?: string; thinking?: string }>; +}>; + +/** A model, with the thinking levels it supports, lowest first. */ +export type PiModel = ModelChoice & Readonly<{ thinking: readonly string[] }>; + +export type Tested = Readonly<{ ok: boolean; message: string }>; + +/** Pi's own thinking level when its settings name none. */ +export const DEFAULT_THINKING = "medium"; +const LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; +/** The level a model with `levels` runs at when asked for `level`, as Pi's + * `clampThinkingLevel` picks it: that one, else the nearest, higher first. */ +export function runsAt(levels: readonly string[], level: string) { + if (levels.includes(level)) return level; + const at = LEVELS.indexOf(level); + const has = (item: string) => levels.includes(item); + return ( + (at < 0 + ? undefined + : (LEVELS.slice(at).find(has) ?? + LEVELS.slice(0, at).reverse().find(has))) ?? + levels[0] ?? + "off" + ); +} + +export class PiSetup { + private listing: Promise | undefined; + private readonly listed = new Map>(); + + constructor(private readonly pi: Pick) {} + + /** Pi's providers, read once until a key is saved. */ + providers(): Promise { + if (this.listing) return this.listing; + const listing = this.pi.request("providers"); + this.listing = listing; + listing.catch(() => { + if (this.listing === listing) this.listing = undefined; + }); + return listing; + } + + /** What Pi's settings pick now, which the `pi` command changes. */ + defaults(): Promise { + return this.pi.request("defaults"); + } + + /** A provider's models, from Pi's catalog. */ + models(id: string): Promise { + const listed = this.listed.get(id); + if (listed) return listed; + const listing = this.pi + .request<{ id: string; name: string; thinking: string[] }[]>("models", { + provider: id, + }) + .then((models) => + models.map((model) => ({ + id: model.id, + label: model.name || model.id, + thinking: model.thinking, + })), + ); + this.listed.set(id, listing); + listing.catch(() => { + if (this.listed.get(id) === listing) this.listed.delete(id); + }); + return listing; + } + + /** Saves a provider's API key with Pi's own login. */ + async login(id: string, key: string) { + await this.pi.request("login", { provider: id, key }); + this.listing = undefined; + } + + /** Asks the model for one short reply, without tools. */ + test(choice: Readonly<{ provider: string; model: string }>): Promise { + return this.pi.request("test", choice); + } +} diff --git a/src/bundled/pi/tabs.test.tsx b/src/bundled/pi/tabs.test.tsx new file mode 100644 index 0000000000..e64cf16a28 --- /dev/null +++ b/src/bundled/pi/tabs.test.tsx @@ -0,0 +1,456 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { + cleanup, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useState } from "react"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import type { Agent } from "../../features/agents/service"; +import { type Config, DEFAULT_CONFIG, type Place } from "./runtime"; +import { type Provider, type Providers, PiSetup } from "./setup"; +import { createTabs } from "./tabs"; +import { piTurns } from "./transcript"; + +beforeEach(() => { + // jsdom has no layout observers; the model picker sizes itself with one. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); +}); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +const MODELS: Record< + string, + { id: string; name: string; thinking: string[] }[] +> = { + anthropic: [ + { id: "claude-x", name: "Claude X", thinking: ["off", "high"] }, + { id: "claude-o", name: "Claude O", thinking: ["off"] }, + ], + openai: [{ id: "gpt-x", name: "GPT X", thinking: ["off", "low"] }], +}; + +/** The Pi host's answers to setup's requests; `fail` makes one throw. */ +function fakePi( + fail: Partial> = {}, + defaults: Providers["defaults"] = { + provider: "anthropic", + model: "claude-x", + }, +) { + const providers: Provider[] = [ + { + id: "anthropic", + name: "Anthropic", + key: true, + signsIn: true, + configured: "auth.json", + }, + { id: "openai", name: "OpenAI", key: true, signsIn: false }, + { id: "copilot", name: "GitHub Copilot", key: false, signsIn: true }, + ]; + const request = vi.fn( + async (method: string, params: Record = {}) => { + const failure = fail[method]; + if (failure) throw new Error(failure); + if (method === "providers") + return { + providers: providers.map((item) => ({ ...item })), + defaults, + }; + if (method === "models") return MODELS[params.provider ?? ""] ?? []; + if (method === "login") { + const index = providers.findIndex( + (item) => item.id === params.provider, + ); + const found = providers[index]; + if (found) providers[index] = { ...found, configured: "auth.json" }; + return {}; + } + if (method === "test") + return { ok: true, message: `Connected to ${params.model}` }; + throw new Error(`Unexpected ${method}`); + }, + ); + type Request = ConstructorParameters[0]["request"]; + return { request: request as unknown as Request, calls: request }; +} + +/** The runtime's conversations: a thread Pi works in, and a channel. */ +function fakeRuntime() { + const places: Place[] = [ + { + key: "chan/root", + conversation: 1, + channelId: "chan", + root: "root", + name: "general", + at: 2_000, + }, + { key: "other", conversation: 2, channelId: "other", at: 1_000 }, + ]; + const working = [1]; + return { + places: vi.fn(async () => places), + working: () => working, + subscribe: () => () => {}, + transcript: vi.fn(async (_agent: string, place: Place) => ({ + turns: piTurns( + [ + { + id: 1, + kind: "pi.user", + model: [ + { + role: "user", + content: `Hello from ${place.key}`, + timestamp: 1, + }, + ], + }, + { + id: 2, + kind: "pi.assistant", + model: [ + { + role: "assistant", + content: [{ type: "text", text: "Hi there" }], + timestamp: 2, + }, + ], + }, + ], + place, + true, + ), + unknownThread: 0, + more: false, + })), + }; +} + +function created(pi = fakePi()) { + const { Create, CreateModel } = createTabs({ + setup: new PiSetup(pi), + runtime: fakeRuntime(), + }); + const changes: Config[] = []; + function Harness() { + const [config, setConfig] = useState(DEFAULT_CONFIG); + const onChange = (next: Config) => { + changes.push(next); + setConfig(next); + }; + // As New agent shows them: the Model row, then the type's own choices. + return ( + <> + + + + ); + } + render(); + return { pi, changes }; +} + +async function choose(label: string, option: string) { + const user = userEvent.setup(); + await user.click(screen.getByRole("combobox", { name: label })); + await user.click(await screen.findByRole("option", { name: option })); +} + +const modelButton = () => + screen.getByRole("button", { name: /^(Pi default|Choose a|GPT X)/ }); + +/** Opens the provider dialog from the Model field. */ +async function openChoices() { + await userEvent.setup().click(modelButton()); + return screen.findByRole("dialog", { name: "Provider and model" }); +} + +it("starts New agent's Model field on Pi's default, with the provider kept to its dialog", async () => { + created(); + expect(screen.queryByRole("combobox", { name: "Provider" })).toBeNull(); + await waitFor(() => + expect(modelButton()).toHaveTextContent( + "Pi default (Anthropic · Claude X)", + ), + ); + // Pi's mark, then the provider's. + expect(modelButton().querySelectorAll("svg")).toHaveLength(2); + expect(screen.getByLabelText("Instructions Optional")).toBeVisible(); +}); + +it("saves a provider's key with Pi's login, needs a model for it, and tests it", async () => { + const { pi, changes } = created(); + const user = userEvent.setup(); + const dialog = await openChoices(); + await waitFor(() => + expect( + within(dialog).getByRole("combobox", { name: "Provider" }), + ).toHaveTextContent("From Pi settings (Anthropic)"), + ); + expect(within(dialog).getByText("Anthropic is set up in Pi.")).toBeVisible(); + + // Providers Buzz has a mark for show it. + await user.click(within(dialog).getByRole("combobox", { name: "Provider" })); + const anthropic = await screen.findByRole("option", { name: /^Anthropic/ }); + expect(anthropic.querySelector("svg")).not.toBeNull(); + const copilot = screen.getByRole("option", { name: "GitHub Copilot" }); + expect(copilot.querySelector("svg")).toBeNull(); + await user.click(screen.getByRole("option", { name: "OpenAI" })); + const key = await within(dialog).findByLabelText("API key"); + expect(key).toHaveAttribute("type", "password"); + await user.type(key, "sk-test"); + await user.click(within(dialog).getByRole("button", { name: "Save to Pi" })); + await waitFor(() => + expect(within(dialog).getByText("OpenAI is set up in Pi.")).toBeVisible(), + ); + expect(pi.calls).toHaveBeenCalledWith("login", { + provider: "openai", + key: "sk-test", + }); + + // Pi's settings give no model for a provider they do not name. + const use = within(dialog).getByRole("button", { name: "Use" }); + expect(use).toHaveAttribute("aria-disabled", "true"); + expect( + within(dialog).getByText( + "Pi's settings name no model for OpenAI. Choose one.", + ), + ).toBeVisible(); + await choose("Model", "GPT X"); + await user.click( + within(dialog).getByRole("button", { name: "Test connection" }), + ); + expect(await within(dialog).findByRole("status")).toHaveTextContent( + "Connected to gpt-x", + ); + expect(pi.calls).toHaveBeenCalledWith("test", { + provider: "openai", + model: "gpt-x", + }); + // Nothing reaches the agent until the choice is used. + expect(changes).toEqual([]); + await user.click(use); + expect(changes.at(-1)).toMatchObject({ provider: "openai", model: "gpt-x" }); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); + await waitFor(() => expect(modelButton()).toHaveTextContent("GPT X")); + expect(modelButton().querySelectorAll("svg")).toHaveLength(2); +}); + +it("sends a provider that signs in without a key to the pi command", async () => { + created(); + const dialog = await openChoices(); + await waitFor(() => + expect( + within(dialog).getByRole("combobox", { name: "Provider" }), + ).toBeEnabled(), + ); + await choose("Provider", "GitHub Copilot"); + expect( + within(dialog).getByText(/GitHub Copilot signs in through Pi/), + ).toBeVisible(); + expect(within(dialog).queryByLabelText("API key")).toBeNull(); +}); + +it("says why Pi could not start, and tries again", async () => { + const fail: Record = { + providers: + "Pi needs Node.js 22.19 or later. Install it from nodejs.org or with Homebrew, then try again.", + }; + created(fakePi(fail)); + const dialog = await openChoices(); + expect( + await within(dialog).findByText(/Pi needs Node\.js 22\.19 or later/), + ).toBeVisible(); + delete fail.providers; + await userEvent + .setup() + .click(within(dialog).getByRole("button", { name: "Try again" })); + await waitFor(() => + expect( + within(dialog).getByRole("combobox", { name: "Provider" }), + ).toHaveTextContent("From Pi settings (Anthropic)"), + ); + // The card and its dialog read Pi's providers once, so neither still fails. + expect(screen.queryByText(/Pi needs Node\.js/)).toBeNull(); + expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); +}); + +it("says Buzz can't use a default provider that a Pi package adds", async () => { + created(fakePi({}, { provider: "databricks", model: "databricks-x" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "databricks isn't built into Pi, so Buzz can't use it", + ); + // Only Pi's mark: Buzz has none for this provider. + expect(modelButton().querySelectorAll("svg")).toHaveLength(1); + const dialog = await openChoices(); + const provider = within(dialog).getByRole("combobox", { + name: "Provider", + }); + await waitFor(() => expect(provider).toBeEnabled()); + expect(provider).toHaveAccessibleDescription( + /Buzz can use only Pi's built-in providers, and databricks isn't one/, + ); + const use = within(dialog).getByRole("button", { name: "Use" }); + expect(use).toHaveAttribute("aria-disabled", "true"); + expect( + within(dialog).getByRole("button", { name: "Test connection" }), + ).toBeDisabled(); + + await choose("Provider", "Anthropic"); + expect(provider).not.toHaveAccessibleDescription(/built-in providers/); + await choose("Model", "Claude X"); + expect(use).not.toHaveAttribute("aria-disabled", "true"); +}); + +const AGENT = { + pubkey: "c".repeat(64), + name: "pii", + type: "buzz.pi/pi", + owner: "a".repeat(64), + config: DEFAULT_CONFIG, +} as Agent; + +it("keeps Try again's providers for what the dialog's Use allows", async () => { + const fail: Record = { providers: "Pi stopped" }; + created(fakePi(fail, { provider: "databricks", model: "databricks-x" })); + const dialog = await openChoices(); + expect(await within(dialog).findByText("Pi stopped")).toBeVisible(); + delete fail.providers; + await userEvent + .setup() + .click(within(dialog).getByRole("button", { name: "Try again" })); + await waitFor(() => + expect( + within(dialog).getByRole("combobox", { name: "Provider" }), + ).toHaveAccessibleDescription(/databricks isn't one/), + ); + expect(within(dialog).getByRole("button", { name: "Use" })).toHaveAttribute( + "aria-disabled", + "true", + ); +}); + +it("shows the provider, model and thinking level in Identity, and the provider's setup in Settings", async () => { + const { SettingsTab } = createTabs({ + setup: new PiSetup(fakePi()), + runtime: fakeRuntime(), + }); + const save = vi.fn(async () => {}); + // As the agent's profile shows it: one form, a section at a time. + const { rerender } = render( + , + ); + const provider = screen.getByRole("combobox", { name: "Provider" }); + await waitFor(() => expect(provider).toHaveTextContent("Anthropic")); + const thinking = screen.getByRole("combobox", { name: "Thinking" }); + // Pi's medium is high on a model without it. + await waitFor(() => expect(thinking).toHaveTextContent("High")); + expect(screen.getByLabelText("Instructions")).toBeVisible(); + expect(screen.queryByRole("button", { name: "Test connection" })).toBeNull(); + expect(screen.queryByText("Anthropic is set up in Pi.")).toBeNull(); + expect(screen.queryByRole("button", { name: "Save" })).toBeNull(); + + await choose("Thinking", "High"); + // A level the new model doesn't have goes back to Pi's. + await choose("Model", "Claude O"); + expect(thinking).toHaveTextContent("Off"); + await choose("Model", "Claude X"); + await choose("Thinking", "Off"); + + rerender(); + expect(screen.queryByRole("combobox", { name: "Thinking" })).toBeNull(); + expect(screen.getByText("Anthropic is set up in Pi.")).toBeVisible(); + expect(screen.getByRole("button", { name: "Test connection" })).toBeVisible(); + expect(screen.getByRole("textbox", { name: "Workspace" })).toBeVisible(); + await userEvent.setup().click(screen.getByRole("button", { name: "Save" })); + expect(save).toHaveBeenCalledWith({ + ...DEFAULT_CONFIG, + model: "claude-x", + effort: "off", + }); +}); + +it("saves a key on Enter without saving the agent's settings", async () => { + const pi = fakePi(); + const { SettingsTab } = createTabs({ + setup: new PiSetup(pi), + runtime: fakeRuntime(), + }); + const save = vi.fn(); + render(); + await waitFor(() => + expect(screen.getByRole("combobox", { name: "Provider" })).toBeEnabled(), + ); + await choose("Provider", "OpenAI"); + const user = userEvent.setup(); + await user.type(await screen.findByLabelText("API key"), "sk-test{Enter}"); + expect(await screen.findByText("OpenAI is set up in Pi.")).toBeVisible(); + expect(pi.calls).toHaveBeenCalledWith("login", { + provider: "openai", + key: "sk-test", + }); + expect(save).not.toHaveBeenCalled(); +}); + +it("lists Pi's conversations in the Activity tab, and shows a transcript", async () => { + const runtime = fakeRuntime(); + const { ActivityTab } = createTabs({ setup: new PiSetup(fakePi()), runtime }); + render( + , + ); + const user = userEvent.setup(); + const here = await screen.findByRole("region", { name: "This thread" }); + expect(here).toHaveTextContent("#general"); + expect(here).toHaveTextContent("Working"); + expect(screen.getAllByRole("listitem").map((row) => row.textContent)).toEqual( + [expect.stringContaining("other · channel")], + ); + + await user.click( + within(here).getByRole("button", { name: "View transcript" }), + ); + expect(await screen.findByText("Hello from chan/root")).toBeVisible(); + expect(screen.getByText("Hi there")).toBeVisible(); + expect(runtime.transcript).toHaveBeenLastCalledWith( + "c".repeat(64), + expect.objectContaining({ conversation: 1 }), + true, + expect.any(AbortSignal), + ); + await user.click(screen.getByRole("button", { name: "Back to profile" })); + await user.click( + within(screen.getByRole("listitem")).getByRole("button", { + name: "View transcript", + }), + ); + expect(await screen.findByText("Hello from other")).toBeVisible(); + + // A refresh that fails says so, and keeps the transcript it has. + runtime.transcript.mockRejectedValueOnce(new Error("Pi stopped")); + await user.click(screen.getByRole("button", { name: "Refresh" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Could not read the Pi conversation: Pi stopped", + ); + expect(screen.getByText("Hello from other")).toBeVisible(); +}); diff --git a/src/bundled/pi/tabs.tsx b/src/bundled/pi/tabs.tsx new file mode 100644 index 0000000000..711e3c53e8 --- /dev/null +++ b/src/bundled/pi/tabs.tsx @@ -0,0 +1,945 @@ +// Pi's New agent choices and agent settings. Providers, their keys and their +// models are Pi's own: listed from it, and keys saved with its login. +import { + useEffect, + useId, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { errorText } from "../../buzz-mcp/rpc"; +import { ActivityTranscript } from "../../features/agents/ActivityTranscript"; +import { AgentInstructions } from "../../features/agents/AgentInstructions"; +import { AgentSaveButton } from "../../features/agents/AgentSaveButton"; +import { ModelPicker } from "../../features/agents/ModelPicker"; +import { + OpenedConversation, + useOpenedSession, + useOpenTranscript, +} from "../../features/agents/OpenedConversation"; +import type { + AgentCreateProps, + AgentViewProps, +} from "../../features/agents/service"; +import { PanelSubview } from "../../features/panels/PanelSubview"; +import { + ClaudeLogoIcon, + type DefinedIcon, + OpenAILogoIcon, + PiLogoIcon, +} from "../../shared/design-system/icons"; +import { Button } from "../../shared/design-system/ui/Button"; +import { Dialog } from "../../shared/design-system/ui/Dialog"; +import { Field } from "../../shared/design-system/ui/Field"; +import { Input } from "../../shared/design-system/ui/Input"; +import { Select } from "../../shared/design-system/ui/Select"; +import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup"; +import { effortLabel } from "../claude-code/runtime"; +import { + type Config, + DEFAULT_CONFIG, + type PiRuntime, + type Place, + config as readConfig, +} from "./runtime"; +import { + DEFAULT_THINKING, + type PiModel, + type PiSetup, + type Provider, + type Providers, + runsAt, + type Tested, +} from "./setup"; +import { TURN_LIMIT } from "./transcript"; + +const SCOPES = [ + { + label: "", + options: [ + { value: "thread", label: "One conversation per thread" }, + { value: "channel", label: "One conversation per channel" }, + ], + }, +]; + +type Choice = Readonly<{ provider: string; model: string }>; + +/** Marks for the providers Buzz has one for, by Pi's provider ID. */ +const PROVIDER_ICONS: ReadonlyMap = new Map([ + ["anthropic", ClaudeLogoIcon], + ["openai", OpenAILogoIcon], +]); +/** The provider's mark, or undefined for one without a mark. */ +const providerIcon = (id: string | undefined) => { + const Icon = id ? PROVIDER_ICONS.get(id) : undefined; + return Icon &&