diff --git a/.github/workflows/build-pi-villani-runtime-release.yml b/.github/workflows/build-pi-villani-runtime-release.yml new file mode 100644 index 00000000..1ff51f5f --- /dev/null +++ b/.github/workflows/build-pi-villani-runtime-release.yml @@ -0,0 +1,77 @@ +name: Build Pi Villani Runtime Release +permissions: + contents: write +on: + workflow_dispatch: + push: + tags: ["pi-villani-runtime-v*"] +jobs: + build: + strategy: + matrix: + include: + - key: win32-x64 + os: windows-latest + ext: zip + - key: darwin-arm64 + os: macos-14 + ext: tar.gz + - key: darwin-x64 + os: macos-26 + ext: tar.gz + - key: linux-x64 + os: ubuntu-latest + ext: tar.gz + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: {python-version: '3.11'} + - run: python -m pip install -e . pyinstaller + - run: pyinstaller packaging/villani_runtime.spec --distpath dist-runtime/villani-code + - run: python packaging/smoke_test_runtime.py dist-runtime/villani-code/villani-code${{ runner.os == 'Windows' && '.exe' || '' }} + - shell: bash + run: | + name="villani-runtime-v0.1.5-${{ matrix.key }}.${{ matrix.ext }}" + cd dist-runtime + if [[ "${{ matrix.ext }}" == "zip" ]]; then 7z a ../$name villani-code; else tar -czf ../$name villani-code; fi + cd .. + python - "$name" > "${name}.sha256" <<'PY' + import hashlib + import pathlib + import sys + + path = pathlib.Path(sys.argv[1]) + digest = hashlib.sha256(path.read_bytes()).hexdigest() + print(f"{digest} {path.name}") + PY + mkdir -p smoke-asset + python - "$name" smoke-asset <<'PY' + import sys, tarfile, zipfile + from pathlib import Path + archive, dest = Path(sys.argv[1]), Path(sys.argv[2]) + if archive.suffix == '.zip': + with zipfile.ZipFile(archive) as zf: + zf.extractall(dest) + else: + with tarfile.open(archive, 'r:gz') as tf: + tf.extractall(dest) + PY + python packaging/smoke_test_runtime.py smoke-asset/villani-code/villani-code${{ runner.os == 'Windows' && '.exe' || '' }} + - uses: actions/upload-artifact@v4 + with: + name: runtime-${{ matrix.key }} + path: "villani-runtime-v0.1.5-${{ matrix.key }}.${{ matrix.ext }}*" + release: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + - run: find artifacts -name '*.sha256' -exec cat {} \; > checksums.txt + - uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/**/villani-runtime-v0.1.5-* + checksums.txt diff --git a/.gitignore b/.gitignore index 8284d478..b78037ce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ +# Keep the Pi runtime PyInstaller spec tracked +!packaging/villani_runtime.spec + +# Node +node_modules/ +npm-debug.log* +*.tgz + __pycache__/ *.pyc .venv/ diff --git a/README.md b/README.md index ef4d1203..4843e80f 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,21 @@ Autonomous pass: villani-code --villani-mode --base-url http://127.0.0.1:1234 --model your-model --repo /path/to/repo ``` +## Use Villani Code in Pi + +# @mmprotest/pi-villani +Install with: +```bash +pi install npm:@mmprotest/pi-villani +``` +Provides `/villani `. + +Runtime version: `v0.1.5`. + +## Pi Usage + +Run Villani with `/villani `. Active runs are still cleaned up automatically on process/session cancellation or error cleanup. + ## Reports - [Villani Code Terminal-Bench 2.0 Qwen3.6 27B Report](docs/Villani_Code_Terminal_Bench_2_Qwen27B_Report.pdf) diff --git a/integrations/pi-villani/README.md b/integrations/pi-villani/README.md new file mode 100644 index 00000000..0f2fd512 --- /dev/null +++ b/integrations/pi-villani/README.md @@ -0,0 +1,86 @@ +# @mmprotest/pi-villani +Install with: +```bash +pi install npm:@mmprotest/pi-villani +``` +Provides `/villani `. + +Runtime version: `v0.1.5`. + +## Usage + +Run Villani with `/villani `. Active runs are still cleaned up automatically on process/session cancellation or error cleanup. + +# Villani Code + +**Flagship coding-agent performance from small local models.** + +Villani Code is a local-first coding-agent runtime designed to make smaller open models do real repository work: navigate files, run commands, make patches, survive verification, and keep working through messy terminal environments. + +The thesis is simple: small models do not just need better weights. They need a better runtime. + +## Terminal-Bench 2.0: Qwen3.6 27B full-suite run + +Villani Code achieved a **196/445 lower-bound score** on the full Terminal-Bench 2.0 suite using **Qwen3.6 27B**. + +That is **44.0%** across **89 tasks** with **5 attempts per task**. + +### Headline result + +| System | Model | Terminal-Bench 2.0 accuracy | +|---|---|---:| +| Codex CLI | GPT-5-Codex | 44.3% | +| **Villani Code** | **Qwen3.6 27B** | **44.0%** | +| Mini-SWE-Agent | GPT-5-Codex | 41.3% | +| Claude Code | Claude Sonnet 4.5 | 40.1% | +| Dakou Agent | Qwen 3 Coder 480B | 27.2% | +| little-coder | Qwen3.6-35B-A3B | 24.6% | +| Bash Agent | TermiGen-32B | 19.3% | +| little-coder | Qwen3.5-9B | 9.2% | + +## Qwen3.5 9B same-model runtime comparison + +Villani Code was also tested against Claude Code using the same model: **Qwen3.5 9B**. + +Same model. Same tasks. Different agent runtime. + +Villani Code won. + + +| Runner | Score | Success rate | +|---|---:|---:| +| **Villani Code + Qwen3.5 9B** | **38/60** | **63.3%** | +| Claude Code + Qwen3.5 9B | 26/60 | 43.3% | + +**Villani Code delivered a 46% relative performance improvement over Claude Code.** + +This comparison covers **12 overlapping Terminal-Bench tasks**, with **5 runs per task**, for **60 runs per agent**. + +Villani Code won **6 tasks**, tied **6 tasks**, and lost **0**. + +## What Villani Code is + +Villani Code is a terminal-first coding agent for: + +- bounded bug fixes +- repo navigation and localization +- command-driven debugging +- test-guided patching +- local inference setups +- privacy-sensitive codebases +- smaller open model backends + +It is built for the environment where most coding agents start to fall apart: smaller models, hard verification, constrained context, terminal noise, failed commands, and real repositories. + +## What changed in the upgraded runtime + +The latest Villani Code upgrade includes: + +- new execution loop +- better local model integration +- cleaner tool handling +- improved failure recovery +- task-scoped memory system +- better state tracking across long-running coding tasks + +The benchmark comparison evaluates the upgraded runtime as a whole. \ No newline at end of file diff --git a/integrations/pi-villani/docs/release.md b/integrations/pi-villani/docs/release.md new file mode 100644 index 00000000..a0391856 --- /dev/null +++ b/integrations/pi-villani/docs/release.md @@ -0,0 +1,5 @@ +# Release checklist +1. Publish matching GitHub runtime release `pi-villani-runtime-v0.1.5`. +2. Verify assets and checksums. +3. Publish `@mmprotest/pi-villani`. +Install: `pi install npm:@mmprotest/pi-villani`. diff --git a/integrations/pi-villani/package-lock.json b/integrations/pi-villani/package-lock.json new file mode 100644 index 00000000..2018f88e --- /dev/null +++ b/integrations/pi-villani/package-lock.json @@ -0,0 +1,3465 @@ +{ + "name": "@mmprotest/pi-villani", + "version": "0.1.5", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@mmprotest/pi-villani", + "version": "0.1.5", + "dependencies": { + "@earendil-works/pi-ai": "*", + "adm-zip": "0.5.16", + "tar": "7.4.3" + }, + "devDependencies": { + "@earendil-works/pi-ai": "0.80.2", + "@earendil-works/pi-coding-agent": "0.80.2", + "@earendil-works/pi-tui": "0.80.2", + "@types/adm-zip": "0.5.7", + "@types/node": "24.3.0", + "typescript": "5.9.2" + }, + "engines": { + "node": ">=22.19.0" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.23.tgz", + "integrity": "sha512-MiWR/uWjxjFXGzrE0Ghc5lWxUxzHsUWFhV+OX7M4cR9SrmrnZs6TXavnCWnzzdwJeFri34xQo81rvGNzK3c4BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@aws-sdk/xml-builder": "^3.972.31", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.49.tgz", + "integrity": "sha512-liB3yQNHCM9k/gu/w36XHMKPluT7HTlnGUhRbBGSISDQkcr/Sy1zsZabiuvQj8WG5yW573u9RehrBvvnIQ9OEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.51.tgz", + "integrity": "sha512-XET0H2oofciJ5lMRWNIvRjAP7Q3wv2XT+JtJJEdhPWUMwe3TvQ9qcxonpu7vXmNngncvFpi4E2It+Tamas/naA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.2.tgz", + "integrity": "sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.56", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.56.tgz", + "integrity": "sha512-IAmc61hbgQiHht9U3x0tnRwz0lzdwOwD/i9voRgdJrKamF+JtmrBOsW9GwB7mfFonNWOWL4qARWYrF8veEMe3w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/credential-provider-env": "^3.972.49", + "@aws-sdk/credential-provider-http": "^3.972.51", + "@aws-sdk/credential-provider-login": "^3.972.55", + "@aws-sdk/credential-provider-process": "^3.972.49", + "@aws-sdk/credential-provider-sso": "^3.972.55", + "@aws-sdk/credential-provider-web-identity": "^3.972.55", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.55.tgz", + "integrity": "sha512-hBBkANo3cDn+h2qxxzER4a+J8JCO9o9Z/YYmU7iky6AcaarX5RRdRcHNC6SLdwY0vAXQygn6soUbDqPn3GghaA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.58", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.58.tgz", + "integrity": "sha512-OyCLVmSI7pZO8hxwNVX6pXhTVlJqRBTp+ijdEfJSUj0RyjHnF602OfAarOzGq6wkGodeFkYBt8MmJ6A6ycRgWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.49", + "@aws-sdk/credential-provider-http": "^3.972.51", + "@aws-sdk/credential-provider-ini": "^3.972.56", + "@aws-sdk/credential-provider-process": "^3.972.49", + "@aws-sdk/credential-provider-sso": "^3.972.55", + "@aws-sdk/credential-provider-web-identity": "^3.972.55", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/credential-provider-imds": "^4.3.7", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.49.tgz", + "integrity": "sha512-C8h36lBuC/RnBSsjlO+dn6xZm3KbAl5vpJaVPAfQnMmz2/OISmKOc8XZcqMQgO2ADwBYNRMM6Kf3vz9G/TulMQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.55.tgz", + "integrity": "sha512-1FkOz74Ea5QGS9jtIoXp55T/IkSS3spv+nLTT07fRY/+T5xmEOqaYBVIaEmX4zTNvbV6g2lrtlaVKWEoNyJt3w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/token-providers": "3.1074.0", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1074.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1074.0.tgz", + "integrity": "sha512-pv80IzgGW4RnXWtft692chZOM9i6PhebVsLCcnaM4dBEPZva2fE6FXAHs76G7Rc7s3yGyX/68G0nZMrUy+Vmpg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.55", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.55.tgz", + "integrity": "sha512-g2BoECD1q01kTPByi56+VLVvdWDzMkKIcr77qixpqH0okw2t0U5CoPv+6S8v/D1Y2Wa6QKKtn6XAtDzP+Kfpvg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/nested-clients": "^3.997.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.22.tgz", + "integrity": "sha512-tqPJv0dz4+O0hWGm1a6YekcMZyPhDFs/zH73Von7icaVT5n0Jqvm86typ3jRrG+qoUdPhALOnboRLTmnWQTlYQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.18.tgz", + "integrity": "sha512-OHpk8YoZi3yexPq8aFt1vN1IxA2zLKvsIR5GpWYylX/ve6kQmY7wxHNSFy/D3t2apMZ16rs76Co4dJWcDyIk3A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.31.tgz", + "integrity": "sha512-ps1rumU1LybSFHaW9dTDgkhCMJLVaedEY78kKSzUDDY+b9974/g6aiaYYA0U9WV0oL4CJCJrVWG+EZ/qr4or7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.23.tgz", + "integrity": "sha512-gO93ZPsI2bxeFZD42f1/qjDw6FAZkNZcKRO94LIiT03fzOmcJ9e/tunxjVjA1Rl69ClmVJzz8H3G9CdKef10PA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.23", + "@aws-sdk/signature-v4-multi-region": "^3.996.35", + "@aws-sdk/types": "^3.973.13", + "@smithy/core": "^3.24.6", + "@smithy/fetch-http-handler": "^5.4.6", + "@smithy/node-http-handler": "^4.7.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.8.2", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.8.2.tgz", + "integrity": "sha512-wfl1uwrAqMH9/pi4kqBo5LBcFwrJLxuDLqL7p7qNcJIFcyZDUc6pzhYk4CYv+DP7fIUpQCZumwNnkhPKS52osQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.35", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.35.tgz", + "integrity": "sha512-6L/VWs+Wch2stHemCGTmUNqKLMzURxQDK5boNG3Jn3kAOp71meDUuS5sbObpEvFxHDq0uWeSLFDNSYsjNt+Dlg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.13", + "@smithy/signature-v4": "^5.4.6", + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.13.tgz", + "integrity": "sha512-pEHZqRkAlHfnfAU9tK+WpKv/gBNjGJrHMgA3A0iYRGyswBS2t0pfez+lWlwktb3Bqa0ovh7w/QJTFwp3fDxLNg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.8.tgz", + "integrity": "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.31.tgz", + "integrity": "sha512-SzE4Pgyl+hDF+BuyuzxUSpwnuUu9lJuO1YGgteG89/4Qv0+2IQiVQqdbPV32IozLvXWQChPQcdkk/sKvb1QHiQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.80.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.2.tgz", + "integrity": "sha512-5GNKfdrRJ4uZ5Zd9iudoXggi/BbUcKnD/xfRHtdR+7q4vWqPvfx8auFuaT+ewGBVI8K4wj87eigFQ/iCSuy9RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.80.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.80.2.tgz", + "integrity": "sha512-m9v7OUit0s9LklWfh61ca/XY5INjUzjtYtNZwy3cNvyjOLk3IpBgghP8aAp0iH35rLaiRwuuWiJ8t88ODMWY+A==", + "dev": true, + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.80.2", + "@earendil-works/pi-ai": "^0.80.2", + "@earendil-works/pi-tui": "^0.80.2", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.1.38", + "undici": "8.5.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.974.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.11.tgz", + "integrity": "sha512-QpnINq5FZH6EOaDEkmHdT7eUunbvD27pDNQypaWjFyYz7Zl1q3UCMQErBZxpmfGfI7MvI2TlK8KTkgNpv8b1ug==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.24", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.37.tgz", + "integrity": "sha512-/jpPvEh6f7ntmIzf7dNxoNX6Q8vt8UpesCjbW6mFfk4V1NW6bIy9qxcQ6WbA8As5yQhsZOe+xeNd4xHX8kdY2Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.39.tgz", + "integrity": "sha512-pIgTpisWyWg7X1bUbzSjuUYosYTD0Ghz2M0hkSTmb3a6i3qV3uU+NYJPI/E2XSC0HcsZh5rsLPzeXrkb2DS0Cg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.41.tgz", + "integrity": "sha512-u2tyjaxJJzW8UtW4SM1ZcPMDwO6y+kV+llvou+Adts0FAKyzes5jG4izQN+KX3yE8ZROpS5y1LJ//xL2iSf76w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-login": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.41.tgz", + "integrity": "sha512-0LBitxXiAiaE5nlFPfpNIww/8FRY/I7WIndWsc9GmNFOM7cE1wNpVNQEGEk9Outg5l8xl+3vybxFyUy4l9q/LQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.42.tgz", + "integrity": "sha512-D4oon2zbqqsWOJUM99Gm3/ZyJ0IJvTXVN3PyloGb3kQEyI36fjCZheZj422lAgTWWd6TSHgiImLt3RIaLdv3dQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.37", + "@aws-sdk/credential-provider-http": "^3.972.39", + "@aws-sdk/credential-provider-ini": "^3.972.41", + "@aws-sdk/credential-provider-process": "^3.972.37", + "@aws-sdk/credential-provider-sso": "^3.972.41", + "@aws-sdk/credential-provider-web-identity": "^3.972.41", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.37.tgz", + "integrity": "sha512-7nVaHBUaWIddASYfVaA9O4D5ZVjewU3sCol9WqZPGfW0nR+0WqE0xHZnD/U2L33PlOB8KNXGKZ6wOES/QijKzg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.41.tgz", + "integrity": "sha512-IOWAWEHe5LkjSKkkUUX9ciV6Y1scHTsnfEkdt5yyC4Slrc7AGbkLPrpntjqh18ksJAMOaVhoBsO8p2WyTcY2wQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.41.tgz", + "integrity": "sha512-mbACk9Yypa8nm4iGZLs0PofOXEcTDOUw6wDnsPXNDNSd2WNXs1tSo+6nc/fh0jLYdfVZThhBL98PHW4aXFsG5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.16.tgz", + "integrity": "sha512-yedpPgKftqjU5SlPFHfqWpOw6xSCRieWRG1euWOlXn4WJxt2VX92VprCa2PpSOXjVCAeK6dTjW9eJRXVig9yGA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.12.tgz", + "integrity": "sha512-tHTHHCHNrq6XklQvlzHBDJG4Iuhh7NVPRdtmvP+nHFA+5sxPlIDzlAHHgfoYHGvT3NXP1yVP/L5c3opUn6T3Qg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.19", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.19.tgz", + "integrity": "sha512-mkEhOGYozqKQkbFaVrjwr0faiwwZza1v5/jSY6Tucm3bD+uKTazIUH/4Yo6aMnQD2ua2W9cMP6s8mvwTcjtqHw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.9.tgz", + "integrity": "sha512-jPR3rnmRI4hWYyzfmTGBr7NblMp8QYYeflHXba1H6+7CGrWVqWKQzaXFQ4qbExqPRsXN3T3L3JxFhr6aouXUGQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.27", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", + "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.24", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", + "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@nodable/entities": "2.1.0", + "@smithy/types": "^4.14.1", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.80.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.80.2.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.80.2", + "ignore": "7.0.5", + "typebox": "1.1.38", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.80.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.80.2.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.1.38" + }, + "bin": { + "pi-ai": "./dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.80.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.2.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "dev": true, + "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/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@mistralai/mistralai": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", + "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/credential-provider-imds": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", + "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/fetch-http-handler": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", + "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.4.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", + "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "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/@earendil-works/pi-coding-agent/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "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/@earendil-works/pi-coding-agent/node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "dev": true, + "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/@earendil-works/pi-coding-agent/node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, + "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/@earendil-works/pi-coding-agent/node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "dev": true, + "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/@earendil-works/pi-coding-agent/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "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/@earendil-works/pi-coding-agent/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "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/@earendil-works/pi-coding-agent/node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/p-retry/node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "dev": true, + "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/@earendil-works/pi-coding-agent/node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "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/@earendil-works/pi-coding-agent/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", + "integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "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 + } + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.80.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.80.2.tgz", + "integrity": "sha512-OvOAMIbXiC9OSse17YMiXIsI9AS5XM/ZV8N/k+UzdlRpPILDQYmLElevgGW92kkXR8qHBClIdzhCjuzlBGvphA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "dev": true, + "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/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@mistralai/mistralai": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@smithy/core": { + "version": "3.26.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.26.0.tgz", + "integrity": "sha512-mLUktFAn+Pa2agl1J7VgtYNFWCX8/b4GMJSK1hCu4YCvtBfM6F8Os3EP4ry+DFFlXOf3wyvlgXhuUdFoy52D3g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.2.tgz", + "integrity": "sha512-18UMDMyrAbDcpmL1gLUA7ww0fRTcdCrSjSJOi2Sbld+tVjwD/pW+OAwjlScFLR7vvBnhZrIPQ7kVuTf1mnJLug==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.5.2.tgz", + "integrity": "sha512-Ei/UK/QMhq0rKaMqGPlOAkE2yS9DZeYmZdk1RAKc3vp3zxgleZHZyBLlZv8yLsxljX4svCRuMTD6u3LLIcU4Bg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.5.2.tgz", + "integrity": "sha512-7xHpmPY4rt0IOmeAA8EfjgEH8isT+587TCdy9H6a7d4OMi5CQ0oEHhWllunvPu4j4Cq0vTFwdxXN/kABWPjdyA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.26.0", + "@smithy/types": "^4.15.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.15.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz", + "integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@types/adm-zip": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.7.tgz", + "integrity": "sha512-DNEs/QvmyRLurdQPChqq0Md4zGvPwHerAJYWk9l2jCbD1VPpnzRJorOdiq4zsw09NFbYnhfsoEhWtxIzXpn2yw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "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", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "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", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.5.tgz", + "integrity": "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==", + "dev": true, + "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/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "dev": true, + "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/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.0.tgz", + "integrity": "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg==", + "dev": true, + "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", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "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", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "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", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "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": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "dev": true, + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", + "dev": true, + "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/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "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/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typebox": { + "version": "1.1.38", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.1.38.tgz", + "integrity": "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "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 + } + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/integrations/pi-villani/package.json b/integrations/pi-villani/package.json new file mode 100644 index 00000000..787d4b20 --- /dev/null +++ b/integrations/pi-villani/package.json @@ -0,0 +1,48 @@ +{ + "name": "@mmprotest/pi-villani", + "version": "0.1.5", + "description": "Pi extension for Villani Code", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "pi": { + "extensions": [ + "./dist/index.js" + ] + }, + "files": [ + "dist", + "!dist/*.test.js", + "!dist/*.test.d.ts", + "README.md", + "docs" + ], + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "npm run build && node --test dist/*.test.js", + "prepack": "npm run build", + "prepublishOnly": "npm test" + }, + "devDependencies": { + "@earendil-works/pi-ai": "0.80.2", + "@earendil-works/pi-coding-agent": "0.80.2", + "@earendil-works/pi-tui": "0.80.2", + "@types/adm-zip": "0.5.7", + "@types/node": "24.3.0", + "typescript": "5.9.2" + }, + "dependencies": { + "adm-zip": "0.5.16", + "tar": "7.4.3", + "@earendil-works/pi-ai": "*" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-tui": "*" + } +} diff --git a/integrations/pi-villani/src/abort.test.ts b/integrations/pi-villani/src/abort.test.ts new file mode 100644 index 00000000..acde1484 --- /dev/null +++ b/integrations/pi-villani/src/abort.test.ts @@ -0,0 +1,3 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; +import { VillaniBridgeProcess } from './process.js'; +test('bridge waitForEvent resolves matching event',async()=>{const b=Object.assign(new EventEmitter(),{off:EventEmitter.prototype.off}) as any as VillaniBridgeProcess; Object.setPrototypeOf(b,VillaniBridgeProcess.prototype); const p=b.waitForEvent('run_aborted',50); b.emit('event',{type:'run_aborted'}); assert.equal((await p)?.type,'run_aborted');}); diff --git a/integrations/pi-villani/src/approval.test.ts b/integrations/pi-villani/src/approval.test.ts new file mode 100644 index 00000000..68d838d7 --- /dev/null +++ b/integrations/pi-villani/src/approval.test.ts @@ -0,0 +1,2 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; +test('approval ids are expected to be run-prefixed monotonic values',()=>{assert.match('run-1:42',/^run-1:\d+$/);}); diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts new file mode 100644 index 00000000..daceb80e --- /dev/null +++ b/integrations/pi-villani/src/extension.test.ts @@ -0,0 +1,748 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import activate, { bridgePing } from "./index.js"; +function api() { + const commands: any = {}; + return { + commands, + registerCommand: (name: string, opts: any) => { + commands[name] = opts; + assert.equal(typeof opts.handler, "function"); + }, + }; +} +test("registers only the public /villani command", () => { + const a = api(); + activate(a); + assert.deepEqual(Object.keys(a.commands), ["villani"]); + for (const removed of [ + "villani-abort", + "villani-confirm-test", + "villani-ping", + "villani-doctor", + "villani-proxy-test", + "villani-bridge-ping", + ]) { + assert.equal(a.commands[removed], undefined); + } +}); +import { + chmodSync, + existsSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runVillani } from "./index.js"; +function bridgeScript(body: string) { + const d = mkdtempSync(join(tmpdir(), "pi-villani-run-")); + const p = join(d, "bridge.mjs"); + writeFileSync(p, `#!/usr/bin/env node\n${body}`); + chmodSync(p, 0o755); + return p; +} +const readyPrelude = + "process.stdout.write(JSON.stringify({type:'ready'})+'\\n');\nconst send=e=>process.stdout.write(JSON.stringify(e)+'\\n');\n"; +function fakeBridgeLaunch(fakeBridgeScriptPath: string) { + return { + executableOverride: process.execPath, + bridgeArgsOverride: [fakeBridgeScriptPath], + }; +} + +test("/villani sends command notification and only reports run started after run_started event", async () => { + const old = process.env.VILLANI_COMMAND; + const p = bridgeScript( + readyPrelude + + "process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'phase',id:msg.id,name:'x'}); send({type:'run_completed',id:msg.id,summary:'done'});}}); setTimeout(()=>{},500);\n", + ); + process.env.VILLANI_COMMAND = p; + const notes: string[] = []; + try { + process.env.VILLANI_USE_PI_MODEL = "false"; + await runVillani( + "task", + { sendMessage: (m: string) => notes.push(m) }, + { + ui: { notify: (m: string) => notes.push(m) }, + bridgeLaunchOptions: fakeBridgeLaunch(p), + cwd: "/tmp", + model: { id: "m" }, + }, + ); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + delete process.env.VILLANI_USE_PI_MODEL; + } + const joined = notes.join("\n"); + assert.match(joined, /Villani starting/); + assert.doesNotMatch( + joined, + /bridge event received|heartbeat pong|model request started/, + ); +}); + +test("/villani missing run_started timeout reports visible error instead of started", async () => { + const old = process.env.VILLANI_COMMAND; + const p = bridgeScript( + readyPrelude + + "process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping')send({type:'pong'});}}); console.error('no ack'); setTimeout(()=>{},15000);\n", + ); + process.env.VILLANI_COMMAND = p; + const notes: string[] = []; + try { + process.env.VILLANI_USE_PI_MODEL = "false"; + await assert.rejects( + () => + runVillani( + "task", + {}, + { ui: { notify: (m: string) => notes.push(m) }, bridgeLaunchOptions: fakeBridgeLaunch(p), cwd: "/tmp" }, + ), + /did not acknowledge run command within 10 seconds.*no ack/s, + ); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + delete process.env.VILLANI_USE_PI_MODEL; + } + assert.doesNotMatch(notes.join("\n"), /Villani run started\./); +}); + +async function waitForPidGone(pid: number, timeoutMs = 3000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + process.kill(pid, 0); + } catch { + return; + } + await new Promise((r) => setTimeout(r, 50)); + } + assert.fail(`fake bridge process ${pid} remained alive`); +} + +test("/villani run_started timeout cleans up fake bridge process", async () => { + const oldCommand = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const d = mkdtempSync(join(tmpdir(), "pi-villani-cleanup-")); + const pidFile = join(d, "pid"); + const p = join(d, "bridge.mjs"); + writeFileSync( + p, + `#!/usr/bin/env node +import { writeFileSync } from 'node:fs'; +writeFileSync(${JSON.stringify(pidFile)},String(process.pid)); +process.stdout.write(JSON.stringify({type:'ready'})+'\\n'); +process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping')process.stdout.write(JSON.stringify({type:'pong'})+'\\n');}}); +console.error('cleanup no ack'); +setInterval(()=>{},1000); +`, + ); + chmodSync(p, 0o755); + process.env.VILLANI_COMMAND = p; + process.env.VILLANI_USE_PI_MODEL = "false"; + const notes: string[] = []; + try { + await assert.rejects( + () => + runVillani( + "task", + {}, + { ui: { notify: (m: string) => notes.push(m) }, bridgeLaunchOptions: fakeBridgeLaunch(p), cwd: "/tmp" }, + ), + /did not acknowledge run command within 10 seconds.*cleanup no ack/s, + ); + assert.ok(existsSync(pidFile)); + await waitForPidGone(Number(readFileSync(pidFile, "utf8"))); + assert.doesNotMatch(notes.join("\n"), /Villani run started\./); + } finally { + if (oldCommand === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = oldCommand; + if (oldUsePi === undefined) delete process.env.VILLANI_USE_PI_MODEL; + else process.env.VILLANI_USE_PI_MODEL = oldUsePi; + } +}); + +test("bridge exit before ready with ModuleNotFoundError produces pip install diagnostic", async () => { + const old = process.env.VILLANI_COMMAND; + const p = bridgeScript( + "console.error(\"ModuleNotFoundError: No module named 'villani_code'\"); process.exit(1);\n", + ); + process.env.VILLANI_COMMAND = p; + try { + await assert.rejects( + () => bridgePing({ ui: { notify: () => {} }, bridgeLaunchOptions: fakeBridgeLaunch(p) }), + /pip install -e/, + ); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + } +}); + +test("/villani-bridge-ping succeeds with fake bridge", async () => { + const old = process.env.VILLANI_COMMAND; + const p = bridgeScript( + readyPrelude + + "process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split(/\\n/)){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping')send({type:'pong'});}}); setTimeout(()=>{},500);\n", + ); + process.env.VILLANI_COMMAND = p; + const notes: string[] = []; + try { + await bridgePing({ ui: { notify: (m: string) => notes.push(m) }, bridgeLaunchOptions: fakeBridgeLaunch(p) }); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + } + assert.match(notes.join("\n"), /Villani bridge ping succeeded/); +}); + +test("/villani-bridge-ping surfaces stderr on failure", async () => { + const old = process.env.VILLANI_COMMAND; + const p = bridgeScript("console.error('boom stderr'); process.exit(2);\n"); + process.env.VILLANI_COMMAND = p; + try { + await assert.rejects( + () => bridgePing({ ui: { notify: () => {} }, bridgeLaunchOptions: fakeBridgeLaunch(p) }), + /boom stderr/, + ); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + } +}); + +test("/villani surfaces bridge error before run_started without waiting 10 seconds", async () => { + const old = process.env.VILLANI_COMMAND; + const p = bridgeScript( + readyPrelude + + "process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} if(msg.type==='run')send({type:'error',id:msg.id,error:'bad run command'});}}); setTimeout(()=>{},15000);\n", + ); + process.env.VILLANI_COMMAND = p; + const notes: string[] = []; + const started = Date.now(); + try { + process.env.VILLANI_USE_PI_MODEL = "false"; + await assert.rejects( + () => + runVillani( + "task", + {}, + { ui: { notify: (m: string) => notes.push(m) }, bridgeLaunchOptions: fakeBridgeLaunch(p), cwd: "/tmp" }, + ), + /bad run command/, + ); + assert.ok(Date.now() - started < 5000); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + delete process.env.VILLANI_USE_PI_MODEL; + } + assert.doesNotMatch(notes.join("\n"), /Villani run started\./); +}); + +test("/villani launches bridge with ctx cwd", async () => { + const old = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const d = mkdtempSync(join(tmpdir(), "pi-villani-cwd-")); + const cwdFile = join(d, "cwd.txt"); + const p = bridgeScript( + readyPrelude + + `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(cwdFile)}, process.cwd()); process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'phase',id:msg.id,name:'x'}); send({type:'run_completed',id:msg.id,summary:'done'});}}); setTimeout(()=>{},500);\n`, + ); + process.env.VILLANI_COMMAND = p; + process.env.VILLANI_USE_PI_MODEL = "false"; + try { + await runVillani( + "task", + { sendMessage: () => {} }, + { ui: { notify: () => {} }, bridgeLaunchOptions: fakeBridgeLaunch(p), cwd: d, model: { id: "m" } }, + ); + assert.equal(readFileSync(cwdFile, "utf8"), d); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + if (oldUsePi === undefined) delete process.env.VILLANI_USE_PI_MODEL; + else process.env.VILLANI_USE_PI_MODEL = oldUsePi; + } +}); + +test("VILLANI_PI_DEBUG sends bridge diagnostics to console not notify", async () => { + const old = process.env.VILLANI_PI_DEBUG; + const notes: string[] = []; + const errs: string[] = []; + const oldErr = console.error; + try { + process.env.VILLANI_PI_DEBUG = "1"; + console.error = (m: any) => errs.push(String(m)); + const { renderBridgeEvent } = await import("./render.js"); + await renderBridgeEvent( + { type: "bridge_diagnostic", message: "capturing initial git status" }, + {}, + { ui: { notify: (m: string) => notes.push(m) } }, + ); + assert.equal(notes.length, 0); + assert.match(errs.join("\n"), /capturing initial git status/); + } finally { + console.error = oldErr; + if (old === undefined) delete process.env.VILLANI_PI_DEBUG; + else process.env.VILLANI_PI_DEBUG = old; + } +}); + +test("sendDurableVillaniMessage sends a structured iterable custom message", async () => { + const { sendDurableVillaniMessage } = await import("./render.js"); + const sent: any[] = []; + await sendDurableVillaniMessage( + { sendMessage: (m: any) => sent.push(m) }, + { ui: { notify: () => assert.fail("notify should not be used") } }, + "hello", + { + type: "run_completed", + authorization: "Bearer secret", + headers: { authorization: "Bearer secret" }, + apiKey: "secret", + token: "secret", + }, + ); + assert.equal(sent.length, 1); + assert.notEqual(typeof sent[0], "string"); + assert.equal(sent[0].customType, "villani-result"); + assert.equal(sent[0].display, true); + assert.deepEqual(sent[0].content, [{ type: "text", text: "hello" }]); + assert.ok(Symbol.iterator in Object(sent[0].content)); + assert.doesNotMatch( + JSON.stringify(sent[0].details), + /secret|authorization|apiKey|token/i, + ); +}); + +test("sendDurableVillaniMessage falls back to ctx.ui.notify if Pi sendMessage throws", async () => { + const { sendDurableVillaniMessage } = await import("./render.js"); + const notes: string[] = []; + await sendDurableVillaniMessage( + { + sendMessage: async () => { + throw new Error("boom"); + }, + }, + { + ui: { notify: (m: string, level: string) => notes.push(`${level}:${m}`) }, + }, + "fallback text", + ); + assert.deepEqual(notes, ["info:fallback text"]); +}); + +test("renderBridgeEvent does not send durable final messages", async () => { + const { renderBridgeEvent } = await import("./render.js"); + let sent = 0; + const notes: string[] = []; + for (const type of ["run_completed", "run_failed", "run_aborted"]) + await renderBridgeEvent( + { type, summary: "done" }, + { sendMessage: () => sent++ }, + { ui: { notify: (m: string) => notes.push(m) } }, + ); + assert.equal(sent, 0); + assert.deepEqual(notes, []); +}); + +test("/villani sends exactly one iterable final durable message with summary metadata", async () => { + const old = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const p = bridgeScript( + readyPrelude + + "process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'model_request_started',id:msg.id}); send({type:'run_completed',id:msg.id,summary:'done',changed_files:['src/a.ts','.villani/secret'],transcript_path:'/tmp/transcript.jsonl',verification_status:'passed',authorization:'Bearer secret',headers:{authorization:'Bearer secret'}});}}); setTimeout(()=>{},500);\n", + ); + const sent: any[] = []; + try { + process.env.VILLANI_COMMAND = p; + process.env.VILLANI_USE_PI_MODEL = "false"; + await runVillani( + "task", + { sendMessage: (m: any) => sent.push(m) }, + { ui: { notify: () => {} }, bridgeLaunchOptions: fakeBridgeLaunch(p), cwd: "/tmp", model: { id: "m" } }, + ); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + if (oldUsePi === undefined) delete process.env.VILLANI_USE_PI_MODEL; + else process.env.VILLANI_USE_PI_MODEL = oldUsePi; + } + assert.equal(sent.length, 1); + const payload = sent[0]; + assert.equal(payload.customType, "villani-result"); + assert.deepEqual(payload.content, [ + { type: "text", text: payload.content[0].text }, + ]); + assert.ok(Array.isArray(payload.content)); + assert.match(payload.content[0].text, /Villani completed/); + assert.match(payload.content[0].text, /done/); + assert.match(payload.content[0].text, /src\/a\.ts/); + assert.doesNotMatch(payload.content[0].text, /\.villani\/secret/); + assert.match(payload.content[0].text, /Transcript: \/tmp\/transcript\.jsonl/); + assert.match(payload.content[0].text, /Verification: passed/); + assert.doesNotMatch( + JSON.stringify(payload.details), + /authorization|Bearer secret/i, + ); +}); + +test("approvalMessage never renders object and includes Bash command", async () => { + const { approvalMessage, approvalTitle } = await import("./index.js"); + const msg = approvalMessage({ + tool: "Bash", + summary: "Run command", + input: { command: "echo hi" }, + }); + assert.doesNotMatch(msg, /\[object Object\]/); + assert.match(msg, /Command: echo hi/); + assert.equal( + approvalTitle({ tool: "Bash" }), + "Villani requests command authority", + ); +}); + +test("confirm passes signal option to ctx.ui.confirm", async () => { + const { confirm } = await import("./render.js"); + const signal = new AbortController().signal; + let args: any[] = []; + const ok = await confirm( + { + ui: { + confirm: async (...a: any[]) => { + args = a; + return true; + }, + }, + }, + "t", + "m", + { signal }, + ); + assert.equal(ok, true); + assert.equal(args[2].signal, signal); +}); + +test("/villani approval pending clears widget, sets status, confirms, and accepted clears widget", async () => { + const old = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const p = bridgeScript( + readyPrelude + + "process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} if(msg.type==='approval_response'){send({type:'tool_progress',id:msg.id,tool:'Bash',message:'Running command: echo hi'}); send({type:'run_completed',id:msg.id,summary:'done'}); continue;} send({type:'run_started',id:msg.id}); send({type:'approval_required',id:msg.id,request_id:msg.id+':1',tool:'Bash',summary:'Run command',input:{command:'echo hi'}});}}); setTimeout(()=>{},500);\n", + ); + const statuses: any[] = []; + const widgets: any[] = []; + const confirms: any[] = []; + try { + process.env.VILLANI_COMMAND = p; + process.env.VILLANI_USE_PI_MODEL = "false"; + await runVillani( + "task", + { sendMessage: () => {} }, + { + ui: { + notify: () => {}, + setStatus: (...a: any[]) => statuses.push(a), + setWidget: (...a: any[]) => widgets.push(a), + confirm: async (...a: any[]) => { + confirms.push(a); + return true; + }, + }, + bridgeLaunchOptions: fakeBridgeLaunch(p), + cwd: "/tmp", + model: { id: "m" }, + }, + ); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + if (oldUsePi === undefined) delete process.env.VILLANI_USE_PI_MODEL; + else process.env.VILLANI_USE_PI_MODEL = oldUsePi; + } + assert.ok( + statuses.some((a) => a[0] === "villani" && /approval|authorization|authority|clearance/i.test(String(a[1]))), + ); + assert.ok(widgets.length > 0); + assert.equal(widgets[0][0], "villani"); + assert.equal(widgets[0][1], undefined); + assert.equal(confirms[0][0], "Villani requests command authority"); + assert.match(confirms[0][1], /Command: echo hi/); + assert.match(confirms[0][1], /Approve this Villani action\?/); + assert.doesNotMatch(JSON.stringify(widgets), /Villani requests command authority|Command: echo hi|Pending approval|Allow this operation|\[object Object\]/); + assert.ok(widgets.some((a) => a[0] === "villani" && a[1] === undefined)); + assert.equal(confirms[0][2].signal instanceof AbortSignal, true); +}); + +test("tool result and finished events update status without notify spam", async () => { + const { renderBridgeEvent } = await import("./render.js"); + const notes: string[] = []; + const statuses: string[] = []; + await renderBridgeEvent( + { + type: "tool_finished", + tool: "Bash", + summary: "Command finished: exit 0", + ok: true, + }, + {}, + { + ui: { + notify: (m: string) => notes.push(m), + setStatus: (_: string, m: string) => statuses.push(m), + }, + }, + ); + await renderBridgeEvent( + { type: "tool_progress", message: "Running command: echo hi" }, + {}, + { + ui: { + notify: (m: string) => notes.push(m), + setStatus: (_: string, m: string) => statuses.push(m), + }, + }, + ); + assert.deepEqual(notes, []); + assert.deepEqual(statuses, []); +}); + +test("/villani keeps waiting after nonzero tool_finished and renders next model event", async () => { + const old = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const p = bridgeScript( + readyPrelude + + "process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'tool_finished',id:msg.id,tool:'Bash',ok:false,is_error:true,summary:'Bash finished: exit 255\\nstderr: head not recognized'}); setTimeout(()=>send({type:'model_request_started',id:msg.id}),50); setTimeout(()=>send({type:'run_completed',id:msg.id,summary:'done'}),100);}}); setTimeout(()=>{},500);\n", + ); + const statuses: string[] = []; + const notes: string[] = []; + const sent: any[] = []; + try { + process.env.VILLANI_COMMAND = p; + process.env.VILLANI_USE_PI_MODEL = "false"; + await runVillani( + "task", + { sendMessage: (m: any) => sent.push(m) }, + { + ui: { + notify: (m: string) => notes.push(m), + setStatus: (_: string, m: string) => statuses.push(m), + }, + bridgeLaunchOptions: fakeBridgeLaunch(p), + cwd: "/tmp", + model: { id: "m" }, + }, + ); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + if (oldUsePi === undefined) delete process.env.VILLANI_USE_PI_MODEL; + else process.env.VILLANI_USE_PI_MODEL = oldUsePi; + } + assert.doesNotMatch(notes.join("\n"), /Villani tool finished/); + assert.ok(statuses.some((s) => /^Villani|^Villani/.test(s))); + assert.equal(sent.length, 1); +}); + +test("repeated model_request_started updates status without notify spam", async () => { + const { renderBridgeEvent } = await import("./render.js"); + const statuses: string[] = []; + const notes: string[] = []; + const ctx = { + ui: { + setStatus: (_: string, m: string) => statuses.push(m), + notify: (m: string) => notes.push(m), + }, + }; + await renderBridgeEvent({ type: "model_request_started" }, {}, ctx); + await renderBridgeEvent({ type: "model_request_started" }, {}, ctx); + assert.equal(statuses.length, 1); + assert.ok(statuses.every((s) => /^Villani/.test(s))); + assert.deepEqual(notes, []); +}); + +test("bridge heartbeat pong never calls notify, including debug", async () => { + const { renderBridgeEvent } = await import("./render.js"); + const old = process.env.VILLANI_PI_DEBUG; + const notes: string[] = []; + try { + delete process.env.VILLANI_PI_DEBUG; + await renderBridgeEvent( + { type: "pong" }, + {}, + { ui: { notify: (m: string) => notes.push(m) } }, + ); + process.env.VILLANI_PI_DEBUG = "1"; + await renderBridgeEvent( + { type: "bridge_diagnostic", message: "bridge heartbeat pong" }, + {}, + { ui: { notify: (m: string) => notes.push(m) } }, + ); + assert.deepEqual(notes, []); + } finally { + if (old === undefined) delete process.env.VILLANI_PI_DEBUG; + else process.env.VILLANI_PI_DEBUG = old; + } +}); + +test("bridge_diagnostic does not notify unless debug enabled and debug uses console", async () => { + const { renderBridgeEvent } = await import("./render.js"); + const old = process.env.VILLANI_PI_DEBUG; + const oldErr = console.error; + const notes: string[] = []; + const errs: string[] = []; + try { + delete process.env.VILLANI_PI_DEBUG; + await renderBridgeEvent( + { type: "bridge_diagnostic", message: "model request started" }, + {}, + { ui: { notify: (m: string) => notes.push(m) } }, + ); + process.env.VILLANI_PI_DEBUG = "1"; + console.error = (m: any) => errs.push(String(m)); + await renderBridgeEvent( + { type: "bridge_diagnostic", message: "model request started" }, + {}, + { ui: { notify: (m: string) => notes.push(m) } }, + ); + assert.deepEqual(notes, []); + assert.match(errs.join("\n"), /model request started/); + } finally { + console.error = oldErr; + if (old === undefined) delete process.env.VILLANI_PI_DEBUG; + else process.env.VILLANI_PI_DEBUG = old; + } +}); + +test("command lifecycle sets then clears widget through final result", async () => { + const old = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const p = bridgeScript( + readyPrelude + + "process.stdin.on('data',chunk=>{for(const line of chunk.toString().trim().split('\\n')){if(!line)continue; const msg=JSON.parse(line); if(msg.type==='ping'){send({type:'pong'}); continue;} send({type:'run_started',id:msg.id}); send({type:'command_started',id:msg.id,command:'echo hi'}); send({type:'command_finished',id:msg.id,command:'echo hi',exit_code:0,stdout_preview:'hi'}); send({type:'run_completed',id:msg.id,summary:'done'});}}); setTimeout(()=>{},500);\n", + ); + const widgets: any[] = []; + const sent: any[] = []; + try { + process.env.VILLANI_COMMAND = p; + process.env.VILLANI_USE_PI_MODEL = "false"; + await runVillani( + "task", + { sendMessage: (m: any) => sent.push(m) }, + { + ui: { + notify: () => {}, + setWidget: (...a: any[]) => widgets.push(a), + setStatus: () => {}, + }, + bridgeLaunchOptions: fakeBridgeLaunch(p), + cwd: "/tmp", + model: { id: "m" }, + }, + ); + } finally { + if (old === undefined) delete process.env.VILLANI_COMMAND; + else process.env.VILLANI_COMMAND = old; + if (oldUsePi === undefined) delete process.env.VILLANI_USE_PI_MODEL; + else process.env.VILLANI_USE_PI_MODEL = oldUsePi; + } + assert.ok(widgets.some((a) => a[0] === "villani" && a[1] === undefined)); + assert.equal(sent.length, 1); +}); + +test("strict render allowlist suppresses bridge plumbing", async () => { + const { renderBridgeEvent, shouldRenderUserFacingEvent } = await import("./render.js"); + const calls: any[] = []; + const ctx = { + ui: { + notify: (...a: any[]) => calls.push(["notify", ...a]), + setStatus: (...a: any[]) => calls.push(["setStatus", ...a]), + setWidget: (...a: any[]) => calls.push(["setWidget", ...a]), + }, + }; + for (const event of [ + { type: "bridge_diagnostic", message: "event received: tool_result" }, + { type: "bridge_diagnostic", message: "bridge heartbeat pong" }, + { type: "runner_heartbeat" }, + { type: "pong" }, + { type: "tool_result", summary: "tool_result mapped" }, + ]) { + assert.equal(shouldRenderUserFacingEvent(event), false); + await renderBridgeEvent(event, {}, ctx); + } + assert.deepEqual(calls, []); +}); + +test("stream_text renders clean assistant blocks and suppresses duplicates/whitespace", async () => { + const { renderBridgeEvent, resetVillaniUiState } = await import("./render.js"); + resetVillaniUiState(); + const notes: string[] = []; + const ctx = { ui: { notify: (m: string) => notes.push(m) } }; + await renderBridgeEvent({ type: "stream_text", text: "\n\nI'll start...\n\n" }, {}, ctx); + await renderBridgeEvent({ type: "stream_text", text: "I'll start..." }, {}, ctx); + await renderBridgeEvent({ type: "stream_text", text: "\n \t\n" }, {}, ctx); + assert.deepEqual(notes, ["I'll start..."]); +}); + +test("tool and command events render readable English", async () => { + const { renderBridgeEvent, resetVillaniUiState } = await import("./render.js"); + resetVillaniUiState(); + const notes: string[] = []; + const widgets: any[] = []; + const statuses: string[] = []; + const ctx = { + ui: { + notify: (m: string) => notes.push(m), + setWidget: (_: string, w: any) => widgets.push(w), + setStatus: (_: string, s: string) => statuses.push(s), + }, + }; + await renderBridgeEvent({ type: "tool_started", tool: "Bash" }, {}, ctx); + await renderBridgeEvent({ type: "command_started", command: "python -m pytest -v" }, {}, ctx); + await renderBridgeEvent({ type: "command_finished", command: "python -m pytest -v", exit_code: 1, stderr_preview: "boom" }, {}, ctx); + assert.deepEqual(notes, ["Command finished: exit 1\n\nstderr:\nboom"]); + assert.doesNotMatch(notes.join("\n"), /tool_started|command_started|command_finished|Preparing command|Running command:/); + assert.ok(statuses.some((s) => /^Villani/.test(s))); + assert.ok(widgets.some((w) => String(w).includes("python -m pytest -v"))); +}); + +test("model request clears stale command widget and final clears widget", async () => { + const { renderBridgeEvent, resetVillaniUiState } = await import("./render.js"); + resetVillaniUiState(); + const widgets: any[] = []; + const statuses: string[] = []; + const ctx = { + ui: { + notify: () => {}, + setWidget: (_: string, w: any) => widgets.push(w), + setStatus: (_: string, s: string) => statuses.push(s), + }, + }; + await renderBridgeEvent({ type: "command_started", command: "echo hi" }, {}, ctx); + await renderBridgeEvent({ type: "command_finished", command: "echo hi", exit_code: 0, stdout_preview: "hi" }, {}, ctx); + await renderBridgeEvent({ type: "model_request_started" }, {}, ctx); + await renderBridgeEvent({ type: "run_completed", summary: "final assistant summary" }, {}, ctx); + assert.ok(widgets.some((w) => String(w).includes("Command finished: exit 0"))); + assert.ok(widgets.some((w) => w === undefined)); + assert.ok(statuses.some((s) => /^Villani/.test(s))); +}); + +test("final message includes final assistant summary", async () => { + const { finalMessage } = await import("./render.js"); + const msg = finalMessage({ + type: "run_completed", + summary: "final assistant summary", + changed_files: ["src/a.ts"], + transcript_path: "/tmp/t.jsonl", + }); + assert.match(msg, /Villani completed/); + assert.match(msg, /final assistant summary/); + assert.match(msg, /Changed files:\n- src\/a\.ts/); + assert.match(msg, /Transcript: \/tmp\/t\.jsonl/); +}); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts new file mode 100644 index 00000000..0dd25ff0 --- /dev/null +++ b/integrations/pi-villani/src/index.ts @@ -0,0 +1,633 @@ +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { resolveVillaniRuntime, cacheRoot } from "./runtime.js"; +import { + VILLANI_RUNTIME_TAG, + VILLANI_RUNTIME_VERSION, +} from "./runtimeConfig.js"; +import { VillaniBridgeProcess, type BridgeLaunchOptions } from "./process.js"; +import { + resolvePiModel, + sanitizeError, + startModelProxyFromPiModel, +} from "./modelProxy.js"; +import { + confirm, + finalMessage, + nextVillaniStatus, + notify, + renderBridgeEvent, + resetVillaniUiState, + sendDurableVillaniMessage, + setStatus, + setWidget, +} from "./render.js"; + +type ActiveRun = { + id: string; + abort: AbortController; + bridge?: VillaniBridgeProcess; + proxy?: { close: () => void | Promise }; + pending: Map; +}; +let activeRun: ActiveRun | null = null; +export function buildChildEnvForProxy( + proxyUrl: string, + model: any, +): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const k of Object.keys(env)) { + if ( + ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "VILLANI_API_KEY"].includes(k) || + /(_API_KEY|_TOKEN|_AUTH|_BEARER|TOKEN|AUTH|BEARER)$/i.test(k) + ) + delete env[k]; + } + env.VILLANI_PROVIDER = "openai"; + env.VILLANI_MODEL = model?.id ?? "pi-current-model"; + env.VILLANI_BASE_URL = proxyUrl; + return env; +} +function envConfig() { + return { + provider: process.env.VILLANI_PROVIDER, + model: process.env.VILLANI_MODEL, + base_url: process.env.VILLANI_BASE_URL, + api_key: process.env.VILLANI_API_KEY, + }; +} +export async function resolveModelAuth(ctx: any, model: any) { + if (!ctx.modelRegistry?.getApiKeyAndHeaders) + throw new Error("Pi modelRegistry.getApiKeyAndHeaders is unavailable."); + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); + if (!auth?.ok) + throw new Error( + `Villani could not resolve Pi model authentication: ${sanitizeError(auth?.error ?? "unknown error")}`, + ); + return { apiKey: auth.apiKey, headers: auth.headers }; +} +export async function safeCommand( + ctx: any, + label: string, + fn: () => Promise, +): Promise { + try { + await fn(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await notify(ctx, `${label} failed: ${message}`, "error"); + if (process.env.VILLANI_PI_DEBUG === "1") console.error(error); + } +} +export function approvalTitle(request: any) { + const tool = String(request.tool || ""); + if (tool === "Bash") return "Villani requests command authority"; + if (tool === "Read") return "Villani requests dossier access"; + if (tool === "Write") return "Villani requests edit authority"; + if (tool === "Patch") return "Villani requests patch authority"; + if (tool === "GitStatus") return "Villani requests repository inspection"; + if (tool === "GitDiff") return "Villani requests diff inspection"; + return "Villani requests approval"; +} +export function approvalMessage(request: any) { + const input = + request.input && + typeof request.input === "object" && + !Array.isArray(request.input) + ? request.input + : {}; + + const tool = String(request.tool || "operation"); + const command = typeof input.command === "string" ? input.command : undefined; + const path = + typeof input.path === "string" ? input.path : + typeof input.file_path === "string" ? input.file_path : + typeof request.path === "string" ? request.path : + undefined; + + const lines: string[] = []; + + if (tool === "Bash" && command) lines.push(`Command: ${command}`, ""); + else if ((tool === "Write" || tool === "Patch") && path) lines.push(`File: ${path}`, ""); + else { + lines.push("Operation:", tool, ""); + if (command) lines.push(`Command: ${command}`, ""); + if (path) lines.push(`File: ${path}`, ""); + } + + lines.push("Approve this Villani action?"); + return lines.join("\n"); +} +async function handleApproval(run: ActiveRun, ctx: any, e: any) { + const requestId = e.request_id || e.requestId; + if (!requestId || run.pending.has(requestId)) return; + const tool = String(e.tool || "tool"); + run.pending.set(requestId, false); + let approved = false; + const message = approvalMessage(e); + try { + await setWidget(ctx, undefined); + await setStatus(ctx, nextVillaniStatus("approval", requestId) ?? "Villaniclearance required..."); + approved = await confirm(ctx, approvalTitle(e), message, { + signal: run.abort.signal, + }); + } catch (err) { + approved = false; + await notify( + ctx, + `Villani approval UI failed; denying request: ${err instanceof Error ? err.message : String(err)}`, + "warn", + ); + } finally { + await setWidget(ctx, undefined); + } + if (run.pending.get(requestId) !== false) return; + run.pending.set(requestId, true); + await setStatus(ctx, approved ? "Villani resumes operation..." : "Villani records denial..."); + run.bridge?.respondToApproval(run.id, requestId, approved); + if (process.env.VILLANI_PI_DEBUG === "1") + console.error("[pi-villani bridge] approval response sent"); +} +function denyPending(run: ActiveRun) { + for (const [id, done] of run.pending) { + if (!done) { + run.pending.set(id, true); + run.bridge?.respondToApproval(run.id, id, false); + } + } +} +function bridgeStderr(bridge: VillaniBridgeProcess) { + return bridge.getRecentStderr?.() ?? bridge.stderr ?? ""; +} + +function bridgeLaunchOptions(ctx: any): BridgeLaunchOptions { + return ctx?.bridgeLaunchOptions ?? ctx?.villaniBridgeLaunchOptions ?? {}; +} +function bridgeDiagnosticMessage(e: unknown) { + const msg = sanitizeError(e); + if (/ModuleNotFoundError: No module named ['"]villani_code['"]/.test(msg)) + return `Villani bridge failed because the selected VILLANI_COMMAND could not import villani_code. +From repo root, run: +.\\.venv\\Scripts\\python.exe -m pip install -e . + +${msg}`; + return msg; +} +async function assertBridgePing( + executable: string, + ctx: any, + env?: NodeJS.ProcessEnv, + signal?: AbortSignal, + launchOptions: BridgeLaunchOptions = bridgeLaunchOptions(ctx), +) { + const bridge = new VillaniBridgeProcess(executable, { + ...launchOptions, + env, + startupTimeoutMs: 30000, + cwd: ctx.cwd ?? process.cwd(), + }); + try { + await bridge.waitUntilReady(undefined, signal); + bridge.send({ type: "ping" }); + const pong = await bridge.waitForEvent("pong", 5000, signal); + if (!pong) + throw new Error(`Villani bridge ping timed out. ${bridgeStderr(bridge)}`); + await notify(ctx, "Villani bridge ping succeeded.", "info"); + return true; + } catch (e) { + throw new Error(bridgeDiagnosticMessage(e)); + } finally { + bridge.kill(); + await bridge.waitForExit(1500).catch(() => {}); + } +} +async function waitForRunAcknowledgement( + bridge: VillaniBridgeProcess, + runId: string, + signal?: AbortSignal, +) { + const event = await bridge.waitForAnyEvent( + ["run_started", "error", "run_failed", "run_aborted"], + runId, + 10000, + signal, + ); + if (event?.type === "run_started") return event; + if (event) { + const msg = event.error || event.message || event.summary || event.type; + throw new Error(`Villani bridge rejected run command: ${msg}`); + } + throw new Error( + `Villani bridge did not acknowledge run command within 10 seconds. ${bridgeStderr(bridge)}`, + ); +} +async function waitForFirstProgressAfterStart( + bridge: VillaniBridgeProcess, + runId: string, + signal?: AbortSignal, +) { + const event = await bridge.waitForAnyEvent( + [ + "model_request_started", + "approval_required", + "tool_started", + "phase", + "run_completed", + "run_failed", + "run_aborted", + ], + runId, + 60000, + signal, + ); + if (!event) + throw new Error( + "Villani stalled after run_started before any model/tool/progress event.", + ); + return event; +} +export async function runVillani( + task: string, + pi: any = {}, + ctx: any = pi, +): Promise { + resetVillaniUiState(); + await notify(ctx, "Villani starting...", "info"); + await setStatus(ctx, nextVillaniStatus("thinking", "startup") ?? "Villaniplan forming..."); + if (!task?.trim()) { + await notify(ctx, "/villani requires a task argument", "warn"); + return; + } + if (activeRun) { + await notify( + ctx, + "Villani is already running. Wait for the active run to finish or cancel the session.", + "warn", + ); + return; + } + const runId = randomUUID(); + const abort = new AbortController(); + const run: ActiveRun = { id: runId, abort, pending: new Map() }; + let postToolTimer: NodeJS.Timeout | undefined; + let heartbeatTimer: NodeJS.Timeout | undefined; + let heartbeatSeq = 0; + const clearPostToolTimer = () => { + if (postToolTimer) { + clearTimeout(postToolTimer); + postToolTimer = undefined; + } + }; + const clearHeartbeat = () => { + if (heartbeatTimer) { + clearInterval(heartbeatTimer); + heartbeatTimer = undefined; + } + }; + activeRun = run; + try { + let model: any; + let proxy: any; + const usePi = process.env.VILLANI_USE_PI_MODEL !== "false"; + let config: any; + let env: any; + if (usePi) { + await setStatus(ctx, nextVillaniStatus("analysis", "model-connection") ?? "Villanalysis begins..."); + model = await resolvePiModel(ctx); + const auth = await resolveModelAuth(ctx, model); + await setStatus(ctx, nextVillaniStatus("analysis", "model-connection") ?? "Villanalysis begins..."); + proxy = await startModelProxyFromPiModel({ + model, + apiKey: auth.apiKey, + headers: auth.headers, + signal: abort.signal, + timeoutMs: 300000, + onEvent: (e: any) => void renderBridgeEvent(e, pi, ctx), + }); + run.proxy = proxy; + config = { + provider: "openai", + model: model.id ?? "pi-current-model", + base_url: proxy.url, + pi_model_proxy: true, + }; + env = buildChildEnvForProxy(proxy.url, model); + } else { + model = { id: process.env.VILLANI_MODEL }; + config = envConfig(); + env = process.env; + } + await setStatus(ctx, nextVillaniStatus("thinking", "runtime-start") ?? "Villaniplan forming..."); + const executable = await resolveVillaniRuntime({ signal: abort.signal }); + if (process.env.VILLANI_COMMAND) { + await setStatus(ctx, nextVillaniStatus("thinking", "runtime-start") ?? "Villaniplan forming..."); + await assertBridgePing(executable, ctx, env, abort.signal); + } + await setStatus(ctx, nextVillaniStatus("thinking", "runtime-start") ?? "Villaniplan forming..."); + const bridge = new VillaniBridgeProcess(executable, { + ...bridgeLaunchOptions(ctx), + env, + startupTimeoutMs: 30000, + proxyMode: usePi, + explicitConfigMode: !usePi, + cwd: ctx.cwd ?? process.cwd(), + }); + run.bridge = bridge; + await setStatus(ctx, nextVillaniStatus("thinking", "runtime-start") ?? "Villaniplan forming..."); + await bridge.waitUntilReady(undefined, abort.signal); + await setStatus(ctx, nextVillaniStatus("thinking", "approval-resolved") ?? "Villanithoughts classified..."); + const startPostToolTimer = (eventType: string) => { + clearPostToolTimer(); + postToolTimer = setTimeout(async () => { + try { + bridge.send({ type: "ping", id: `${runId}-post-tool-ping` }); + const pong = await bridge.waitForEvent("pong", 3000, abort.signal); + if (pong) { + const status = nextVillaniStatus("thinking", "post-tool-ping"); + if (status) await setStatus(ctx, status); + } + else + await notify( + ctx, + `Villani bridge did not respond while waiting for next runner event. ${bridgeStderr(bridge)}`, + "error", + ); + } catch (err) { + await notify( + ctx, + `Villani bridge ping failed while waiting: ${sanitizeError(err)} ${bridgeStderr(bridge)}`, + "error", + ); + } + }, 5000); + postToolTimer.unref?.(); + }; + const onBridgeEvent = (e: any) => { + if (process.env.VILLANI_PI_DEBUG === "1" && e.type !== "pong") + console.error(`[pi-villani bridge] event received: ${e.type}`); + if ( + [ + "model_request_started", + "model_request_completed", + "proxy_request_started", + "proxy_request_completed", + "command_started", + "command_finished", + "tool_started", + "approval_required", + "stream_text", + "run_completed", + "run_failed", + "run_aborted", + ].includes(e.type) + ) + clearPostToolTimer(); + if (e.type === "approval_required") void handleApproval(run, ctx, e); + else void renderBridgeEvent(e, pi, ctx); + if (e.type === "tool_result" || e.type === "tool_finished") + startPostToolTimer(e.type); + }; + bridge.off("event", onBridgeEvent); + bridge.on("event", onBridgeEvent); + heartbeatTimer = setInterval(async () => { + try { + const id = `${runId}-heartbeat-${++heartbeatSeq}`; + bridge.send({ type: "ping", id }); + const pong = await bridge.waitForEvent("pong", 3000, abort.signal); + if (!pong) + await notify( + ctx, + `Villani bridge heartbeat failed. ${bridgeStderr(bridge)}`, + "error", + ); + else if (process.env.VILLANI_PI_DEBUG === "1") + console.error("[pi-villani bridge] heartbeat pong"); + } catch (err) { + await notify( + ctx, + `Villani bridge heartbeat failed: ${sanitizeError(err)} ${bridgeStderr(bridge)}`, + "error", + ); + } + }, 15000); + heartbeatTimer.unref?.(); + const runStartedPromise = waitForRunAcknowledgement( + bridge, + runId, + abort.signal, + ); + const progressPromise = waitForFirstProgressAfterStart( + bridge, + runId, + abort.signal, + ); + let finished = false; + const finalPromise = bridge + .waitForFinalEvent(runId, 30 * 60 * 1000, abort.signal) + .then((event) => { + finished = true; + return event; + }); + const exitPromise = bridge.waitForExit(0, abort.signal).then((code) => { + if (!finished && !abort.signal.aborted) { + throw new Error( + `Villani bridge exited before final event with code ${code}. ${bridgeStderr(bridge)}`, + ); + } + return undefined; + }); + bridge.send({ + type: "run", + id: runId, + task, + repo: ctx.cwd ?? process.cwd(), + mode: "runner", + config, + }); + await setStatus(ctx, nextVillaniStatus("thinking", "approval-resolved") ?? "Villanithoughts classified..."); + try { + await runStartedPromise; + } catch (e) { + abort.abort(); + progressPromise.catch(() => {}); + finalPromise.catch(() => {}); + exitPromise.catch(() => {}); + bridge.kill(); + await proxy?.close?.(); + await bridge.waitForExit(1500).catch(() => {}); + throw e; + } + await Promise.race([progressPromise, finalPromise]); + const final = await Promise.race([finalPromise, exitPromise]); + if (final) { + clearPostToolTimer(); + clearHeartbeat(); + await setWidget(ctx, undefined); + await setStatus( + ctx, + final.type === "run_completed" + ? "Completed" + : final.type === "run_aborted" + ? "Failed" + : "Failed", + ); + await sendDurableVillaniMessage(pi, ctx, finalMessage(final), final); + } + } finally { + if (activeRun === run) { + clearPostToolTimer(); + clearHeartbeat(); + denyPending(run); + run.abort.abort(); + run.bridge?.kill(); + await run.proxy?.close?.(); + await run.bridge?.waitForExit(1500).catch(() => {}); + await setStatus(ctx, undefined); + activeRun = null; + } + } +} +export async function proxyTest(ctx: any): Promise { + await notify(ctx, "Villani proxy test starting...", "info"); + const model = await resolvePiModel(ctx); + await notify(ctx, `active model id: ${model?.id ?? "unavailable"}`, "info"); + await notify( + ctx, + `modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders === "function"}`, + "info", + ); + const auth = await resolveModelAuth(ctx, model); + await notify(ctx, "auth resolution succeeded: true", "info"); + const proxy = await startModelProxyFromPiModel({ + model, + apiKey: auth.apiKey, + headers: auth.headers, + timeoutMs: 300000, + completeFn: ctx.completeFn, + completeSource: ctx.completeSource, + completeImporter: ctx.completeImporter, + pi: ctx.pi, + }); + try { + await notify(ctx, `proxy URL: ${proxy.url}`, "info"); + await notify( + ctx, + "POST /v1/chat/completions reached proxy: sending", + "info", + ); + const r = await fetch(proxy.url + "/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: model.id ?? "pi-current-model", + messages: [{ role: "user", content: "Say exactly READY" }], + stream: false, + }), + }); + await notify(ctx, "POST /v1/chat/completions reached proxy: yes", "info"); + await notify( + ctx, + `selected Pi completion helper source: ${proxy.completionSource ?? "unavailable"}`, + "info", + ); + const text = await r.text(); + if (!r.ok) { + await notify( + ctx, + `complete returned or failed: failed (${r.status})`, + "info", + ); + throw new Error(`HTTP ${r.status}: ${text}`); + } + await notify(ctx, "complete returned or failed: returned", "info"); + await notify( + ctx, + `Villani proxy test succeeded: ${text.slice(0, 500)}`, + "info", + ); + } finally { + await proxy.close(); + } +} +export async function abortVillani(ctx: any = {}): Promise { + if (!activeRun) { + await notify(ctx, "No active Villani run.", "info"); + return false; + } + const run = activeRun; + await notify(ctx, "Aborting Villani...", "info"); + denyPending(run); + run.bridge?.abort(run.id); + const aborted = await run.bridge?.waitForEvent("run_aborted", 1500); + run.abort.abort(); + if (!aborted) run.bridge?.kill(); + await run.proxy?.close?.(); + await run.bridge?.waitForExit(1500).catch(() => {}); + await setStatus(ctx, undefined); + if (activeRun === run) activeRun = null; + await notify( + ctx, + aborted ? "Villani aborted." : "Villani abort requested.", + "info", + ); + return true; +} +export async function bridgePing(ctx: any): Promise { + await notify(ctx, "Villani bridge ping starting...", "info"); + const executable = await resolveVillaniRuntime({}); + await notify(ctx, `runtime executable: ${executable}`, "info"); + await assertBridgePing(executable, ctx); +} +async function doctor(ctx: any) { + const cached = (() => { + try { + return existsSync(cacheRoot()); + } catch { + return false; + } + })(); + const lines = [ + `package version: 0.1.5`, + `runtime version: ${VILLANI_RUNTIME_VERSION}`, + `cwd: ${ctx.cwd ?? process.cwd()}`, + `ctx.model exists: ${!!ctx.model}`, + `ctx.model.id: ${ctx.model?.id ?? "unavailable"}`, + `ctx.modelRegistry exists: ${!!ctx.modelRegistry}`, + `ctx.modelRegistry.getApiKeyAndHeaders exists: ${typeof ctx.modelRegistry?.getApiKeyAndHeaders === "function"}`, + `VILLANI_USE_PI_MODEL is set: ${process.env.VILLANI_USE_PI_MODEL !== undefined}`, + `VILLANI_COMMAND is set: ${process.env.VILLANI_COMMAND !== undefined}`, + `runtime tag: ${VILLANI_RUNTIME_TAG}`, + `cached runtime executable exists: ${cached}`, + `active run: ${activeRun ? "yes" : "no"}`, + ]; + if (process.env.VILLANI_COMMAND) { + try { + const executable = await resolveVillaniRuntime({}); + await assertBridgePing(executable, ctx); + } catch (e) { + lines.push( + `VILLANI_COMMAND bridge ping failed: ${bridgeDiagnosticMessage(e)}`, + ); + } + } + await notify(ctx, lines.join("\n"), "info"); +} +export default function activate(api: any) { + const reg = ( + name: string, + description: string, + handler: (args: string, ctx: any) => Promise, + ) => api.registerCommand(name, { description, handler }); + reg("villani", "Run Villani Code on a task", async (args, ctx) => + safeCommand(ctx, "Villani", () => runVillani(args, ctx?.pi ?? api, ctx)), + ); + try { + api.onSessionStart?.(async (ctx: any) => + notify(ctx, "Villani extension loaded"), + ); + api.on?.("sessionStart", async (ctx: any) => + notify(ctx, "Villani extension loaded"), + ); + } catch {} +} +export { notify, setStatus, sendDurableVillaniMessage, confirm }; diff --git a/integrations/pi-villani/src/modelProxy.ts b/integrations/pi-villani/src/modelProxy.ts new file mode 100644 index 00000000..9ab8f7e8 --- /dev/null +++ b/integrations/pi-villani/src/modelProxy.ts @@ -0,0 +1,80 @@ +import http, { type ServerResponse } from 'node:http'; +import type { OpenAIMessage } from './protocol.js'; + +export type PiCompleteResolver = (name:string)=>Promise; +export async function resolvePiComplete(importer:PiCompleteResolver=(name)=>import(name)):Promise<{fn:any;source:string}>{ + const candidates=['@earendil-works/pi-ai','@earendil-works/pi-ai/compat']; + for(const name of candidates){ + try{ + const mod=await importer(name); + const options:[string,any][]=[['complete',(mod as any).complete],['default.complete',(mod as any).default?.complete],['compat.complete',(mod as any).compat?.complete]]; + for(const [exportName,fn] of options) if(typeof fn==='function') return {fn,source:`${name}:${exportName}`}; + }catch{ /* try next candidate */ } + } + throw new Error('No compatible Pi completion helper found in @earendil-works/pi-ai or @earendil-works/pi-ai/compat.'); +} + +type Usage={input:number;output:number;totalTokens?:number}; +type AssistantMessage={role?:'assistant';content?:any[];responseId?:string;timestamp:number;responseModel?:string;model?:string;usage?:Usage;stopReason?:'stop'|'toolUse'|'length'|'error'|'aborted'|string;errorMessage?:string}; +export interface PiModelProxyOptions { model:any; apiKey?:string; headers?:Record; signal?:AbortSignal; timeoutMs?:number; completeFn?:any; completeSource?:string; completeImporter?:PiCompleteResolver; pi?:any; onEvent?:(event:any)=>void; } +export class UpstreamModelError extends Error{constructor(message:string){super(message);this.name='UpstreamModelError';}} +export class AbortRequestError extends Error{constructor(message='Pi model request aborted'){super(message);this.name='AbortRequestError';}} +export function abortError(){return new AbortRequestError();} +export function zeroUsage():Usage{return {input:0,output:0,totalTokens:0};} +export function sanitizeErrorMessage(e:unknown){return String((e as Error)?.message||e).replace(/Bearer\s+\S+/ig,'Bearer [redacted]').replace(/sk-[A-Za-z0-9_-]+/g,'[redacted]').replace(/(OPENAI_API_KEY|ANTHROPIC_API_KEY|VILLANI_API_KEY)=[^\s]+/ig,'$1=[redacted]').replace(/(authorization|api[-_]?key|x-api-key)["':= ]+[^,}\s]+/ig,'$1=[redacted]');} +export const sanitizeError=sanitizeErrorMessage; +function debug(message:string){if(process.env.VILLANI_PI_DEBUG==='1') console.error(`[pi-villani proxy] ${message}`);} +export function resolvePiModel(ctx:any):any{ if(process.env.VILLANI_USE_PI_MODEL==='false') return {id:process.env.VILLANI_MODEL||'villani-env-model', bypass:true}; const m=ctx?.model; if(!m) throw new Error('No active Pi model available. Select or launch Pi with a model, then retry.'); return m; } +function textOf(content:any):string{if(content==null)return ''; if(typeof content==='string')return content; if(Array.isArray(content))return content.map(p=>typeof p==='string'?p:(p?.text??p?.content??'')).join(''); return String(content);} +function timestampOf(m:any){return m?.timestamp??Date.now();} +function contentBlocksOf(m:any){const blocks:any[]=[]; const text=textOf(m.content); if(text) blocks.push({type:'text',text}); for(const c of m.tool_calls||[]) blocks.push({type:'toolCall',...fromOpenAIToolCall(c)}); return blocks;} +function openAIChatToPiContext(request:any){const messages:OpenAIMessage[]=request?.messages||[]; const ctx:any={messages:[],tools:toPiTools(request?.tools)}; const system:string[]=[]; for(const m of messages){if(m.role==='system'){system.push(textOf(m.content)); continue;} if(m.role==='user') ctx.messages.push({role:'user',content:textOf(m.content),timestamp:timestampOf(m)}); else if(m.role==='assistant'){const content=contentBlocksOf(m); ctx.messages.push({role:'assistant',content,api:'openai-completions',provider:'pi',model:m.model,usage:zeroUsage(),stopReason:content.some((block)=>block.type==='toolCall')?'toolUse':'stop',timestamp:timestampOf(m)});} else if(m.role==='tool') ctx.messages.push({role:'toolResult',toolCallId:m.tool_call_id,toolName:m.name??m.toolName,content:[{type:'text',text:textOf(m.content)}],isError:false,timestamp:timestampOf(m)});} if(system.length) ctx.systemPrompt=system.join('\n'); return ctx;} +function toPiContext(messages:OpenAIMessage[]=[], tools:any[]|undefined){return openAIChatToPiContext({messages,tools});} +function toPiTools(tools:any[]|undefined){return tools?.map(t=>t?.function?{name:t.function.name,description:t.function.description,parameters:t.function.parameters}:t);} +function fromOpenAIToolCall(c:any){return {id:c.id,name:c.name??c.function?.name,arguments:parseArgs(c.arguments??c.function?.arguments)};} +function parseArgs(a:any){if(typeof a==='string'){try{return JSON.parse(a);}catch{return a;}} return a??{};} +function normalizeToolArguments(value:unknown):Record{ if(value==null) return {}; if(typeof value==='string'){ const trimmed=value.trim(); if(!trimmed) return {}; try{ const parsed=JSON.parse(trimmed); if(parsed&&typeof parsed==='object'&&!Array.isArray(parsed)) return parsed as Record; return {}; }catch{return {};} } if(typeof value==='object'&&!Array.isArray(value)) return value as Record; return {}; } +function toOpenAIToolCalls(blocks:any[]){return blocks.filter((block)=>block?.type==='toolCall').map((block,index)=>{const rawArguments=block.arguments??block.function?.arguments; return {id:block.id||`call_${index}`,type:'function',function:{name:String(block.name??block.function?.name??''),arguments:JSON.stringify(normalizeToolArguments(rawArguments))}};});} +export function toOpenAIFinishReason(reason:AssistantMessage['stopReason']):string{if(reason==='toolUse')return 'tool_calls'; if(reason==='length')return 'length'; return 'stop';} +export function toOpenAIUsage(usage:Usage):Record{return {prompt_tokens:usage.input,completion_tokens:usage.output,total_tokens:usage.totalTokens||usage.input+usage.output};} +export function piAssistantToOpenAIResponse(message:AssistantMessage):Record{ + if(message.stopReason==='error') throw new UpstreamModelError(message.errorMessage??'Pi model request failed'); + if(message.stopReason==='aborted') throw abortError(); + const content=Array.isArray(message.content)?message.content:[]; + const text=content.filter((block)=>block?.type==='text').map((block)=>String(block.text??'')).join('\n\n'); + const toolCalls=toOpenAIToolCalls(content); + const response={id:message.responseId??`pi-villani-${message.timestamp}`,object:'chat.completion',created:Math.floor(message.timestamp/1000),model:message.responseModel??message.model,choices:[{index:0,message:{role:'assistant',content:text||null,...(toolCalls.length?{tool_calls:toolCalls}:{})},finish_reason:toOpenAIFinishReason(message.stopReason)}],usage:toOpenAIUsage(message.usage??zeroUsage())}; + return response; +} +export function writeOpenAIStream(res:ServerResponse,message:AssistantMessage):void{const response=piAssistantToOpenAIResponse(message) as any; const choice=response.choices[0]; const fullMessage=choice.message; res.writeHead(200,{"content-type":"text/event-stream; charset=utf-8","cache-control":"no-cache",connection:"keep-alive"}); res.write(`data: ${JSON.stringify({id:response.id,object:"chat.completion.chunk",created:response.created,model:response.model,choices:[{index:0,delta:fullMessage,finish_reason:choice.finish_reason}],usage:response.usage})}\n\n`); res.write("data: [DONE]\n\n"); res.end();} +export function writeJson(res:ServerResponse,status:number,body:unknown){res.writeHead(status,{'content-type':'application/json'}); res.end(JSON.stringify(body));} +export function writeProxyError(res:ServerResponse,error:unknown){writeJson(res,500,{error:{message:sanitizeErrorMessage(error),type:'upstream_error',code:error instanceof AbortRequestError?'pi_completion_aborted':'pi_completion_failed'}});} +function normalizeAssistant(raw:any,payload:any,model:any):AssistantMessage{const now=Date.now(); if(Array.isArray(raw?.content)) return {...raw,timestamp:raw.timestamp??now,model:raw.model??payload.model??model?.id??'pi-current-model',usage:raw.usage??zeroUsage()}; const text=raw?.content??raw?.message?.content??raw?.text??''; return {...raw,content:text?[{type:'text',text:String(text)}]:[],timestamp:raw?.timestamp??now,model:raw?.model??payload.model??model?.id??'pi-current-model',usage:raw?.usage??zeroUsage()};} + +export class PiModelProxy { private server?:http.Server; completionSource?:string; constructor(private readonly options:PiModelProxyOptions){this.completionSource=options.completeSource;} + async start():Promise{this.server=http.createServer((req,res)=>void this.handle(req,res)); if(this.options.signal) this.options.signal.addEventListener('abort',()=>void this.stop(),{once:true}); await new Promise(r=>this.server!.listen(0,'127.0.0.1',r)); const addr=this.server.address(); if(!addr||typeof addr==='string') throw new Error('Proxy bind failed'); const url=`http://127.0.0.1:${addr.port}`; debug(`listening on ${url}`); return url;} + async stop():Promise{const s=this.server; if(!s)return; this.server=undefined; await new Promise(r=>s.close(()=>r()));} + private async handle(req:http.IncomingMessage,res:http.ServerResponse){if(req.method!=='POST'||(req.url||'').split('?')[0]!=='/v1/chat/completions'){res.writeHead(404).end();return;} debug('POST /v1/chat/completions'); try{const body=await new Promise((resolve,reject)=>{let b=''; req.on('data',d=>b+=d); req.on('end',()=>resolve(b)); req.on('error',reject);}); const payload=JSON.parse(body||'{}'); const context=openAIChatToPiContext(payload); const assistant=normalizeAssistant(await this.complete(context,payload),payload,this.options.model); if(payload.stream) writeOpenAIStream(res,assistant); else writeJson(res,200,piAssistantToOpenAIResponse(assistant));}catch(e){debug(`Pi complete failed: ${sanitizeErrorMessage(e)}`); writeProxyError(res,e);}} + private async complete(context:any,payload:any){ + debug('calling Pi complete'); + this.options.onEvent?.({type:'proxy_request_started'}); + const completeSafely=async (fn:Function, receiver:any, args:any[])=>{try{const r=await fn.apply(receiver,args); this.options.onEvent?.({type:'proxy_request_completed'}); return r;}catch(e){this.options.onEvent?.({type:'proxy_request_failed', error:sanitizeErrorMessage(e)}); throw e;}}; + const opts={apiKey:this.options.apiKey,headers:this.options.headers,maxTokens:payload.max_tokens,temperature:payload.temperature,signal:this.options.signal,timeoutMs:this.options.timeoutMs}; + let completeFn=this.options.completeFn; + if(typeof completeFn==='function'){ + this.completionSource=this.options.completeSource??'injected:completeFn'; + const r=await completeSafely(completeFn, undefined, [this.options.model,context,opts]); debug('Pi complete returned'); return r; + } + let resolveError:unknown; + try{const resolved=await resolvePiComplete(this.options.completeImporter); completeFn=resolved.fn; this.completionSource=resolved.source;}catch(e){resolveError=e;} + if(typeof completeFn==='function'){const r=await completeSafely(completeFn, undefined, [this.options.model,context,opts]); debug('Pi complete returned'); return r;} + debug(`Pi completion helper unavailable: ${sanitizeErrorMessage(resolveError)}`); + const model=this.options.model; + const fallbacks:[string,any][]=[['model.api.streamSimple',model?.api?.streamSimple],['model.complete',model?.complete],['ctx.pi.complete',this.options.pi?.complete]]; + for(const [source,fn] of fallbacks){if(typeof fn!=='function') continue; this.completionSource=source; const receiver=source==='ctx.pi.complete'?this.options.pi:(source==='model.complete'?model:model?.api); const r=await completeSafely(fn, receiver, [model,context,opts]); debug(`${source} returned`); return r;} + { const error=new Error('Active Pi model cannot be proxied: no supported completion API found.'); this.options.onEvent?.({type:'proxy_request_failed', error:sanitizeErrorMessage(error)}); throw error; } + } +} +export async function startModelProxyFromPiModel(options:PiModelProxyOptions){const p=new PiModelProxy(options); const url=await p.start(); return {url,close:()=>p.stop(),proxy:p,get completionSource(){return p.completionSource;}};} +export const startModelProxy=startModelProxyFromPiModel; +export const _test={toPiContext,openAIChatToPiContext,piAssistantToOpenAIResponse,writeOpenAIStream,sanitizeErrorMessage,toOpenAIToolCalls,normalizeToolArguments}; diff --git a/integrations/pi-villani/src/process.test.ts b/integrations/pi-villani/src/process.test.ts new file mode 100644 index 00000000..33ae3ea4 --- /dev/null +++ b/integrations/pi-villani/src/process.test.ts @@ -0,0 +1,11 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { VillaniBridgeProcess, sanitizedEnv } from './process.js'; +function script(dir:string, body:string){const p=join(dir,'fake-ready-bridge.mjs'); writeFileSync(p,body); return p;} +const readyBridge="process.stdout.write(JSON.stringify({type:'ready',protocol_version:1})+'\\n');\nprocess.stdin.on('data',chunk=>{for(const line of chunk.toString().split(/\\r?\\n/)){if(!line.trim())continue; const msg=JSON.parse(line); if(msg.type==='ping')process.stdout.write(JSON.stringify({type:'pong',id:msg.id})+'\\n'); if(msg.type==='abort')process.stdout.write(JSON.stringify({type:'run_aborted',id:msg.id})+'\\n'); if(msg.type==='approval_response')process.stdout.write(JSON.stringify({type:'approval_resolved',id:msg.id,request_id:msg.request_id,approved:msg.approved})+'\\n');}}); setTimeout(()=>{},500);\n"; +test('VillaniBridgeProcess launches production bridge stdio by default with shell false and paths with spaces work',async()=>{const d=mkdtempSync(join(tmpdir(),'pi space ')); const p=script(d,"if(process.argv[2]!=='bridge'||process.argv[3]!=='--stdio') process.exit(7); process.stdout.write(JSON.stringify({type:'ready'})+'\\n'); setTimeout(()=>{},200);\n"); const b=new VillaniBridgeProcess(process.execPath,{bridgeArgs:[p,'bridge','--stdio']}); await b.waitUntilReady(1000); assert.equal((b.proc as any).spawnfile,process.execPath); assert.equal((b.proc as any).spawnargs[1],p); b.kill(); const prod=new VillaniBridgeProcess(process.execPath); assert.deepEqual(prod.proc.spawnargs.slice(-2),['bridge','--stdio']); assert.equal((prod.proc as any).spawnfile,process.execPath); assert.equal(prod.shell,false); prod.kill();}); +test('test bridge args override can use a fixture script',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=script(d,readyBridge); const b=new VillaniBridgeProcess(process.execPath,{bridgeArgs:[p]}); await b.waitUntilReady(1000); assert.equal((b.proc as any).spawnargs[1],p); b.kill();}); +test('bridge readiness timeout includes stderr',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=script(d,"console.error('bad stderr'); setTimeout(()=>{},300);\n"); const b=new VillaniBridgeProcess(process.execPath,{bridgeArgs:[p]}); await assert.rejects(()=>b.waitUntilReady(800),/bad stderr/); b.kill();}); +test('malformed JSONL kills process',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=script(d,"console.log('nope'); setTimeout(()=>{},500);\n"); const b=new VillaniBridgeProcess(process.execPath,{bridgeArgs:[p]}); await assert.rejects(()=>new Promise((_,rej)=>b.once('error',rej)),/Malformed JSONL/); assert.equal(b.proc.killed,true);}); +test('abort and approval response send correct JSONL',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=script(d,readyBridge); const b=new VillaniBridgeProcess(process.execPath,{bridgeArgs:[p]}); await b.waitUntilReady(1000); const got:any[]=[]; b.on('event',e=>{if(e.type!=='ready')got.push(e)}); b.abort('r'); b.respondToApproval('r','r:1',true); await new Promise(r=>setTimeout(r,50)); b.kill(); assert.deepEqual(got,[{type:'run_aborted',id:'r'},{type:'approval_resolved',id:'r',request_id:'r:1',approved:true}]);}); +test('sanitizes secrets only in proxy mode and explicit config preserves env',()=>{const env:any={OPENAI_API_KEY:'x',ANTHROPIC_API_KEY:'a',VILLANI_API_KEY:'v',CUSTOM_TOKEN:'y',X_AUTH:'z',Y_BEARER:'b',PATH:'p'}; const e=sanitizedEnv({proxyMode:true,env}); assert.equal(e.OPENAI_API_KEY,undefined); assert.equal(e.ANTHROPIC_API_KEY,undefined); assert.equal(e.VILLANI_API_KEY,undefined); assert.equal(e.CUSTOM_TOKEN,undefined); assert.equal(e.X_AUTH,undefined); assert.equal(e.Y_BEARER,undefined); assert.equal(e.PATH,'p'); assert.equal(sanitizedEnv({proxyMode:true,explicitConfigMode:true,env}).VILLANI_API_KEY,'v');}); + +test('waitForFinalEvent rejects when bridge exits before final event',async()=>{const d=mkdtempSync(join(tmpdir(),'pi-')); const p=script(d,"process.stdout.write(JSON.stringify({type:'ready'})+'\\n'); console.error('early boom'); setTimeout(()=>process.exit(4),30);\n"); const b=new VillaniBridgeProcess(process.execPath,{bridgeArgs:[p]}); await b.waitUntilReady(1000); await assert.rejects(()=>b.waitForFinalEvent('r',1000),/exited before final event with code 4.*early boom/s);}); diff --git a/integrations/pi-villani/src/process.ts b/integrations/pi-villani/src/process.ts new file mode 100644 index 00000000..f8247d3e --- /dev/null +++ b/integrations/pi-villani/src/process.ts @@ -0,0 +1,14 @@ +import { spawn, ChildProcessWithoutNullStreams } from 'node:child_process'; import { EventEmitter } from 'node:events'; import type { BridgeEvent } from './protocol.js'; +export interface BridgeLaunchOptions{executableOverride?:string; bridgeArgsOverride?:string[]} export interface BridgeProcessOptions extends BridgeLaunchOptions{proxyMode?:boolean; explicitConfigMode?:boolean; env?:NodeJS.ProcessEnv; cwd?:string; startupTimeoutMs?:number; bridgeArgs?:string[]} export type LaunchOptions=BridgeProcessOptions; +export function sanitizedEnv(opts:BridgeProcessOptions={}):NodeJS.ProcessEnv{const e={...(opts.env||process.env)}; if(opts.proxyMode&&!opts.explicitConfigMode){for(const k of Object.keys(e)){if(['OPENAI_API_KEY','ANTHROPIC_API_KEY','VILLANI_API_KEY'].includes(k)||/(_API_KEY|_TOKEN|_AUTH|_BEARER|TOKEN|AUTH|BEARER)$/i.test(k)) delete e[k];}} return e;} +export class VillaniBridgeProcess extends EventEmitter{proc:ChildProcessWithoutNullStreams; ready=false; stderr=''; buffer=''; startupTimeoutMs:number; readonly shell=false; finals=new Map(); private exitInfo?:{code:number|null;signal:NodeJS.Signals|null}; + constructor(executable:string, opts:BridgeProcessOptions={}){super(); const launchExecutable=opts.executableOverride??executable; const args=opts.bridgeArgsOverride??opts.bridgeArgs??['bridge','--stdio']; this.startupTimeoutMs=opts.startupTimeoutMs??30000; this.proc=spawn(launchExecutable,args,{shell:false,env:sanitizedEnv(opts),cwd:opts.cwd}); this.proc.stderr.on('data',d=>{this.stderr=(this.stderr+d.toString()).slice(-8000)}); this.proc.stdout.on('data',d=>this.onout(d.toString())); this.proc.once('error',e=>this.emit('error',e)); this.proc.once('exit',(code,signal)=>{this.exitInfo={code,signal}; this.emit('exit',{code,signal});});} + getRecentStderr(){return this.stderr;} + onout(s:string){this.buffer+=s; let i; while((i=this.buffer.indexOf('\n'))>=0){const line=this.buffer.slice(0,i); this.buffer=this.buffer.slice(i+1); if(!line.trim())continue; try{const e=JSON.parse(line); if(e.type==='ready')this.ready=true; if(['run_completed','run_failed','run_aborted'].includes(e.type)&&e.id)this.finals.set(e.id,e); this.emit('event',e);}catch{const err=new Error('Malformed JSONL from bridge'); this.kill(); this.emit('error',err);}}} + send(c:unknown){try{if(this.proc.stdin.destroyed||!this.proc.stdin.writable)return false; return this.proc.stdin.write(JSON.stringify(c)+'\n');}catch(e){this.emit('error',e); return false;}} abort(runId:string){this.send({type:'abort',id:runId});} respondToApproval(runId:string,requestId:string,approved:boolean){this.send({type:'approval_response',id:runId,request_id:requestId,approved});} kill(){if(!this.proc.killed)this.proc.kill();} + private timeout(timeoutMs:number|undefined, fn:()=>void){if(!timeoutMs)return undefined; const t=setTimeout(fn,timeoutMs); t.unref?.(); return t;} + waitForEvent(type:string,timeoutMs=1000,signal?:AbortSignal){return new Promise((res,rej)=>{if(this.exitInfo)return res(undefined); if(signal?.aborted)return res(undefined); let done=false; const cleanup=()=>{if(done)return false; done=true; this.off('event',on); this.off('error',onError); this.proc?.off('exit',onExit as any); this.off('exit',onExit as any); signal?.removeEventListener('abort',onAbort); if(t)clearTimeout(t); return true;}; const finish=(value?:BridgeEvent)=>{if(cleanup())res(value);}; const fail=(e:Error)=>{if(cleanup())rej(e);}; const on=(e:BridgeEvent)=>{if(e.type===type)finish(e);}; const onError=(e:Error)=>fail(e); const onExit=()=>finish(undefined); const onAbort=()=>finish(undefined); const t=this.timeout(timeoutMs,()=>finish(undefined)); this.on('event',on); this.once('error',onError); if(this.proc)this.proc.once('exit',onExit as any); this.once('exit',onExit as any); signal?.addEventListener('abort',onAbort,{once:true});});} + waitForAnyEvent(types:string[],runId?:string,timeoutMs=1000,signal?:AbortSignal){return new Promise((res,rej)=>{if(this.exitInfo)return res(undefined); if(signal?.aborted)return res(undefined); const wanted=new Set(types); let done=false; const cleanup=()=>{if(done)return false; done=true; this.off('event',on); this.off('error',onError); this.proc?.off('exit',onExit as any); this.off('exit',onExit as any); signal?.removeEventListener('abort',onAbort); if(t)clearTimeout(t); return true;}; const finish=(value?:BridgeEvent)=>{if(cleanup())res(value);}; const fail=(e:Error)=>{if(cleanup())rej(e);}; const on=(e:BridgeEvent)=>{if(wanted.has(e.type)&&(!runId||!e.id||e.id===runId))finish(e);}; const onError=(e:Error)=>fail(e); const onExit=()=>finish(undefined); const onAbort=()=>finish(undefined); const t=this.timeout(timeoutMs,()=>finish(undefined)); this.on('event',on); this.once('error',onError); if(this.proc)this.proc.once('exit',onExit as any); this.once('exit',onExit as any); signal?.addEventListener('abort',onAbort,{once:true});});} + waitUntilReady(timeoutMs=this.startupTimeoutMs,signal?:AbortSignal){return new Promise((res,rej)=>{if(this.ready)return res(); if(this.exitInfo)return rej(new Error(`Bridge exited before ready. stderr: ${this.stderr}`)); if(signal?.aborted)return rej(new Error('Bridge readiness cancelled.')); let done=false; const finish=(err?:Error)=>{if(done)return; done=true; clearTimeout(t); this.off('event',on); this.off('error',onError); this.proc?.off('exit',onExit as any); this.off('exit',onExit as any); signal?.removeEventListener('abort',onAbort); err?rej(err):res();}; const t=setTimeout(()=>finish(new Error(`Bridge readiness timeout. stderr: ${this.stderr}`)),timeoutMs); t.unref?.(); const on=(e:BridgeEvent)=>{if(e.type==='ready')finish();}; const onError=(e:Error)=>finish(e); const onExit=()=>finish(new Error(`Bridge exited before ready. stderr: ${this.stderr}`)); const onAbort=()=>finish(new Error('Bridge readiness cancelled.')); this.on('event',on); this.once('error',onError); if(this.proc)this.proc.once('exit',onExit as any); this.once('exit',onExit as any); signal?.addEventListener('abort',onAbort,{once:true});});} + waitForFinalEvent(runId:string,timeoutMs=0,signal?:AbortSignal){return new Promise((res,rej)=>{const existing=this.finals.get(runId); if(existing)return res(existing); if(this.exitInfo)return rej(new Error(`Villani bridge exited before final event with code ${this.exitInfo.code}. ${this.stderr}`)); if(signal?.aborted)return rej(new Error('Cancelled waiting for Villani final event')); let done=false; const cleanup=()=>{if(done)return false; done=true; this.off('event',on); this.off('error',onError); this.proc?.off('exit',onExit as any); this.off('exit',onExit as any); signal?.removeEventListener('abort',onAbort); if(t)clearTimeout(t); return true;}; const ok=(e:BridgeEvent)=>{if(cleanup())res(e);}; const fail=(e:Error)=>{if(cleanup())rej(e);}; const on=(e:BridgeEvent)=>{if(['run_completed','run_failed','run_aborted'].includes(e.type)&&e.id===runId)ok(e);}; const onError=(e:Error)=>fail(e); const onExit=(codeOrInfo:any)=>{const code=typeof codeOrInfo==='object'?codeOrInfo?.code:codeOrInfo; fail(new Error(`Villani bridge exited before final event with code ${code}. ${this.stderr}`));}; const onAbort=()=>fail(new Error('Cancelled waiting for Villani final event')); const t=this.timeout(timeoutMs,()=>fail(new Error('Timed out waiting for Villani final event'))); this.on('event',on); this.once('error',onError); if(this.proc)this.proc.once('exit',onExit as any); this.once('exit',onExit as any); signal?.addEventListener('abort',onAbort,{once:true});});} + waitForExit(timeoutMs=0,signal?:AbortSignal){return new Promise((res)=>{if(this.exitInfo)return res(this.exitInfo.code); if(signal?.aborted)return res(undefined); let done=false; const cleanup=()=>{if(done)return false; done=true; this.proc?.off('exit',onExit as any); this.off('exit',onExit as any); signal?.removeEventListener('abort',onAbort); if(t)clearTimeout(t); return true;}; const finish=(code?:number|null)=>{if(cleanup())res(code);}; const onExit=(codeOrInfo:any)=>finish(typeof codeOrInfo==='object'?codeOrInfo?.code:codeOrInfo); const onAbort=()=>finish(undefined); const t=this.timeout(timeoutMs,()=>finish(undefined)); if(this.proc)this.proc.once('exit',onExit as any); this.once('exit',onExit as any); signal?.addEventListener('abort',onAbort,{once:true});});}} diff --git a/integrations/pi-villani/src/protocol.ts b/integrations/pi-villani/src/protocol.ts new file mode 100644 index 00000000..3ea2a4e0 --- /dev/null +++ b/integrations/pi-villani/src/protocol.ts @@ -0,0 +1,9 @@ +export type BridgeEventType = + | 'tool_started' | 'tool_finished' | 'command_started' | 'command_finished' | 'tool_result' + | 'runner_heartbeat' | 'proxy_request_started' | 'proxy_request_completed' | 'proxy_request_failed' + | 'model_request_started' | 'model_request_completed' | 'model_request_failed' + | 'approval_required' | 'run_completed' | 'run_failed' | 'run_aborted' + | 'run_started' | 'phase' | 'bridge_diagnostic' | 'stream_text' | 'error'; +export interface BridgeEvent { type: BridgeEventType | string; [key: string]: any } +export interface OpenAIMessage { role: string; content?: any; tool_call_id?: string; tool_calls?: any[]; [key:string]: any } +export interface PiCompletionResult { text: string; tool_calls: any[] } diff --git a/integrations/pi-villani/src/proxy.test.ts b/integrations/pi-villani/src/proxy.test.ts new file mode 100644 index 00000000..7d326fa7 --- /dev/null +++ b/integrations/pi-villani/src/proxy.test.ts @@ -0,0 +1,44 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; import { resolvePiComplete, resolvePiModel, startModelProxy, zeroUsage, _test } from './modelProxy.js'; +import { resolveModelAuth, proxyTest } from './index.js'; + +test('model resolution uses ctx.model and fails clearly without one',()=>{assert.throws(()=>resolvePiModel({}),/No active Pi model/); assert.equal(resolvePiModel({model:{id:'m'} as any}).id,'m');}); +test('resolveModelAuth calls ctx.modelRegistry.getApiKeyAndHeaders(model)',async()=>{const model={id:'m'}; let seen; const auth=await resolveModelAuth({modelRegistry:{getApiKeyAndHeaders:async(m:any)=>(seen=m,{ok:true,apiKey:'k',headers:{h:'v'}})}},model); assert.equal(seen,model); assert.deepEqual(auth,{apiKey:'k',headers:{h:'v'}});}); +test('auth failure throws clear sanitized error',async()=>{await assert.rejects(()=>resolveModelAuth({modelRegistry:{getApiKeyAndHeaders:async()=>({ok:false,error:'bad Bearer abc sk-secret OPENAI_API_KEY=abc'})}},{id:'m'}),/Villani could not resolve Pi model authentication: bad Bearer \[redacted\] \[redacted\] OPENAI_API_KEY=\[redacted\]/);}); +test('proxy uses resolved complete path and passes auth',async()=>{let opts:any; const proxy=await startModelProxy({model:{id:'m',api:{streamSimple:async function*(){throw new Error('not preferred');}}},apiKey:'key',headers:{Authorization:'Bearer secret'},completeFn:(async(_m:any,ctx:any,o:any)=>{opts=o; assert.equal(ctx.systemPrompt,'sys'); assert.equal(ctx.messages[0].role,'user'); assert.equal(ctx.messages[0].content,'hi'); assert.equal(typeof ctx.messages[0].timestamp,'number'); return {content:'ok',usage:{input:2,output:1,totalTokens:3}};}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'system',content:'sys'},{role:'user',content:'hi'}]})}); const j:any=await r.json(); assert.equal(j.choices[0].message.content,'ok'); assert.deepEqual(j.usage,{prompt_tokens:2,completion_tokens:1,total_tokens:3}); assert.equal(opts.apiKey,'key'); assert.deepEqual(opts.headers,{Authorization:'Bearer secret'});}finally{await proxy.close();}}); +test('unsupported paths return 404',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:''}) as any}); try{const r=await fetch(proxy.url+'/nope',{method:'POST'}); assert.equal(r.status,404);}finally{await proxy.close();}}); +test('OpenAI messages and tools convert into old Pi Context shape',async()=>{let seen:any; const assistantUsage={input:4,output:2,totalTokens:6}; const proxy=await startModelProxy({model:{id:'m'},completeFn:(async(_m:any,ctx:any)=>{seen=ctx; return {content:'ok'};}) as any}); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({messages:[{role:'user',content:'hi'},{role:'assistant',content:'use',model:'gpt-test',usage:assistantUsage,stopReason:'toolUse',tool_calls:[{id:'c1',function:{name:'doit',arguments:'{"x":1}'}}]},{role:'tool',tool_call_id:'c1',name:'doit',content:'result'}],tools:[{type:'function',function:{name:'doit',description:'d',parameters:{type:'object'}}}]})}); assert.equal(seen.messages[0].role,'user'); assert.equal(seen.messages[0].content,'hi'); assert.equal(typeof seen.messages[0].timestamp,'number'); assert.equal(seen.messages[1].role,'assistant'); assert.deepEqual(seen.messages[1].content,[{type:'text',text:'use'},{type:'toolCall',id:'c1',name:'doit',arguments:{x:1}}]); assert.equal(seen.messages[1].api,'openai-completions'); assert.equal(seen.messages[1].provider,'pi'); assert.equal(seen.messages[1].model,'gpt-test'); assert.deepEqual(seen.messages[1].usage,zeroUsage()); assert.equal(seen.messages[1].stopReason,'toolUse'); assert.equal(typeof seen.messages[1].timestamp,'number'); assert.deepEqual(seen.messages[2].content,[{type:'text',text:'result'}]); assert.equal(seen.messages[2].role,'toolResult'); assert.equal(seen.messages[2].toolCallId,'c1'); assert.equal(seen.messages[2].toolName,'doit'); assert.equal(seen.messages[2].isError,false); assert.equal(typeof seen.messages[2].timestamp,'number'); assert.deepEqual(seen.tools[0],{name:'doit',description:'d',parameters:{type:'object'}}); assert.equal('toolCalls' in seen.messages[1],false); assert.equal('inputSchema' in seen.tools[0],false);}finally{await proxy.close();}}); +test('Pi AssistantMessage toolCall converts to OpenAI tool_calls',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'toolCall',id:'c1',name:'doit',arguments:{x:1}}],stopReason:'toolUse'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(j.choices[0].finish_reason,'tool_calls'); assert.equal(j.choices[0].message.tool_calls[0].function.name,'doit'); assert.equal(j.choices[0].message.tool_calls[0].function.arguments,'{"x":1}');}finally{await proxy.close();}}); + +test('Pi finish reasons map to OpenAI values',async()=>{for(const [stopReason,toolCalls,expected] of [['toolUse',true,'tool_calls'],['length',false,'length'],['stop',false,'stop'],[undefined,true,'stop'],[undefined,false,'stop']] as const){const content=toolCalls?[{type:'toolCall',id:'c1',name:'doit',arguments:{}}]:'ok'; const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content,stopReason}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(j.choices[0].finish_reason,expected); assert.notEqual(j.choices[0].finish_reason,'toolUse');}finally{await proxy.close();}}}); + + +test('non-streaming proxy response exactly matches Pi AssistantMessage conversion shape',async()=>{const assistant:any={content:[{type:'text',text:'hello'},{type:'text',text:'world'}],timestamp:1700000000000,responseId:'resp_1',responseModel:'pi-response-model',model:'pi-model',usage:{input:2,output:3,totalTokens:5},stopReason:'stop'}; const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>assistant}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.deepEqual(j,_test.piAssistantToOpenAIResponse(assistant) as any); assert.equal(j.choices[0].message.content,'hello\n\nworld');}finally{await proxy.close();}}); + +test('streaming proxy response preserves old OpenAI chunk shape',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'text',text:'hello'}],timestamp:1700000000000,model:'m',usage:{input:1,output:1,totalTokens:2},stopReason:'stop'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({stream:true})}); const txt=await r.text(); const chunk=JSON.parse(txt.split('\n').find((line)=>line.startsWith('data: {'))!.slice('data: '.length)); assert.equal(chunk.object,'chat.completion.chunk'); assert.equal(chunk.choices[0].delta.role,'assistant'); assert.equal(typeof chunk.choices[0].delta.content,'string'); assert.ok(['stop','length','tool_calls'].includes(chunk.choices[0].finish_reason)); assert.deepEqual(chunk.usage,{prompt_tokens:1,completion_tokens:1,total_tokens:2}); assert.match(txt,/data: \[DONE\]/);}finally{await proxy.close();}}); + +test('response conversion does not expose simplified assistantFromPi path',()=>{assert.equal('assistantFromPi' in _test,false);}); + +test('streaming mode emits one SSE chunk plus DONE',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:'hi'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({stream:true})}); const txt=await r.text(); assert.match(txt,/data: .*hi/); assert.match(txt,/data: \[DONE\]/);}finally{await proxy.close();}}); + +test('Pi stopReason error and aborted become upstream errors',async()=>{for(const stopReason of ['error','aborted']){const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'text',text:'bad'}],stopReason}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const j:any=await r.json(); assert.equal(r.status,500); assert.equal(j.error.type,'upstream_error'); assert.match(j.error.message,stopReason==='aborted'?/aborted/:/Pi model request failed/);}finally{await proxy.close();}}}); +test('upstream errors are sanitized and credentials are not exposed',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>{throw new Error('failed Bearer abc sk-secret OPENAI_API_KEY=abc Authorization: xyz');}}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const txt=await r.text(); assert.equal(r.status,500); assert.doesNotMatch(txt,/Bearer abc|sk-secret|OPENAI_API_KEY=abc|Authorization: xyz/);}finally{await proxy.close();}}); +test('abort signal is passed into Pi complete',async()=>{const ac=new AbortController(); let saw:AbortSignal|undefined; const proxy=await startModelProxy({model:{id:'m'},signal:ac.signal,completeFn:(async(_m:any,_ctx:any,o:any)=>{saw=o.signal; return {content:'ok'};}) as any}); try{await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); assert.equal(saw,ac.signal); assert.deepEqual(zeroUsage(),{input:0,output:0,totalTokens:0});}finally{await proxy.close();}}); +test('/villani-proxy-test sends real request using mocked complete function, reports selected helper, and hides credentials',async()=>{const notes:string[]=[]; const model={id:'m'}; await proxyTest({model,modelRegistry:{getApiKeyAndHeaders:async()=>({ok:true,apiKey:'sk-secret',headers:{Authorization:'Bearer abc'}})},ui:{notify:(m:string)=>notes.push(m)}, completeFn:async()=>({content:'READY'}),completeSource:'test:complete'}); const msg=notes.join('\n'); assert.match(msg,/Villani proxy test succeeded/); assert.match(msg,/selected Pi completion helper source: test:complete/); assert.match(msg,/active model id: m/); assert.match(msg,/auth resolution succeeded: true/); assert.doesNotMatch(msg,/sk-secret|Bearer abc/);}); + +test('resolvePiComplete finds top-level complete',async()=>{const fn=()=>{}; const r=await resolvePiComplete(async()=>({complete:fn})); assert.equal(r.fn,fn); assert.equal(r.source,'@earendil-works/pi-ai:complete');}); +test('resolvePiComplete finds default.complete',async()=>{const fn=()=>{}; const r=await resolvePiComplete(async()=>({default:{complete:fn}})); assert.equal(r.fn,fn); assert.equal(r.source,'@earendil-works/pi-ai:default.complete');}); +test('resolvePiComplete finds compat.complete',async()=>{const fn=()=>{}; const r=await resolvePiComplete(async()=>({compat:{complete:fn}})); assert.equal(r.fn,fn); assert.equal(r.source,'@earendil-works/pi-ai:compat.complete');}); +test('resolvePiComplete tries compat package if primary lacks complete',async()=>{const fn=()=>{}; const seen:string[]=[]; const r=await resolvePiComplete(async(name)=>{seen.push(name); return name.endsWith('/compat')?{complete:fn}:{};}); assert.deepEqual(seen,['@earendil-works/pi-ai','@earendil-works/pi-ai/compat']); assert.equal(r.source,'@earendil-works/pi-ai/compat:complete');}); +test('no compatible helper produces a clear visible error',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeImporter:async()=>({})}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:'{}'}); const txt=await r.text(); assert.equal(r.status,500); assert.match(txt,/Active Pi model cannot be proxied: no supported completion API found/); assert.doesNotMatch(txt,/PiAI\.complete is unavailable/);}finally{await proxy.close();}}); + +test('toOpenAIToolCalls converts object arguments to JSON object string',()=>{const calls=(_test as any).toOpenAIToolCalls([{type:'toolCall',id:'c1',name:'doit',arguments:{x:1}}]); assert.equal(calls[0].function.arguments,'{"x":1}');}); + +test('toOpenAIToolCalls converts JSON string object arguments to JSON object string',()=>{const calls=(_test as any).toOpenAIToolCalls([{type:'toolCall',id:'c1',name:'doit',arguments:'{"path":"x"}'}]); assert.equal(calls[0].function.arguments,'{"path":"x"}');}); + +test('toOpenAIToolCalls converts invalid string arguments to empty object string',()=>{const calls=(_test as any).toOpenAIToolCalls([{type:'toolCall',id:'c1',name:'doit',arguments:'repo'}]); assert.equal(calls[0].function.arguments,'{}');}); + +test('toOpenAIToolCalls converts array string and number arguments to empty object string',()=>{for(const value of [[1], '"repo"', 42]){const calls=(_test as any).toOpenAIToolCalls([{type:'toolCall',id:'c1',name:'doit',arguments:value}]); assert.equal(calls[0].function.arguments,'{}');}}); + +test('streaming tool call response never emits raw non-object arguments',async()=>{const proxy=await startModelProxy({model:{id:'m'},completeFn:async()=>({content:[{type:'toolCall',id:'c1',name:'doit',arguments:'repo'}],stopReason:'toolUse'}) as any}); try{const r=await fetch(proxy.url+'/v1/chat/completions',{method:'POST',body:JSON.stringify({stream:true})}); const txt=await r.text(); assert.doesNotMatch(txt,/"arguments":"repo"/); assert.match(txt,/"arguments":"\{\}"/);}finally{await proxy.close();}}); + +test('proxy text response conversion remains unchanged',()=>{const assistant:any={content:[{type:'text',text:'hello'},{type:'text',text:'world'}],timestamp:1700000000000,responseId:'resp_1',responseModel:'pi-response-model',usage:{input:2,output:3,totalTokens:5},stopReason:'stop'}; const converted:any=_test.piAssistantToOpenAIResponse(assistant); assert.equal(converted.choices[0].message.content,'hello\n\nworld'); assert.equal(converted.choices[0].message.tool_calls,undefined); assert.deepEqual(converted.usage,{prompt_tokens:2,completion_tokens:3,total_tokens:5});}); diff --git a/integrations/pi-villani/src/render.test.ts b/integrations/pi-villani/src/render.test.ts new file mode 100644 index 00000000..f25c1030 --- /dev/null +++ b/integrations/pi-villani/src/render.test.ts @@ -0,0 +1,172 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + cleanAssistantText, + reduceVillaniUiState, + renderBridgeEvent, + nextVillaniStatus, + resetVillaniCopyCounters, + resetVillaniUiState, + toolStartedMessage, + villaniCopy, +} from './render.js'; +import { approvalMessage, approvalTitle } from './index.js'; + +function ctxRecorder() { + const statuses: string[] = []; + const widgets: any[] = []; + const notifications: string[] = []; + return { + statuses, + widgets, + notifications, + ctx: { ui: { + setStatus: async (_key: string, value: string) => { statuses.push(value); }, + setWidget: async (_key: string, value: any) => { widgets.push(value); }, + notify: async (message: string) => { notifications.push(message); }, + } }, + }; +} + +test('toolStartedMessage for Read with path does not say unknown', () => { + const msg = toolStartedMessage({ type: 'tool_started', tool: 'Read', path: 'src/foo.py' }); + assert.match(msg, /File: src\/foo.py/); + assert.doesNotMatch(msg, /unknown/i); +}); + +test('toolStartedMessage for Read without path does not say unknown', () => { + const msg = toolStartedMessage({ type: 'tool_started', tool: 'Read' }); + assert.equal(msg, 'Villani reads file. File nervous.'); + assert.doesNotMatch(msg, /unknown/i); +}); + +test('Read event sets reading Villani copy', () => { + resetVillaniCopyCounters(); + const state = reduceVillaniUiState({ phase: 'x' }, { type: 'tool_started', tool: 'Read', path: 'a.ts' }); + assert.equal(state.phase, 'Villani reads file. File nervous.'); +}); + +test('Write/Patch event sets writing Villani copy', () => { + resetVillaniCopyCounters(); + assert.equal(reduceVillaniUiState({ phase: 'x' }, { type: 'tool_started', tool: 'Write' }).phase, 'Villani makes file obey...'); + assert.equal(reduceVillaniUiState({ phase: 'x' }, { type: 'tool_started', tool: 'Patch' }).phase, 'Villanipatch imposed...'); +}); + +test('Bash command event sets running Villani copy', () => { + resetVillaniCopyCounters(); + const state = reduceVillaniUiState({ phase: 'x' }, { type: 'tool_started', tool: 'Bash', input: { command: 'echo hi' } }); + assert.equal(state.phase, 'Villani gives command...'); +}); + +test('pytest command event sets testing Villani copy', () => { + resetVillaniCopyCounters(); + const state = reduceVillaniUiState({ phase: 'x' }, { type: 'tool_started', tool: 'Bash', input: { command: 'python -m pytest -q' } }); + assert.equal(state.phase, 'Villani begins inspection...'); +}); + +test('run_completed uses complete Villani copy', () => { + resetVillaniCopyCounters(); + assert.equal(reduceVillaniUiState({ phase: 'x' }, { type: 'run_completed' }).phase, 'Villanified. Accept result.'); +}); + +test('run_failed uses failure Villani copy', () => { + resetVillaniCopyCounters(); + assert.equal(reduceVillaniUiState({ phase: 'x' }, { type: 'run_failed', error: 'boom' }).phase, 'Villani sees failure. Unacceptable.'); +}); + +test('villaniCopy rotates deterministically', () => { + resetVillaniCopyCounters(); + assert.equal(villaniCopy('thinking'), 'Villani is make plan...'); + assert.equal(villaniCopy('thinking'), 'Villaniplan forming...'); + assert.equal(villaniCopy('thinking'), 'Villani thinks. Nobody interrupt.'); +}); + +test('stream_text renders clean assistant text unchanged', async () => { + resetVillaniUiState(); + const rec = ctxRecorder(); + await renderBridgeEvent({ type: 'stream_text', text: "\n\nI'll start by exploring the repository structure and finding the failing tests.\n\n" }, {}, rec.ctx); + assert.deepEqual(rec.notifications, ["I'll start by exploring the repository structure and finding the failing tests."]); +}); + +test('cleanAssistantText trims without Villani prefix', () => { + assert.equal(cleanAssistantText('\n\nhello\n\n'), 'hello'); +}); + +test('approval message remains literal and readable', () => { + const request = { tool: 'Bash', summary: 'Run command', input: { command: 'python -m pytest -v' } }; + assert.equal(approvalTitle(request), 'Villani requests command authority'); + const msg = approvalMessage(request); + assert.doesNotMatch(msg, /Villani requests command authority/); + assert.match(msg, /Command: python -m pytest -v/); + assert.match(msg, /Approve this Villani action\?/); + assert.doesNotMatch(msg, /Allow this operation\?/); + assert.doesNotMatch(msg, /\[object Object\]/); +}); + + +test('repeated model_request_started only updates status once within 12 seconds', () => { + resetVillaniCopyCounters(); + const first = reduceVillaniUiState({ phase: 'x' }, { type: 'model_request_started' }); + const second = reduceVillaniUiState(first, { type: 'model_request_started' }); + assert.equal(first.phase, 'Villani is make plan...'); + assert.equal(second.phase, first.phase); +}); + +test('proxy_request_started does not spam new thinking copy when already thinking', () => { + resetVillaniCopyCounters(); + const first = reduceVillaniUiState({ phase: 'x' }, { type: 'model_request_started' }); + const second = reduceVillaniUiState(first, { type: 'proxy_request_started' }); + assert.equal(second.phase, first.phase); +}); + +test('approval_required uses approval category copy and widget avoids pending approval', async () => { + resetVillaniCopyCounters(); + const rec = ctxRecorder(); + await renderBridgeEvent({ type: 'approval_required', request_id: 'r1', tool: 'Read', summary: 'Read' }, {}, rec.ctx); + assert.equal(rec.statuses.at(-1), 'Villani requires authorization...'); + assert.deepEqual(rec.widgets, [undefined]); + assert.doesNotMatch(JSON.stringify(rec.widgets), /Pending approval|Villaniclearance required|Allow this operation|\[object Object\]/); +}); + +test('approval titles use Villani-style copy for Read and Bash', () => { + assert.equal(approvalTitle({ tool: 'Read' }), 'Villani requests dossier access'); + assert.equal(approvalTitle({ tool: 'Bash' }), 'Villani requests command authority'); +}); + +test('approval message for Read without path is readable', () => { + const msg = approvalMessage({ tool: 'Read', input: {} }); + assert.doesNotMatch(msg, /Villani requests dossier access/); + assert.match(msg, /Operation:\nRead/); + assert.match(msg, /Approve this Villani action\?/); + assert.doesNotMatch(msg, /unknown|\[object Object\]|Allow this operation\?/i); +}); + + +test('approval_resolved accepted and denied clear widget with short continuation status', async () => { + for (const [approved, expected] of [[true, 'Villani resumes operation...'], [false, 'Villani records denial...']] as const) { + resetVillaniUiState(); + const rec = ctxRecorder(); + await renderBridgeEvent({ type: 'approval_resolved', approved }, {}, rec.ctx); + assert.equal(rec.statuses.at(-1), expected); + assert.deepEqual(rec.widgets, [undefined]); + } +}); + +test('after approval_resolved status does not hardcode old thinking copy', () => { + resetVillaniCopyCounters(); + const state = reduceVillaniUiState({ phase: 'approval' }, { type: 'approval_resolved' }); + assert.notEqual(state.phase, 'Villani is thinking...'); +}); + +test('villaniCopy approval rotates deterministically', () => { + resetVillaniCopyCounters(); + assert.equal(villaniCopy('approval'), 'Villani requires authorization...'); + assert.equal(villaniCopy('approval'), 'Villaniclearance required...'); +}); + +test('nextVillaniStatus suppresses repeated status until detail changes', () => { + resetVillaniCopyCounters(); + assert.equal(nextVillaniStatus('thinking', 'same'), 'Villani is make plan...'); + assert.equal(nextVillaniStatus('thinking', 'same'), undefined); + assert.equal(nextVillaniStatus('thinking', 'different'), 'Villaniplan forming...'); +}); diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts new file mode 100644 index 00000000..3036fb51 --- /dev/null +++ b/integrations/pi-villani/src/render.ts @@ -0,0 +1,553 @@ +export type VillaniCopyCategory = + | "thinking" + | "analysis" + | "reading" + | "writing" + | "running" + | "testing" + | "debugging" + | "review" + | "approval" + | "failure" + | "complete"; + +const VILLANI_COPY: Record = { + thinking: ["Villani is make plan...", "Villaniplan forming...", "Villani thinks. Nobody interrupt.", "Villani has doctrine now...", "Villanithoughts classified...","Villani think. Room become unsafe.","Plan is weak. Villani improve.","Villani make brain do labour...","Villani is decide what reality means...","Villani has thought. Other thoughts dismissed...","Villani is make superior plan..."], + analysis: ["Villanalysis begins...", "Villani inspect problem...", "Villani finds weak logic...", "Villanicommission investigates...", "Villani determines blame...","Villani is inspect problem...","Analysis now under state control...","Villani is find where truth escaped...","Villani is diagnose amateur logic...","Villani discovers obvious failure. Embarrassing."], + reading: ["Villani reads file. File nervous.", "Villaniread begins...", "Villani open file for questioning...", "Villanidossier opened...", "Villani check file loyalty...","Villani is read file...","File contents now under suspicion...","File summoned for questioning...","Villani is see what file hide...","File classified. Villani has access...","Villaninterrogation begins...","Villani reads. File should be grateful.","Villani study file. File not impressive."], + writing: ["Villani makes file obey...", "Villanipatch imposed...", "Villani writes new order...", "Villanification applied...", "Villani edits without remorse...","Villani patches. Code say thank you.","Villani is make file obey...","File corrected by authority...","Villani is fixing file attitude..."], + running: ["Villani gives command...", "Villanicommand issued...", "Villani demands output...", "Villanirun begins...", "Villani expects obedience...","Machine must now explain itself...","Villani is launch operation..."], + testing: ["Villani begins inspection...", "Villanitest begins...", "Villani demands green tests...", "Villaniverdict pending...", "Villani checks for lies...","Villanitest begins...","Villani is ask why fail...","Villaninspection. Code scared...","Code must prove usefulness...","Villani hunt fake success..."], + debugging: ["Villani hunts weak bug...", "Villanidebug begins...", "Villani asks bug hard questions...", "Villanistack confesses...", "Villani removes instability...","Villani is hunt bug...","Villani correct disorder...","Villani is make error confess...","Bug hide. Villani interrogate..."], + review: ["Villanireview begins...", "Villani judges patch...", "Villanicompliance checked...", "Villani approves, reluctantly...", "Villani check for betrayal...","Villani judge patch...","Villanicompliance checked...","Patch must justify existence..."], + approval: [ + "Villani requires authorization...", + "Villaniclearance required...", + "Villani requests permission. Briefly.", + "Villani pauses for approval...", + "Villani awaits command authority...", + "Villani demands signed order...", + "Villanipermission pending...", + "Villani asks council. Reluctantly.", + "Villani is need approval...", + "Villani want signed order...", + ], + failure: ["Villani sees failure. Unacceptable.", "Villanifailure recorded...", "Villani prepare punishment...", "Villani blames weak implementation...", "Villani demand second attempt...","Result is shameful. Villani informed...","Failure reported to Villani ministry...","Villani is record disgrace...","Villani blames weak architecture. Correctly."], + complete: ["Villanified. Accept result.", "Villani declares victory...", "Villani restores order...", "Villanivictory logged...", "Villani permits ship...","Villani declare victory. Obviously.","Villani accept outcome as adequate tribute...","Villani create history. New world order established."], +}; + +const copyCounters = new Map(); +let lastStatusCategory: VillaniCopyCategory | undefined; +let lastStatusText: string | undefined; +let lastStatusAt = 0; +let lastStatusDetailKey: string | undefined; +let lastRenderedStatus: string | undefined; + +export function villaniCopy(category: VillaniCopyCategory): string { + const options = VILLANI_COPY[category]; + const current = copyCounters.get(category) ?? 0; + copyCounters.set(category, current + 1); + return options[current % options.length]; +} + +export function resetVillaniCopyCounters(): void { + copyCounters.clear(); + resetVillaniStatusManager(); +} + +export function resetVillaniStatusManager(): void { + lastStatusCategory = undefined; + lastStatusText = undefined; + lastStatusAt = 0; + lastStatusDetailKey = undefined; + lastRenderedStatus = undefined; +} + +function shouldUpdateStatus(category: VillaniCopyCategory, detailKey?: string): boolean { + const now = Date.now(); + if (category !== lastStatusCategory) return true; + if (detailKey && detailKey !== lastStatusDetailKey) return true; + if (now - lastStatusAt > 12000) return true; + return false; +} + +export function nextVillaniStatus(category: VillaniCopyCategory, detailKey?: string): string | undefined { + if (!shouldUpdateStatus(category, detailKey)) return undefined; + const text = villaniCopy(category); + lastStatusCategory = category; + lastStatusText = text; + lastStatusAt = Date.now(); + lastStatusDetailKey = detailKey; + return text; +} + +export function currentStatusFallback(category: VillaniCopyCategory): string { + return lastStatusCategory === category && lastStatusText ? lastStatusText : villaniCopy(category); +} + +export type VillaniUiState = { + phase: string; + lastCommand?: string; + lastCommandExitCode?: number; + lastCommandPreview?: string; + lastToolPath?: string; + lastToolKind?: "reading" | "writing"; + lastAssistantText?: string; + finalSummary?: string; + changedFiles?: string[]; + transcriptPath?: string; + lastEventAt?: number; +}; + +const USER_FACING_EVENT_TYPES = new Set([ + "stream_text", + "phase", + "tool_started", + "command_started", + "command_finished", + "approval_required", + "approval_resolved", + "run_completed", + "run_failed", + "run_aborted", + "model_request_started", + "model_request_completed", + "model_request_failed", + "proxy_request_started", + "proxy_request_completed", + "proxy_request_failed", + "verification_started", + "verification_finished", + "validation_started", + "validation_finished", +]); + +export function shouldRenderUserFacingEvent(event: any): boolean { + if (!event || typeof event !== "object") return false; + const type = String(event.type || ""); + if (!USER_FACING_EVENT_TYPES.has(type)) return false; + if ( + type === "bridge_diagnostic" || + type === "runner_heartbeat" || + type === "pong" + ) + return false; + const text = `${event.message || ""} ${event.text || ""}`; + if (/bridge heartbeat pong|event received|tool_result mapped/i.test(text)) + return false; + return true; +} + +export function cleanAssistantText(value: unknown): string | null { + if (typeof value !== "string") return null; + const text = value.replace(/\r\n/g, "\n").trim(); + if (!text) return null; + return text; +} + +export async function notify( + ctx: any, + message: string, + level: "info" | "warn" | "error" = "info", +): Promise { + try { + if (ctx?.ui?.notify) await ctx.ui.notify(message, level); + else (level === "error" ? console.error : console.log)(message); + } catch { + try { + console.error(message); + } catch {} + } +} +export async function setStatus( + ctx: any, + message: string | undefined, +): Promise { + try { + if (ctx?.ui?.setStatus) await ctx.ui.setStatus("villani", message); + } catch { + try { + if (ctx?.ui?.setStatus) await ctx.ui.setStatus(message); + } catch {} + } +} +export async function setWidget(ctx: any, widget: any): Promise { + try { + if (ctx?.ui?.setWidget) await ctx.ui.setWidget("villani", widget); + } catch {} +} +export async function sendDurableVillaniMessage( + pi: any, + ctx: any, + message: string, + details?: any, +): Promise { + const payload = { + customType: "villani-result", + content: [{ type: "text", text: message }], + display: true, + details: sanitizeDetails(details), + }; + try { + if (typeof pi?.sendMessage === "function") { + await pi.sendMessage(payload); + return; + } + } catch {} + await notify(ctx, message, "info"); +} +export async function confirm( + ctx: any, + title: string, + message: string, + options?: any, +): Promise { + try { + if (ctx?.ui?.confirm) + return !!(await ctx.ui.confirm(title, message, options)); + } catch (e) { + throw e; + } + return false; +} +export function visibleChangedFiles(files: string[] = []) { + return files.filter( + (f) => !/(^|\/)(\.villani|\.villani_code|__pycache__)(\/|$)|\.pyc$/.test(f), + ); +} +function sanitizeDetails(value: any, seen = new WeakSet()): any { + if (value === undefined || value === null) return value; + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) + return value; + if (Array.isArray(value)) return value.map((v) => sanitizeDetails(v, seen)); + if (typeof value === "object") { + if (seen.has(value)) return "[Circular]"; + seen.add(value); + const out: any = {}; + for (const [key, entry] of Object.entries(value)) { + if ( + /(api[_-]?key|authorization|auth|bearer|token|secret|cookie|headers?)$/i.test( + key, + ) || + /^(authorization|cookie)$/i.test(key) + ) + continue; + out[key] = sanitizeDetails(entry, seen); + } + seen.delete(value); + return out; + } + return String(value); +} + +function commandCategory(command: unknown): VillaniCopyCategory { + const text = String(command || ""); + return /pytest|\btest\b|coverage|tox|unittest/i.test(text) ? "testing" : "running"; +} + +function pathFromEvent(event: any): string | undefined { + const input = event.input && typeof event.input === "object" ? event.input : {}; + const value = event.path || event.file_path || event.filepath || event.filename || event.target_file || input.path || input.file_path || input.filepath || input.filename || input.target_file; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function commandFromEvent(event: any): string | undefined { + const input = event.input && typeof event.input === "object" ? event.input : {}; + const value = event.command || input.command; + return typeof value === "string" && value.trim() ? value.slice(0, 500) : undefined; +} + +function categoryForEvent(event: any): VillaniCopyCategory | undefined { + const type = String(event.type || ""); + const phase = String(event.phase || ""); + const tool = String(event.tool || event.name || ""); + if (type === "model_request_started" || type === "proxy_request_started") return "thinking"; + if (type === "approval_required") return "approval"; + if (type === "phase" && /diagnosis|planning/i.test(phase)) return "analysis"; + if (type === "tool_started") { + if (["Read", "GitStatus", "GitDiff", "GitLog"].includes(tool)) return "reading"; + if (["Write", "Patch", "Edit"].includes(tool)) return "writing"; + if (tool === "Bash") return commandCategory(commandFromEvent(event)); + } + if (type === "command_started") return commandCategory(commandFromEvent(event)); + if (type === "command_finished" && Number(event.exit_code ?? 0) !== 0) return "debugging"; + if (type === "validation_started" || type === "verification_started") return "testing"; + if (type === "validation_finished" || type === "verification_finished") return "review"; + if (type === "run_failed") return "failure"; + if (type === "run_completed") return "complete"; + return undefined; +} + +function verificationStatus(event: any): string | undefined { + const verification = + event.verification_status ?? event.verificationStatus ?? event.verification; + if (!verification) return undefined; + if (typeof verification === "string") return `Verification: ${verification}`; + if (typeof verification === "object") { + const status = + verification.status ?? verification.result ?? verification.outcome; + return status ? `Verification: ${status}` : undefined; + } + return `Verification: ${String(verification)}`; +} +export function finalMessage(event: any) { + const files = visibleChangedFiles( + event.changed_files || event.changedFiles || [], + ); + const head = + event.type === "run_completed" + ? "Villani completed" + : event.type === "run_aborted" + ? "Villani aborted" + : "Villani failed"; + const transcript = event.transcript_path || event.transcriptPath; + const verification = verificationStatus(event); + const body = + event.summary || + event.error || + event.message || + (event.type === "run_completed" + ? "Villani completed. See transcript for details." + : "No details were provided."); + const last = + event.type === "run_failed" && event.last_status + ? `Last known status:\n${event.last_status}` + : ""; + return [ + head, + body, + files.length + ? `Changed files:\n${files.map((f: string) => `- ${f}`).join("\n")}` + : "", + last, + transcript ? `Transcript: ${transcript}` : "", + verification, + ] + .filter(Boolean) + .join("\n\n"); +} +function preview(event: any) { + const parts = []; + if (event.stderr_preview) + parts.push(`stderr:\n${String(event.stderr_preview).slice(0, 500)}`); + if (!event.stderr_preview && event.stdout_preview) + parts.push(`output:\n${String(event.stdout_preview).slice(0, 500)}`); + return parts.join("\n"); +} +export function toolStartedMessage(event: any): string { + const tool = String(event.tool || event.name || "tool"); + const path = pathFromEvent(event); + if (tool === "GitStatus" || tool === "GitDiff" || tool === "GitLog" || tool === "Read") return path ? `Villani reads file. File nervous.\nFile: ${path}` : "Villani reads file. File nervous."; + if (tool === "Write" || tool === "Edit") return path ? `Villani makes file obey.\nFile: ${path}` : "Villani makes file obey."; + if (tool === "Patch") return path ? `Villanipatch imposed.\nFile: ${path}` : "Villanipatch imposed."; + if (tool === "Bash") return commandFromEvent(event) ? `Command:\n${commandFromEvent(event)}` : "Villani gives command..."; + return "Villani is make plan..."; +} + +export function reduceVillaniUiState( + state: VillaniUiState, + event: any, +): VillaniUiState { + const next = { ...state, lastEventAt: Date.now() }; + const applyStatus = (category: VillaniCopyCategory, detailKey?: string) => { + const status = nextVillaniStatus(category, detailKey); + if (status) next.phase = status; + }; + if (event.type === "run_started") applyStatus("thinking", "run-started"); + else if (event.type === "model_request_started") { + applyStatus("thinking", "model-request"); + next.lastCommand = undefined; + next.lastCommandExitCode = undefined; + next.lastCommandPreview = undefined; + } else if (event.type === "proxy_request_started") + applyStatus("thinking", "model-request"); + else if (event.type === "model_request_completed") { + if (!state.phase) applyStatus("review", "model-completed"); + } else if (event.type === "proxy_request_completed") { + // Keep existing status; proxy completion is heartbeat-like UI noise. + } else if (event.type === "approval_required") applyStatus("approval", event.request_id || event.requestId); + else if (event.type === "approval_resolved") next.phase = event.approved === false ? "Villani records denial..." : "Villani resumes operation..."; + else if (event.type === "tool_started") { + const tool = String(event.tool || event.name || ""); + const category = categoryForEvent(event) || "thinking"; + applyStatus(category, `${tool}:${pathFromEvent(event) || commandFromEvent(event) || ""}`); + next.lastToolPath = pathFromEvent(event); + next.lastToolKind = ["Read", "GitStatus", "GitDiff", "GitLog"].includes(tool) ? "reading" : (["Write", "Patch", "Edit"].includes(tool) ? "writing" : undefined); + if (tool === "Bash") { + next.lastCommand = commandFromEvent(event); + next.lastToolPath = undefined; + next.lastToolKind = undefined; + } + } + else if (event.type === "command_started") { + const command = String(event.command || "").slice(0, 500); + applyStatus(commandCategory(event.command), command); + next.lastCommand = command; + next.lastCommandExitCode = undefined; + next.lastCommandPreview = undefined; + } else if (event.type === "command_finished") { + applyStatus(Number(event.exit_code ?? 0) !== 0 ? "debugging" : "review", String(event.command || next.lastCommand || "")); + next.lastCommand = String(event.command || next.lastCommand || "").slice( + 0, + 500, + ); + next.lastCommandExitCode = event.exit_code; + next.lastCommandPreview = preview(event); + } else if (event.type === "phase") { + applyStatus(categoryForEvent(event) || "thinking", String(event.phase || "")); + } else if (event.type === "verification_started" || event.type === "validation_started") + applyStatus("testing", event.type); + else if (event.type === "verification_finished" || event.type === "validation_finished") + applyStatus("review", event.type); + else if (event.type === "run_completed") { + applyStatus("complete", "run-completed"); + next.finalSummary = event.summary; + next.changedFiles = visibleChangedFiles( + event.changed_files || event.changedFiles || next.changedFiles || [], + ); + next.transcriptPath = event.transcript_path || event.transcriptPath; + } else if (event.type === "run_failed") { + applyStatus("failure", "run-failed"); + next.finalSummary = event.summary || event.error; + } else if (event.type === "run_aborted") applyStatus("failure", "run-aborted"); + else if ( + event.type === "runner_heartbeat" && + Date.now() - (state.lastEventAt || 0) > 15000 + ) + applyStatus("thinking", "heartbeat"); + return next; +} +export function widgetForState(state: VillaniUiState): any { + if (state.lastToolPath && state.lastToolKind) + return ["File:", state.lastToolPath].join("\n"); + if (state.lastCommandExitCode !== undefined) + return [ + `Command finished: exit ${state.lastCommandExitCode ?? "unknown"}`, + state.lastCommandPreview || "", + ] + .filter(Boolean) + .join("\n"); + if (state.lastCommand) + return ["Command:", state.lastCommand].join("\n"); + if (state.changedFiles?.length) + return `Changed files:\n${state.changedFiles.map((f) => `- ${f}`).join("\n")}`; + return undefined; +} +export async function renderState(ctx: any, state: VillaniUiState) { + if (state.phase && state.phase !== lastRenderedStatus) { + await setStatus(ctx, state.phase); + lastRenderedStatus = state.phase; + } + await setWidget(ctx, widgetForState(state)); +} +let state: VillaniUiState = { phase: "" }; +export async function renderBridgeEvent( + event: any, + _pi: any, + ctx: any, +): Promise { + const debug = process.env.VILLANI_PI_DEBUG === "1"; + if (!shouldRenderUserFacingEvent(event)) { + if ( + debug && + event?.type === "bridge_diagnostic" && + !/heartbeat pong/i.test(String(event.message || "")) + ) + console.error( + `[pi-villani bridge] ${event.message || event.error || "diagnostic"}`, + ); + return; + } + if (event.type === "approval_required") { + state = reduceVillaniUiState(state, event); + await setWidget(ctx, undefined); + await setStatus(ctx, state.phase); + return; + } + if (event.type === "approval_resolved") { + state = reduceVillaniUiState(state, event); + await setStatus(ctx, state.phase); + await setWidget(ctx, undefined); + return; + } + if (event.type === "bridge_diagnostic") { + if (debug && !/heartbeat pong/i.test(String(event.message || ""))) + console.error( + `[pi-villani bridge] ${event.message || event.error || "diagnostic"}`, + ); + return; + } + if (event.type === "pong") return; + if ( + event.type === "run_completed" || + event.type === "run_failed" || + event.type === "run_aborted" + ) { + state = reduceVillaniUiState(state, event); + await renderState(ctx, state); + return; + } + if (event.type === "error") { + await notify( + ctx, + `Villani error: ${event.error || event.message || "unknown error"}`, + "error", + ); + return; + } + if ( + event.type === "model_request_failed" || + event.type === "proxy_request_failed" + ) { + await setStatus(ctx, "Failed"); + await notify( + ctx, + `Villani model request failed: ${event.error || event.message || "unknown error"}`, + "error", + ); + return; + } + if (event.type === "stream_text") { + const text = cleanAssistantText(event.text ?? event.content); + if (text && text !== state.lastAssistantText) { + state = { ...state, lastAssistantText: text, lastEventAt: Date.now() }; + await notify(ctx, text, "info"); + } + return; + } + if (event.type === "tool_started") { + state = reduceVillaniUiState(state, event); + await setStatus(ctx, state.phase); + await setWidget(ctx, widgetForState(state) || toolStartedMessage(event)); + return; + } + if (event.type === "command_started") { + state = reduceVillaniUiState(state, event); + await setStatus(ctx, state.phase); + await setWidget(ctx, widgetForState(state)); + return; + } + if (event.type === "command_finished") { + state = reduceVillaniUiState(state, event); + const msg = [ + `Command finished: exit ${state.lastCommandExitCode ?? "unknown"}`, + state.lastCommandPreview || "", + ] + .filter(Boolean) + .join("\n\n"); + await setStatus(ctx, state.phase); + await notify(ctx, msg, "info"); + await setWidget(ctx, widgetForState(state)); + return; + } + state = reduceVillaniUiState(state, event); + await renderState(ctx, state); +} +export function resetVillaniUiState() { + state = { phase: "" }; + resetVillaniStatusManager(); +} diff --git a/integrations/pi-villani/src/runtime.test.ts b/integrations/pi-villani/src/runtime.test.ts new file mode 100644 index 00000000..f03a1e7a --- /dev/null +++ b/integrations/pi-villani/src/runtime.test.ts @@ -0,0 +1,4 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import AdmZip from 'adm-zip'; import * as tar from 'tar'; +import { assetName, executableRelativePath, platformKey, VILLANI_RUNTIME_VERSION } from './runtimeConfig.js'; import { extractRuntimeArchive, parseChecksums } from './runtime.js'; +test('runtime asset helpers and checksums',()=>{assert.equal(assetName(platformKey('linux','x64')),`villani-runtime-v${VILLANI_RUNTIME_VERSION}-linux-x64.tar.gz`); assert.equal(executableRelativePath('win32-x64'),'villani-code/villani-code.exe'); assert.equal(parseChecksums('abc file.tgz').get('file.tgz'),'abc');}); +test('extracts zip and tar archives without shelling out',async()=>{const dir=mkdtempSync(join(tmpdir(),'villani-rt-')); const zipPath=join(dir,'a.zip'); const zip=new AdmZip(); zip.addFile('villani-code/villani-code',Buffer.from('x')); zip.writeZip(zipPath); const zipOut=join(dir,'zipout'); await extractRuntimeArchive(zipPath,zipOut); assert.ok(existsSync(join(zipOut,'villani-code','villani-code'))); mkdirSync(join(dir,'villani-code'),{recursive:true}); writeFileSync(join(dir,'villani-code','villani-code'),'x'); await tar.c({gzip:true,file:join(dir,'a.tar.gz'),cwd:dir},['villani-code']); const tarOut=join(dir,'tarout'); await extractRuntimeArchive(join(dir,'a.tar.gz'),tarOut); assert.ok(existsSync(join(tarOut,'villani-code','villani-code')));}); diff --git a/integrations/pi-villani/src/runtime.ts b/integrations/pi-villani/src/runtime.ts new file mode 100644 index 00000000..3418ef0f --- /dev/null +++ b/integrations/pi-villani/src/runtime.ts @@ -0,0 +1,13 @@ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import AdmZip from 'adm-zip'; +import * as tar from 'tar'; +import { assetName, executableRelativePath, platformKey, VILLANI_RUNTIME_REPOSITORY, VILLANI_RUNTIME_TAG, VILLANI_RUNTIME_VERSION } from './runtimeConfig.js'; +export function parseChecksums(text:string): Map{const m=new Map(); for(const l of text.split(/\r?\n/)){const p=l.trim().split(/\s+/); if(p.length>=2)m.set(p[p.length-1],p[0]);} return m;} +export function cacheRoot(){return process.platform==='win32'?join(process.env.LOCALAPPDATA||homedir(),'pi-villani','runtime'):join(homedir(),'.cache','pi-villani','runtime');} +async function download(url:string, signal?:AbortSignal):Promise{const r=await fetch(url,{signal}); if(!r.ok) throw new Error(`Download failed ${r.status}: ${url}`); return Buffer.from(await r.arrayBuffer());} +export async function extractRuntimeArchive(archive:string,dest:string):Promise{mkdirSync(dest,{recursive:true}); if(archive.endsWith('.zip')){new AdmZip(archive).extractAllTo(dest,true); return;} await tar.x({file:archive,cwd:dest,gzip:true});} +export async function resolveVillaniRuntime(opts:{signal?:AbortSignal, platform?:string, arch?:string}={}):Promise{ if(process.env.VILLANI_COMMAND) return process.env.VILLANI_COMMAND; const key=platformKey(opts.platform as any, opts.arch as any); const asset=assetName(key); const root=cacheRoot(); const dest=join(root,VILLANI_RUNTIME_VERSION); const exe=join(dest,executableRelativePath(key)); const marker=join(dest,'.verified.json'); const base=`https://github.com/${VILLANI_RUNTIME_REPOSITORY}/releases/download/${VILLANI_RUNTIME_TAG}`; const checks=parseChecksums((await download(`${base}/checksums.txt`,opts.signal)).toString('utf8')); const sum=checks.get(asset); if(!sum) throw new Error(`Missing checksum for ${asset}`); if(existsSync(exe)&&existsSync(marker)){try{const j=JSON.parse(readFileSync(marker,'utf8')); if(j.version===VILLANI_RUNTIME_VERSION&&j.asset===asset&&j.sha256===sum)return exe;}catch{}} + const buf=await download(`${base}/${asset}`,opts.signal); const got=createHash('sha256').update(buf).digest('hex'); if(got!==sum) throw new Error(`Checksum mismatch for ${asset}`); mkdirSync(dest,{recursive:true}); const archive=join(dest,asset); writeFileSync(archive,buf); await extractRuntimeArchive(archive,dest); if(!existsSync(exe)) throw new Error(`Runtime executable missing after extraction: ${exe}`); writeFileSync(marker,JSON.stringify({version:VILLANI_RUNTIME_VERSION,asset,sha256:sum})); return exe; } diff --git a/integrations/pi-villani/src/runtimeConfig.ts b/integrations/pi-villani/src/runtimeConfig.ts new file mode 100644 index 00000000..ccbfc5c9 --- /dev/null +++ b/integrations/pi-villani/src/runtimeConfig.ts @@ -0,0 +1,7 @@ +export const VILLANI_RUNTIME_VERSION = "0.1.5"; +export const VILLANI_RUNTIME_REPOSITORY = "mmprotest/villani-code"; +export const VILLANI_RUNTIME_TAG = `pi-villani-runtime-v${VILLANI_RUNTIME_VERSION}`; +export type PlatformKey = "win32-x64"|"darwin-arm64"|"darwin-x64"|"linux-x64"; +export function platformKey(platform=process.platform, arch=process.arch): PlatformKey { const k=`${platform}-${arch}`; if(["win32-x64","darwin-arm64","darwin-x64","linux-x64"].includes(k)) return k as PlatformKey; throw new Error(`Unsupported platform: ${k}`); } +export function assetName(key: PlatformKey): string { return `villani-runtime-v${VILLANI_RUNTIME_VERSION}-${key}.${key.startsWith('win32')?'zip':'tar.gz'}`; } +export function executableRelativePath(key: PlatformKey): string { return key.startsWith('win32')?'villani-code/villani-code.exe':'villani-code/villani-code'; } diff --git a/integrations/pi-villani/src/smoke.test.ts b/integrations/pi-villani/src/smoke.test.ts new file mode 100644 index 00000000..11578281 --- /dev/null +++ b/integrations/pi-villani/src/smoke.test.ts @@ -0,0 +1,9 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; +import { assetName, executableRelativePath, platformKey, VILLANI_RUNTIME_VERSION } from './runtimeConfig.js'; +import { parseChecksums } from './runtime.js'; +import { sanitizedEnv } from './process.js'; +import { zeroUsage } from './modelProxy.js'; +import { visibleChangedFiles } from './render.js'; +test('runtime asset helpers',()=>{assert.equal(assetName(platformKey('linux','x64')),`villani-runtime-v${VILLANI_RUNTIME_VERSION}-linux-x64.tar.gz`); assert.equal(executableRelativePath('win32-x64'),'villani-code/villani-code.exe');}); +test('checksum parsing and env sanitization',()=>{assert.equal(parseChecksums('abc file.tgz').get('file.tgz'),'abc'); const e=sanitizedEnv({proxyMode:true,env:{OPENAI_API_KEY:'x',PATH:'p'}}); assert.equal(e.OPENAI_API_KEY,undefined); assert.equal(e.PATH,'p');}); +test('usage and render filters',()=>{assert.deepEqual(zeroUsage(),{input:0,output:0,totalTokens:0}); assert.deepEqual(visibleChangedFiles(['a.py','.villani/x','x.pyc']),['a.py']);}); diff --git a/integrations/pi-villani/src/versionConsistency.test.ts b/integrations/pi-villani/src/versionConsistency.test.ts new file mode 100644 index 00000000..fdf9a7ae --- /dev/null +++ b/integrations/pi-villani/src/versionConsistency.test.ts @@ -0,0 +1,3 @@ +import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { VILLANI_RUNTIME_VERSION, assetName, platformKey, VILLANI_RUNTIME_TAG } from './runtimeConfig.js'; +function escapeRegex(value:string):string{return value.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');} +test('runtime version agrees across config, workflow asset names, package docs, and release checklist',()=>{const root=process.cwd(); const wf=readFileSync(join(root,'../../.github/workflows/build-pi-villani-runtime-release.yml'),'utf8'); const readme=readFileSync(join(root,'README.md'),'utf8'); const release=readFileSync(join(root,'docs/release.md'),'utf8'); const pkg=JSON.parse(readFileSync(join(root,'package.json'),'utf8')); assert.match(pkg.version,/^0\.1\.\d+$/); assert.equal(VILLANI_RUNTIME_TAG,`pi-villani-runtime-v${VILLANI_RUNTIME_VERSION}`); assert.equal(assetName(platformKey('linux','x64')),`villani-runtime-v${VILLANI_RUNTIME_VERSION}-linux-x64.tar.gz`); const versionPattern=new RegExp(`v${escapeRegex(VILLANI_RUNTIME_VERSION)}`); assert.match(readme,versionPattern); assert.match(release,versionPattern); assert.match(wf,new RegExp(`villani-runtime-v${escapeRegex(VILLANI_RUNTIME_VERSION)}-`));}); diff --git a/integrations/pi-villani/tsconfig.json b/integrations/pi-villani/tsconfig.json new file mode 100644 index 00000000..6a9515ea --- /dev/null +++ b/integrations/pi-villani/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "declaration": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts" + ] +} \ No newline at end of file diff --git a/packaging/smoke_test_runtime.py b/packaging/smoke_test_runtime.py new file mode 100644 index 00000000..b1aaf71f --- /dev/null +++ b/packaging/smoke_test_runtime.py @@ -0,0 +1,8 @@ +import json, subprocess, sys +exe=sys.argv[1] +p=subprocess.Popen([exe,'bridge','--stdio'],stdin=subprocess.PIPE,stdout=subprocess.PIPE,text=True) +assert p.stdout and p.stdin +ready=json.loads(p.stdout.readline()); assert ready=={"type":"ready","protocol_version":1} +p.stdin.write('{"type":"ping","id":"release-smoke"}\n'); p.stdin.flush() +pong=json.loads(p.stdout.readline()); assert pong=={"type":"pong","id":"release-smoke"} +p.kill() diff --git a/packaging/villani_runtime.spec b/packaging/villani_runtime.spec new file mode 100644 index 00000000..7f1b99f8 --- /dev/null +++ b/packaging/villani_runtime.spec @@ -0,0 +1,47 @@ +from pathlib import Path + +SPEC_DIR = Path(SPECPATH).resolve() +ROOT = SPEC_DIR.parent +ENTRY = ROOT / "packaging" / "villani_runtime_entry.py" + + +# PyInstaller spec for the Pi Villani runtime bridge executable. +block_cipher = None + +a = Analysis( + [str(ENTRY)], + pathex=[str(ROOT)], + binaries=[], + datas=[], + hiddenimports=['villani_code.integrations.pi_bridge'], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + win_no_prefer_redirects=False, + win_private_assemblies=False, + cipher=block_cipher, + noarchive=False, +) +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='villani-code', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/packaging/villani_runtime_entry.py b/packaging/villani_runtime_entry.py new file mode 100644 index 00000000..b82ea3c0 --- /dev/null +++ b/packaging/villani_runtime_entry.py @@ -0,0 +1,3 @@ +from villani_code.cli import app +if __name__ == "__main__": + app() diff --git a/tests/integrations/test_pi_bridge.py b/tests/integrations/test_pi_bridge.py new file mode 100644 index 00000000..330aa9b7 --- /dev/null +++ b/tests/integrations/test_pi_bridge.py @@ -0,0 +1,323 @@ +from __future__ import annotations + +import io, json, os, subprocess, tempfile, time, threading +from pathlib import Path + +from villani_code.execution import ExecutionBudget +from villani_code.integrations.pi_bridge import PiBridge, attributed_changed_files, git_changed_files, hash_files, run_existing_runner + +class DummyRunner: + def __init__(self, approval_callback=None, event_callback=None): self.approval_callback=approval_callback; self.event_callback=event_callback; self.calls=[] + def run(self, task, execution_budget=None): + self.calls.append(('run',task,execution_budget)) + if task=='raise': raise RuntimeError('boom') + if task=='incomplete': return {'response':'bad','execution':{'completed':False,'reason':'budget'}} + if task=='stream': self.event_callback({'type':'stream_text','text':'SECRET'}); return {'response':'ok','execution':{'completed':True}} + if task=='write': + if self.approval_callback('Write', {'path':'a.txt','content':'x'}): Path('a.txt').write_text('x') + return {'response':'ok','execution':{'completed':True}} + if task=='bash': self.approval_callback('Bash', {'command':'echo hello && cat secret'}); return {'response':'ok','execution':{'completed':True}} + if task=='approve-twice': + assert self.approval_callback('Bash', {'command':'one'}) is True + assert self.approval_callback('Bash', {'command':'two'}) is True + return {'response':'ok','execution':{'completed':True},'verification_passed':True} + def run_villani_mode(self): self.calls.append(('villani',)); return {'response':'villani','execution':{'completed':True}} + +def make_bridge(runner=None): + out=io.StringIO(); err=io.StringIO(); made=[] + def factory(cmd,event_callback,approval_callback): + r=runner or DummyRunner(approval_callback,event_callback); r.approval_callback=approval_callback; r.event_callback=event_callback; made.append(r); return r + return PiBridge(stdin=io.StringIO(),stdout=out,stderr=err,runner_factory=factory),out,err,made + +def events(out): return [json.loads(l) for l in out.getvalue().splitlines() if l.strip()] +def wait_for(out, pred, timeout=3): + end=time.time()+timeout + while time.time()=3; b.approval(type('C',(),{'id':'r1','request_id':'r1:2','approved':True})()); wait_for(out,lambda e:e['type']=='run_completed') + +def test_abort_during_pending_approval_emits_run_aborted(tmp_path): + b,out,_,_=make_bridge(); b.handle(run_cmd(tmp_path,'approve-twice')); wait_for(out,lambda e:e['type']=='approval_required'); b.abort('r1'); wait_for(out,lambda e:e['type']=='run_aborted'); assert any(e['type']=='approval_resolved' and e['approved'] is False for e in events(out)) + +def init_repo(p): + subprocess.run(['git','init'],cwd=p,check=True,capture_output=True); subprocess.run(['git','config','user.email','a@b.c'],cwd=p,check=True); subprocess.run(['git','config','user.name','A'],cwd=p,check=True) + +def test_changed_file_attribution_cases(tmp_path): + init_repo(tmp_path); (tmp_path/'tracked.txt').write_text('base'); subprocess.run(['git','add','.'],cwd=tmp_path,check=True); subprocess.run(['git','commit','-m','init'],cwd=tmp_path,check=True,capture_output=True) + (tmp_path/'new.txt').write_text('n'); before=git_changed_files(tmp_path); hashes=hash_files(tmp_path,before); ch,pre=attributed_changed_files(tmp_path,before,hashes,set()); assert 'new.txt' in pre and not ch + (tmp_path/'new2.txt').write_text('n'); ch,pre=attributed_changed_files(tmp_path,before,hashes,set()); assert 'new2.txt' in ch and 'new.txt' in pre + (tmp_path/'new.txt').write_text('changed'); ch,pre=attributed_changed_files(tmp_path,before,hashes,set()); assert 'new.txt' in ch + (tmp_path/'revert.txt').write_text('x'); (tmp_path/'revert.txt').unlink(); ch,_=attributed_changed_files(tmp_path,before,hashes,set()); assert 'revert.txt' not in ch + for path in ['.villani/a','.villani_code/b','__pycache__/c.pyc','d.pyc']: + q=tmp_path/path; q.parent.mkdir(parents=True,exist_ok=True); q.write_text('x') + ch,pre=attributed_changed_files(tmp_path,before,hashes,set()); assert not any(x.startswith(('.villani','.villani_code','__pycache__')) or x.endswith('.pyc') for x in ch+pre) + +class BlockingRunner(DummyRunner): + def __init__(self, started=None, release=None, approval_callback=None, event_callback=None): + super().__init__(approval_callback,event_callback); self.started=started or threading.Event(); self.release=release or threading.Event() + def run(self, task, execution_budget=None): + self.started.set(); self.release.wait(2); return {'response':'ok','execution':{'completed':True}} + +def run_stdio_in_thread(bridge): + th=threading.Thread(target=bridge.run_stdio,daemon=True); th.start(); return th + +def test_run_stdio_emits_ready(): + b,out,_,_=make_bridge(); b.stdin=io.StringIO(''); b.run_stdio(); assert events(out)[0]['type']=='ready' + +def test_run_stdio_accepts_ping_and_returns_pong_without_stdin_eof(): + r,w=os.pipe(); inp=os.fdopen(r,'r'); out=io.StringIO(); b=PiBridge(stdin=inp,stdout=out,runner_factory=lambda *a: DummyRunner()) + th=run_stdio_in_thread(b); wait_for(out,lambda e:e['type']=='ready') + os.write(w,b'{"type":"ping","id":"p1"}\n'); wait_for(out,lambda e:e['type']=='pong' and e.get('id')=='p1') + assert th.is_alive(); os.close(w); th.join(2) + +def test_run_stdio_accepts_run_command_and_emits_run_started(tmp_path): + b,out,_,_=make_bridge(); b.stdin=io.StringIO(json.dumps(run_cmd(tmp_path))+"\n"); b.run_stdio(); assert any(e['type']=='run_started' for e in events(out)) + +def test_run_stdio_keeps_running_after_stdin_closes_while_active_run_is_running(tmp_path): + started=threading.Event(); release=threading.Event(); runner=BlockingRunner(started,release) + b,out,_,_=make_bridge(runner); b.stdin=io.StringIO(json.dumps(run_cmd(tmp_path))+"\n") + th=run_stdio_in_thread(b); wait_for(out,lambda e:e['type']=='run_started'); assert started.wait(1); time.sleep(0.05); assert th.is_alive(); release.set(); th.join(2); assert not th.is_alive() + +def test_run_started_is_emitted_before_runner_factory_is_called(tmp_path): + out=io.StringIO(); order=[] + def factory(cmd,event_callback,approval_callback): + order.append([e['type'] for e in events(out)]); return DummyRunner(approval_callback,event_callback) + b=PiBridge(stdin=io.StringIO(json.dumps(run_cmd(tmp_path))+"\n"),stdout=out,runner_factory=factory); b.run_stdio() + assert 'run_started' in order[0] + +def test_bridge_error_before_run_started_is_surfaced_by_stdio_loop(): + b,out,_,_=make_bridge(); b.stdin=io.StringIO('{"type":"run","id":"r"}\n'); b.run_stdio(); es=events(out); assert es[0]['type']=='ready'; assert any(e['type']=='error' and 'task is required' in e['error'] for e in es) + +def test_worker_events_are_drained_from_queue(tmp_path): + b,out,_,_=make_bridge(); b.stdin=io.StringIO(json.dumps(run_cmd(tmp_path,'stream'))+"\n"); b.run_stdio(); assert any(e['type']=='run_completed' for e in events(out)); assert b._events.empty() + +def test_approval_response_works_through_queued_stdin_command(tmp_path): + r,w=os.pipe(); inp=os.fdopen(r,'r'); out=io.StringIO(); b=PiBridge(stdin=inp,stdout=out,runner_factory=lambda *a: DummyRunner(a[2],a[1])) + th=run_stdio_in_thread(b); wait_for(out,lambda e:e['type']=='ready') + os.write(w,(json.dumps(run_cmd(tmp_path,'write'))+'\n').encode()); wait_for(out,lambda e:e['type']=='approval_required') + os.write(w,(json.dumps({'type':'approval_response','id':'r1','request_id':'r1:1','approved':False})+'\n').encode()); os.close(w) + th.join(2); assert any(e['type']=='approval_resolved' and e['approved'] is False for e in events(out)); assert any(e['type']=='run_completed' for e in events(out)) + +def test_abort_command_denies_pending_approvals_through_stdio(tmp_path): + r,w=os.pipe(); inp=os.fdopen(r,'r'); out=io.StringIO(); b=PiBridge(stdin=inp,stdout=out,runner_factory=lambda *a: DummyRunner(a[2],a[1])) + th=run_stdio_in_thread(b); wait_for(out,lambda e:e['type']=='ready') + os.write(w,(json.dumps(run_cmd(tmp_path,'approve-twice'))+'\n').encode()); wait_for(out,lambda e:e['type']=='approval_required') + os.write(w,(json.dumps({'type':'abort','id':'r1'})+'\n').encode()); os.close(w) + th.join(2); assert any(e['type']=='approval_resolved' and e['approved'] is False for e in events(out)); assert any(e['type']=='run_aborted' for e in events(out)) + +def test_git_changed_files_uses_devnull_stdin(monkeypatch, tmp_path): + calls=[] + class Proc: + returncode=0; stdout='?? x.py\n'; stderr='' + def fake_run(*args, **kwargs): + calls.append(kwargs); return Proc() + monkeypatch.setattr('villani_code.integrations.pi_bridge.subprocess.run', fake_run) + assert git_changed_files(tmp_path)==['x.py'] + assert calls[0]['stdin'] is subprocess.DEVNULL + assert calls[0]['check'] is False + assert calls[0]['timeout']==10 + +def test_worker_diagnostics_order_before_runner(tmp_path): + out=io.StringIO(); order=[] + def factory(cmd,event_callback,approval_callback): + order.extend([e.get('message', e['type']) for e in events(out)]) + return DummyRunner(approval_callback,event_callback) + b=PiBridge(stdin=io.StringIO(),stdout=out,runner_factory=factory) + b.handle(run_cmd(tmp_path)) + wait_for(out,lambda e:e['type']=='run_completed') + assert 'run_started' in [e['type'] for e in events(out)] + assert 'capturing initial git status' in order + assert 'captured initial git status' in order + assert 'creating runner' in order + assert order.index('capturing initial git status') < order.index('captured initial git status') < order.index('creating runner') + +def test_worker_continues_when_git_status_times_out(monkeypatch, tmp_path): + def boom(_repo): + raise subprocess.TimeoutExpired(['git'], 10) + monkeypatch.setattr('villani_code.integrations.pi_bridge.git_changed_files', boom) + b,out,_,_=make_bridge() + b.handle(run_cmd(tmp_path)) + wait_for(out,lambda e:e['type']=='run_failed') + assert any(e['type']=='run_failed' for e in events(out)) + +def test_approval_required_shape_and_bash_input_command(tmp_path): + b,out,_,_=make_bridge(); b.handle(run_cmd(tmp_path,'bash')); wait_for(out,lambda e:e['type']=='approval_required') + e=[x for x in events(out) if x['type']=='approval_required'][0] + assert isinstance(e['summary'], str) + assert isinstance(e['input'], dict) + assert e['summary']=='Run command' + assert e['input']['command']=='echo hello && cat secret' + assert e['tool']=='Bash' + assert 'title' not in e + b.abort('r1') + +def test_map_runner_event_maps_tool_result_and_command_lifecycle(): + from villani_code.integrations.pi_bridge import map_runner_event + finished=map_runner_event('r', {'type':'tool_result','name':'Bash','input':{'command':'echo hi'},'is_error':False,'result':{'exit_code':0,'stdout':'hello'}}) + assert finished[0]['type']=='tool_result' + assert finished[0]['tool']=='Bash' + assert finished[0]['ok'] is True + assert 'Bash finished: exit 0' in finished[0]['summary'] + progress=map_runner_event('r', {'type':'command_started','command':'sleep 1','cwd':'/tmp'}) + assert progress==[{'type':'command_started','id':'r','tool':'Bash','command':'sleep 1','cwd':'/tmp'}] + done=map_runner_event('r', {'type':'command_finished','command':'false','exit_code':1,'stderr':'bad'}) + assert done==[{'type':'command_finished','id':'r','tool':'Bash','command':'false','exit_code':1,'stdout_preview':'','stderr_preview':'bad','truncated':False}] + +def test_bash_tool_result_summary_truncates_output(): + from villani_code.integrations.pi_bridge import map_runner_event + long='x'*800 + ev=map_runner_event('r', {'type':'tool_result','name':'Bash','input':{'command':'pytest'},'result':{'exit_code':1,'stdout':long,'stderr':long}})[0] + assert len(ev['summary']) < 1200 + assert 'output truncated' in ev['summary'] + + +def test_nonzero_bash_events_do_not_map_to_run_failed(): + from villani_code.integrations.pi_bridge import map_runner_event + command = map_runner_event('r', {'type':'command_finished','command':'false','exit_code':255}) + result = map_runner_event('r', {'type':'tool_result','name':'Bash','is_error':False,'result':{'exit_code':255}}) + assert all(e['type'] != 'run_failed' for e in command + result) + assert command[0]['type'] == 'command_finished' + assert result[0]['type'] == 'tool_result' + assert result[0]['is_error'] is False + +def test_runner_heartbeat_is_emitted_after_idle_active_run(monkeypatch): + from villani_code.integrations import pi_bridge as mod + b,out,_,_=make_bridge() + ar=mod.ActiveRun(type('Cmd',(),{'id':'r-heart'})()) + ar.last_runner_event_type='tool_result' + ar.last_runner_event_at=0 + ar.heartbeat_due_at=0 + ar.thread=type('T',(),{'is_alive':lambda self: True})() + b._active['r-heart']=ar + b._stdio_running=True + monkeypatch.setattr(mod.time, 'monotonic', lambda: 16) + b._drain_events() + ev=events(out)[0] + assert ev['type']=='runner_heartbeat' + assert ev['id']=='r-heart' + assert ev['last_event_type']=='tool_result' + assert ev['seconds_since_last_event']==16 + assert ev['worker_alive'] is True + +def test_extract_summary_reads_response_content_blocks(): + from villani_code.integrations.pi_bridge import extract_summary + result={'response':{'content':[{'type':'text','text':'All 14 tests pass.'},{'type':'text','text':'Summary here.'}]}} + assert extract_summary(result)=='All 14 tests pass.\n\nSummary here.' + +def test_extract_summary_reads_execution_final_text(): + from villani_code.integrations.pi_bridge import extract_summary + assert extract_summary({'execution':{'final_text':'final markdown'}})=='final markdown' + +def test_extract_summary_reads_transcript_final_assistant_content(): + from villani_code.integrations.pi_bridge import extract_summary + result={'transcript':{'final_assistant_content':[{'type':'text','text':'final assistant'}]}} + assert extract_summary(result)=='final assistant' + +def test_extract_summary_falls_back_safely_when_absent(): + from villani_code.integrations.pi_bridge import extract_summary + assert extract_summary({'response':{'content':[{'type':'toolCall','arguments':{'x':1}}]}}) is None + +def test_run_completed_includes_summary_from_final_assistant_content(tmp_path): + class R(DummyRunner): + def run(self, task, execution_budget=None): + return {'response':{'content':[{'type':'text','text':'All tests pass.'}]},'execution':{'completed':True}} + b,out,_,_=make_bridge(R()) + b.handle(run_cmd(tmp_path)) + es=wait_for(out,lambda e:e['type']=='run_completed') + ev=[e for e in es if e['type']=='run_completed'][-1] + assert ev['summary']=='All tests pass.' + +def test_map_runner_event_maps_stream_text_field(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'stream_text','text':'hello human'}) + assert ev==[{'type':'stream_text','id':'r','text':'hello human'}] + +def test_map_runner_event_extracts_assistant_content_text_blocks(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'assistant_response','content':[{'type':'text','text':'First'},{'type':'tool_use','input':{'x':1}},{'type':'text','text':'Second'}]}) + assert ev==[{'type':'stream_text','id':'r','text':'First\n\nSecond'}] + +def test_map_runner_event_does_not_emit_hidden_prompts_or_tool_json_as_stream_text(): + from villani_code.integrations.pi_bridge import map_runner_event + assert map_runner_event('r', {'type':'hidden_prompt','content':[{'type':'text','text':'secret prompt'}]})==[] + assert map_runner_event('r', {'type':'assistant_response','content':[{'type':'tool_use','input':{'command':'pytest'}}]})==[] + +def test_bridge_diagnostic_remains_diagnostic_event_for_render_suppression(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'model_request_started'}) + assert {'type':'bridge_diagnostic','id':'r','message':'model request started'} in ev + +def test_tool_started_read_preserves_input_file_path(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'tool_started','name':'Read','input':{'file_path':'src/foo.py','content':'secret'}})[0] + assert ev['type']=='tool_started' + assert ev['input']=={'file_path':'src/foo.py'} + assert ev['path']=='src/foo.py' + + +def test_tool_started_read_preserves_event_path(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'tool_started','name':'Read','path':'src/bar.py','input':{}})[0] + assert ev['path']=='src/bar.py' + assert ev['input']=={} + + +def test_tool_started_bash_preserves_input_command_and_command(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'tool_started','name':'Bash','input':{'command':'pytest -q','cwd':'/tmp','env':{'SECRET':'x'}}})[0] + assert ev['input']=={'command':'pytest -q','cwd':'/tmp'} + assert ev['command']=='pytest -q' + + +def test_tool_result_preserves_safe_input_path_and_command(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'tool_result','name':'Bash','input':{'command':'python -m pytest','path':'tests/test_x.py','headers':{'authorization':'x'}},'result':{'exit_code':1,'stderr':'bad'}})[0] + assert ev['input']=={'path':'tests/test_x.py','command':'python -m pytest'} + assert ev['path']=='tests/test_x.py' + assert ev['command']=='python -m pytest' + assert ev['stderr_preview']=='bad' + + +def test_tool_started_safe_input_excludes_sensitive_and_large_fields(): + from villani_code.integrations.pi_bridge import map_runner_event + ev=map_runner_event('r', {'type':'tool_started','name':'Patch','input':{'path':'a.py','content':'secret file','patch':'diff --git secret','env':{'API_KEY':'x'},'headers':{'authorization':'x'},'api_key':'x','secret':'x','command':'echo ok'}})[0] + assert ev['input']=={'path':'a.py','command':'echo ok'} + dumped=json.dumps(ev).lower() + for forbidden in ['content','diff --git','env','headers','api_key','secret file']: + assert forbidden not in dumped diff --git a/tests/test_bounded_execution.py b/tests/test_bounded_execution.py index de89a806..54713cf6 100644 --- a/tests/test_bounded_execution.py +++ b/tests/test_bounded_execution.py @@ -209,3 +209,15 @@ def test_explicit_no_change_needed_allows_prose_only_completion(tmp_path: Path) runner = _runner(tmp_path, [{"role": "assistant", "content": [{"type": "text", "text": "No code change is needed because behavior matches spec."}]}]) result = runner.run("Fix the bug", execution_budget=_default_budget()) assert result["execution"]["terminated_reason"] == "completed" + + +def test_runner_treats_string_tool_input_as_empty_dict(tmp_path: Path) -> None: + runner = _runner(tmp_path, [{"role": "assistant", "content": [{"type": "tool_use", "id": "1", "name": "Ls", "input": "repo"}]}]) + result = runner.run("work", execution_budget=ExecutionBudget(max_turns=1, max_tool_calls=1, max_seconds=100.0, max_no_edit_turns=20, max_reconsecutive_recon_turns=20)) + assert result["transcript"]["tool_invocations"][0]["input"] == {} + + +def test_runner_treats_list_tool_input_as_empty_dict(tmp_path: Path) -> None: + runner = _runner(tmp_path, [{"role": "assistant", "content": [{"type": "tool_use", "id": "1", "name": "Ls", "input": ["repo"]}]}]) + result = runner.run("work", execution_budget=ExecutionBudget(max_turns=1, max_tool_calls=1, max_seconds=100.0, max_no_edit_turns=20, max_reconsecutive_recon_turns=20)) + assert result["transcript"]["tool_invocations"][0]["input"] == {} diff --git a/tests/test_openai_client.py b/tests/test_openai_client.py index 0e0926c9..f4702a62 100644 --- a/tests/test_openai_client.py +++ b/tests/test_openai_client.py @@ -62,3 +62,9 @@ def test_convert_openai_response_preserves_usage_id_model_and_stop_reason() -> N assert converted["model"] == "gpt-4o-mini" assert converted["usage"] == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15} assert converted["stop_reason"] == "end_turn" + + +def test_convert_openai_response_non_object_tool_arguments_are_empty() -> None: + response = {"id": "r", "model": "m", "choices": [{"finish_reason": "tool_calls", "message": {"role": "assistant", "tool_calls": [{"id": "call_1", "function": {"name": "Read", "arguments": '"repo"'}}]}}]} + converted = convert_openai_response_to_anthropic(response) + assert converted["content"][0]["input"] == {} diff --git a/tests/test_openai_stream_to_anthropic_events.py b/tests/test_openai_stream_to_anthropic_events.py index 50ca9b3e..48d0d964 100644 --- a/tests/test_openai_stream_to_anthropic_events.py +++ b/tests/test_openai_stream_to_anthropic_events.py @@ -1,4 +1,7 @@ +import json + from villani_code.openai_client import openai_stream_to_anthropic_events +from villani_code.streaming import assemble_anthropic_stream def test_openai_stream_text_events_to_anthropic(): @@ -40,3 +43,84 @@ def test_openai_stream_tool_call_arguments_emit_input_json_delta(): assert events[3]["delta"]["partial_json"] == '.txt","content":"x"}' assert events[-2] == {"type": "content_block_stop", "index": 1} assert events[-1] == {"type": "message_stop"} + + + +def test_pi_proxy_single_chunk_stream_assembles_text(): + chunk = { + "id": "pi-villani-1700000000000", + "object": "chat.completion.chunk", + "created": 1700000000, + "model": "pi-test", + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": "hello"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + lines = [f"data: {json.dumps(chunk)}", "data: [DONE]"] + + response = assemble_anthropic_stream(openai_stream_to_anthropic_events(lines, model="pi-test")) + + assert response["content"][0]["text"] == "hello" + + +def test_pi_proxy_single_chunk_stream_assembles_tool_calls(): + chunk = { + "id": "pi-villani-1700000000000", + "object": "chat.completion.chunk", + "created": 1700000000, + "model": "pi-test", + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "index": 0, + "id": "call_1", + "type": "function", + "function": {"name": "Write", "arguments": '{"file_path":"a.txt"}'}, + } + ], + }, + "finish_reason": "tool_calls", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + lines = [f"data: {json.dumps(chunk)}", "data: [DONE]"] + + response = assemble_anthropic_stream(openai_stream_to_anthropic_events(lines, model="pi-test")) + + assert response["content"][1]["type"] == "tool_use" + assert response["content"][1]["name"] == "Write" + assert response["content"][1]["input"] == {"file_path": "a.txt"} + + +def _assembled_tool_input(arguments: str): + chunk = { + "id": "pi-villani-1700000000000", + "object": "chat.completion.chunk", + "created": 1700000000, + "model": "pi-test", + "choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "id": "call_1", "function": {"name": "Read", "arguments": arguments}}]}, "finish_reason": "tool_calls"}], + } + return assemble_anthropic_stream(openai_stream_to_anthropic_events([f"data: {json.dumps(chunk)}", "data: [DONE]"], model="pi-test"))["content"][1]["input"] + + +def test_openai_streaming_tool_call_object_arguments_produces_dict(): + assert _assembled_tool_input('{"path":"x"}') == {"path": "x"} + + +def test_openai_streaming_tool_call_json_string_arguments_produces_empty_dict(): + assert _assembled_tool_input('"repo"') == {} + + +def test_openai_streaming_tool_call_invalid_string_arguments_produces_empty_dict(): + assert _assembled_tool_input('repo') == {} diff --git a/tests/test_state_runtime.py b/tests/test_state_runtime.py index 417f52b1..695c3260 100644 --- a/tests/test_state_runtime.py +++ b/tests/test_state_runtime.py @@ -703,3 +703,21 @@ def test_diagnosis_confidence_weak_without_file_evidence(tmp_path: Path) -> None } confidence = state_runtime.classify_diagnosis_target_confidence(runner, diagnosis, failure_evidence=None) assert confidence == "weak" + +def test_git_changed_files_uses_devnull_stdin_and_is_safe(monkeypatch, tmp_path): + import subprocess + from villani_code import state_runtime + calls=[] + class Proc: + returncode=0; stdout=' M a.py\n'; stderr='' + def fake_run(*args, **kwargs): + calls.append(kwargs); return Proc() + monkeypatch.setattr(state_runtime.subprocess, 'run', fake_run) + assert state_runtime.git_changed_files(tmp_path)==['a.py'] + assert calls[0]['stdin'] is subprocess.DEVNULL + assert calls[0]['check'] is False + assert calls[0]['timeout']==10 + def timeout(*args, **kwargs): + raise subprocess.TimeoutExpired(args[0], 10) + monkeypatch.setattr(state_runtime.subprocess, 'run', timeout) + assert state_runtime.git_changed_files(tmp_path)==[] diff --git a/tests/test_streaming.py b/tests/test_streaming.py index 2d9c4e6e..053be82a 100644 --- a/tests/test_streaming.py +++ b/tests/test_streaming.py @@ -49,7 +49,7 @@ def test_assemble_input_json_delta_chunks_into_dict(): assert msg["content"][0]["input"] == {"file_path": "a.txt", "content": "hello"} -def test_assemble_input_json_delta_malformed_keeps_raw_string(): +def test_assemble_input_json_delta_malformed_keeps_empty_dict(): events = [ {"type": "message_start", "message": {"id": "m", "role": "assistant"}}, { @@ -68,7 +68,7 @@ def test_assemble_input_json_delta_malformed_keeps_raw_string(): msg = assemble_anthropic_stream(events) - assert msg["content"][0]["input"] == '{"file_path":"a.txt"' + assert msg["content"][0]["input"] == {} def test_assemble_preserves_usage_from_message_stop(): diff --git a/villani_code/cli.py b/villani_code/cli.py index eb28cb42..95a6d362 100644 --- a/villani_code/cli.py +++ b/villani_code/cli.py @@ -20,6 +20,7 @@ from villani_code.debug_bundle import create_debug_bundle from villani_code.debug_mode import DebugMode, build_debug_config from villani_code.trace_summary import write_summary_from_events, write_tool_calls_from_events +from villani_code.runner_factory import build_runner app = typer.Typer(help="Villani: constrained-inference coding agent with visible context governance") mcp_app = typer.Typer(help="Manage MCP servers") @@ -95,28 +96,7 @@ def _resolve_villani_flag(repo: Path, cli_value: bool | None) -> bool: def _build_runner(base_url: str, model: str, repo: Path, max_tokens: int, stream: bool, thinking: Optional[str], unsafe: bool, verbose: bool, extra_json: Optional[str], redact: bool, dangerously_skip_permissions: bool, auto_accept_edits: bool, auto_approve: bool, plan_mode: Literal["off", "auto", "strict"], max_repair_attempts: int, small_model: bool, provider: Literal["anthropic", "openai"], api_key: Optional[str], villani_mode: bool = False, villani_objective: str | None = None, benchmark_runtime_json: str | None = None, debug_mode: DebugMode = DebugMode.OFF, debug_dir: Optional[Path] = None, memory_enabled: bool = False, memory_update_interval_tool_calls: int = 5) -> Runner: - resolved_repo = repo.resolve() - try: - ensure_runtime_dependencies_not_shadowed(resolved_repo) - except RuntimeError as exc: - raise typer.BadParameter(str(exc)) from exc - - client: Any - if provider == "openai": - resolved_api_key = api_key or os.environ.get("OPENAI_API_KEY") - client = OpenAIClient(base_url=base_url, api_key=resolved_api_key) - else: - _ = api_key or os.environ.get("ANTHROPIC_API_KEY") - client = AnthropicClient(base_url=base_url) - thinking_obj = None - if thinking: - try: - thinking_obj = json.loads(thinking) - except json.JSONDecodeError: - thinking_obj = thinking - benchmark_config = BenchmarkRuntimeConfig.model_validate_json(benchmark_runtime_json) if benchmark_runtime_json else None - debug_config = build_debug_config(debug_mode.value if isinstance(debug_mode, DebugMode) else str(debug_mode), debug_dir=debug_dir) - return Runner(client=client, repo=resolved_repo, model=model, max_tokens=max_tokens, stream=stream, thinking=thinking_obj, unsafe=unsafe, verbose=verbose, extra_json=extra_json, redact=redact, bypass_permissions=dangerously_skip_permissions, auto_accept_edits=auto_accept_edits, auto_approve=auto_approve, plan_mode=plan_mode, max_repair_attempts=max_repair_attempts, small_model=small_model, villani_mode=villani_mode, villani_objective=villani_objective, benchmark_config=benchmark_config, debug_config=debug_config, provider=provider, memory_enabled=memory_enabled, memory_update_interval_tool_calls=memory_update_interval_tool_calls) + return build_runner(base_url=base_url, model=model, repo=repo, max_tokens=max_tokens, stream=stream, thinking=thinking, unsafe=unsafe, verbose=verbose, extra_json=extra_json, redact=redact, dangerously_skip_permissions=dangerously_skip_permissions, auto_accept_edits=auto_accept_edits, auto_approve=auto_approve, plan_mode=plan_mode, max_repair_attempts=max_repair_attempts, small_model=small_model, provider=provider, api_key=api_key, villani_mode=villani_mode, villani_objective=villani_objective, benchmark_runtime_json=benchmark_runtime_json, debug_mode=debug_mode, debug_dir=debug_dir, memory_enabled=memory_enabled, memory_update_interval_tool_calls=memory_update_interval_tool_calls) def _run_interactive(base_url: str, model: str, repo: Path, max_tokens: int, small_model: bool, provider: Literal["anthropic", "openai"], api_key: Optional[str], villani_mode: bool = False, villani_objective: str | None = None, auto_approve: bool = False, debug_mode: DebugMode = DebugMode.OFF, debug_dir: Optional[Path] = None) -> None: @@ -175,6 +155,15 @@ def main( _run_interactive(base_url, model, repo, max_tokens, small_model, provider, api_key, villani_mode=resolved_villani, auto_approve=auto_approve) +@app.command() +def bridge( + stdio: bool = typer.Option(False, "--stdio", help="Run the JSONL stdio bridge for external integrations."), +) -> None: + if not stdio: + raise typer.BadParameter("Only --stdio is supported for the bridge command") + from villani_code.integrations.pi_bridge import main_stdio + main_stdio() + @app.command() def run( instruction: str = typer.Argument(..., help="User instruction"), diff --git a/villani_code/hooks.py b/villani_code/hooks.py index 1cbd0f35..97c087d1 100644 --- a/villani_code/hooks.py +++ b/villani_code/hooks.py @@ -39,6 +39,8 @@ def run_event(self, event: str, payload: dict[str, Any]) -> HookResult: def _run_shell(self, command: str, payload: dict[str, Any]) -> HookResult: proc = subprocess.run( command, + # Hooks intentionally receive the payload on stdin; this uses a private pipe, + # not the bridge JSONL stdin. input=json.dumps(payload), capture_output=True, text=True, diff --git a/villani_code/integrations/__init__.py b/villani_code/integrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/villani_code/integrations/pi_bridge.py b/villani_code/integrations/pi_bridge.py new file mode 100644 index 00000000..b6805b60 --- /dev/null +++ b/villani_code/integrations/pi_bridge.py @@ -0,0 +1,386 @@ +from __future__ import annotations +import hashlib,json,io,os,queue,subprocess,sys,threading,traceback,time +from dataclasses import dataclass,field +from pathlib import Path +from typing import Any,Callable,TextIO +from villani_code.execution import ExecutionBudget, VILLANI_TASK_BUDGET +from villani_code.integrations.pi_bridge_protocol import * + +HIDDEN={'.villani','.villani_code','__pycache__'} +def _visible(p:str)->bool: + parts=p.replace('\\','/').split('/'); return not (any(x in HIDDEN for x in parts) or p.endswith('.pyc')) +def _cap(v:Any,n:int=2000)->str: return str(v)[:n] +def _preview(v:Any,n:int=500)->str: + text='' if v is None else str(v) + return text[:n] + ('...' if len(text)>n else '') + +def safe_tool_input(value): + if not isinstance(value, dict): + return {} + allowed = {} + for key in ( + "path", + "file_path", + "filepath", + "filename", + "target_file", + "command", + "cwd", + "operation", + ): + if key in value and value[key] is not None: + allowed[key] = _cap(value[key], 500) + return allowed + +def extract_tool_path(event, input): + candidates = [ + event.get("path"), + event.get("file_path"), + event.get("filepath"), + event.get("filename"), + event.get("target_file"), + input.get("path"), + input.get("file_path"), + input.get("filepath"), + input.get("filename"), + input.get("target_file"), + ] + for candidate in candidates: + if isinstance(candidate, str) and candidate.strip(): + return _cap(candidate.strip(), 500) + return None +def summarize_approval_request(tool_name:str, tool_input:dict[str,Any])->tuple[str,dict[str,Any]]: + if tool_name=='Write': + path=str(tool_input.get('path') or tool_input.get('file_path') or '') + return f'Write file: {path}', {'path':path,'content_chars':len(str(tool_input.get('content','')))} + if tool_name=='Patch': + path=str(tool_input.get('path') or tool_input.get('file_path') or '') + return f'Patch file: {path}', {'path':path,'operation':_cap(tool_input.get('operation') or 'patch',100)} + if tool_name=='Bash': return 'Run command', {'command':_cap(tool_input.get('command',''))} + out={k:_cap(tool_input[k]) for k in ('path','file_path','command') if k in tool_input} + return tool_name,out + +def git_changed_files(repo:Path)->list[str]: + try: + r=subprocess.run( + ['git','status','--porcelain=v1','--untracked-files=all'], + cwd=repo, + text=True, + capture_output=True, + stdin=subprocess.DEVNULL, + timeout=10, + check=False, + ) + if r.returncode: return [] + files=[] + for line in r.stdout.splitlines(): + name=line[3:] if len(line)>3 else '' + if ' -> ' in name: name=name.split(' -> ',1)[1] + if name and _visible(name): files.append(name) + return sorted(set(files)) + except Exception: return [] +def hash_file(path:Path)->str|None: + try: + if not path.is_file(): return None + h=hashlib.sha256(); h.update(path.read_bytes()); return h.hexdigest() + except Exception: return None +def hash_files(repo:Path, files:list[str])->dict[str,str|None]: return {f:hash_file(repo/f) for f in files} +def attributed_changed_files(repo:Path,before_dirty:list[str],before_dirty_hashes:dict[str,str|None],touched_files:set[str]|list[str])->tuple[list[str],list[str]]: + after=git_changed_files(repo); before=set(before_dirty); touched=set(touched_files); changed=[]; pre=[] + for f in after: + h=hash_file(repo/f) + if f not in before or before_dirty_hashes.get(f)!=h or (f in touched and before_dirty_hashes.get(f)!=h): changed.append(f) + else: pre.append(f) + return sorted(set(filter(_visible,changed))), sorted(set(filter(_visible,pre))) + +def summarize_tool_result(event:dict[str,Any])->str: + tool=str(event.get('name') or event.get('tool') or 'tool') + result=event.get('result') if isinstance(event.get('result'),dict) else {} + inp=event.get('input') if isinstance(event.get('input'),dict) else {} + command=event.get('command') or result.get('command') or inp.get('command') + exit_code=event.get('exit_code', result.get('exit_code', result.get('returncode'))) + stdout=result.get('stdout_preview', event.get('stdout_preview', result.get('stdout'))) + stderr=result.get('stderr_preview', event.get('stderr_preview', result.get('stderr'))) + truncated=bool(event.get('truncated') or result.get('truncated') or (stdout is not None and len(str(stdout))>500) or (stderr is not None and len(str(stderr))>500)) + if tool=='Bash' or command is not None or exit_code is not None: + parts=[f"Bash finished: exit {exit_code if exit_code is not None else 'unknown'}"] + if command: parts.append(f"command: {_preview(command)}") + if stdout: parts.append(f"stdout: {_preview(stdout)}") + if stderr: parts.append(f"stderr: {_preview(stderr)}") + if truncated: parts.append('output truncated') + return '\n'.join(parts) + if event.get('is_error'): return f"{tool} failed" + return f"{tool} finished" + +def _workspace_path(event:dict[str,Any])->str|None: + inp=event.get('input') if isinstance(event.get('input'),dict) else {} + result=event.get('result') if isinstance(event.get('result'),dict) else {} + path=inp.get('path') or inp.get('file_path') or result.get('path') or result.get('file_path') + return str(path) if path else None + +def map_runner_event(run_id:str,event:dict[str,Any])->list[dict[str,Any]]: + t=event.get('type'); out=[] + phase={'diagnosis_attempted','diagnosis_generated','planning_started','repair_attempt_started'} + if t in phase: out.append({'type':'phase','id':run_id,'phase':t}) + elif t=='model_request_started': out += [{'type':'phase','id':run_id,'phase':t},{'type':'model_request_started','id':run_id},{'type':'bridge_diagnostic','id':run_id,'message':'model request started'}] + elif t=='model_request_completed': out += [{'type':'model_request_completed','id':run_id},{'type':'bridge_diagnostic','id':run_id,'message':'model response received'}] + elif t=='model_request_failed': out.append({'type':'bridge_diagnostic','id':run_id,'message':t}) + elif t=='tool_started': + tool=str(event.get('name') or event.get('tool') or 'tool'); inp=event.get('input') if isinstance(event.get('input'),dict) else {} + safe_input=safe_tool_input(inp); path=extract_tool_path(event,safe_input); command=safe_input.get('command') + out += [{'type':'tool_started','id':run_id,'tool':tool,'input':safe_input, **({'path':path} if path else {}), **({'command':command} if command else {})},{'type':'bridge_diagnostic','id':run_id,'message':f"tool started: {tool}".strip()}] + elif t in {'tool_result','tool_finished'}: + tool=str(event.get('name') or event.get('tool') or 'tool') + result=event.get('result') if isinstance(event.get('result'),dict) else {} + exit_code=event.get('exit_code', result.get('exit_code', result.get('returncode'))) + is_error=bool(event.get('is_error')) + summary=summarize_tool_result(event) + inp=event.get('input') if isinstance(event.get('input'),dict) else {} + safe_input=safe_tool_input(inp); path=extract_tool_path(event,safe_input); command=safe_input.get('command') + base={'type':t,'id':run_id,'tool':tool,'ok':not is_error,'is_error':is_error,'summary':summary,'input':safe_input} + if path: base['path']=path + if command: base['command']=command + if exit_code is not None: base['exit_code']=exit_code + for key in ('stdout_preview','stderr_preview','truncated'): + if key in event: base[key]=event[key] + elif key in result: base[key]=result[key] + if 'stdout_preview' not in base and result.get('stdout') is not None: base['stdout_preview']=_preview(result.get('stdout')) + if 'stderr_preview' not in base and result.get('stderr') is not None: base['stderr_preview']=_preview(result.get('stderr')) + out.append(base) + out.append({'type':'bridge_diagnostic','id':run_id,'message':f'{t} mapped'}) + if tool in {'Write','Patch','Edit'}: + path=_workspace_path(event); out.append({'type':'workspace_changed','id':run_id, **({'path':path} if path else {})}) + elif t=='command_started': + command=_cap(event.get('command',''),500); out.append({'type':'command_started','id':run_id,'tool':'Bash','command':command, **({'cwd':str(event.get('cwd'))} if event.get('cwd') else {})}) + elif t=='command_finished': + exit_code=event.get('exit_code'); + out.append({'type':'command_finished','id':run_id,'tool':'Bash','command':_cap(event.get('command',''),500),'exit_code':exit_code,'stdout_preview':_preview(event.get('stdout_preview',event.get('stdout',''))),'stderr_preview':_preview(event.get('stderr_preview',event.get('stderr',''))),'truncated':bool(event.get('truncated'))}) + elif t=='validation_step_started': out.append({'type':'verification_started','id':run_id,'name':event.get('name')}) + elif t in {'validation_step_finished','validation_completed'}: out.append({'type':'verification_finished','id':run_id,'passed':event.get('passed')}) + elif t in {'command_wandering_detected','progress_governor_redirected','governor_redirect'}: out.append({'type':'governor_redirect','id':run_id,'reason':event.get('reason')}) + elif t in {'stream_text','assistant_text','model_text'}: + text=event.get('text') if isinstance(event.get('text'),str) else event.get('content') + extracted=text if isinstance(text,str) else _extract_text_blocks(text) + if isinstance(extracted,str) and extracted.strip(): out.append({'type':'stream_text','id':run_id,'text':_cap(extracted.strip(),240)}) + elif t in {'assistant_message','assistant_response','model_response','response_completed'}: + text=_extract_text_blocks(event.get('content')) + if text: out.append({'type':'stream_text','id':run_id,'text':_cap(text,240)}) + return out + +def _extract_text_blocks(value:Any)->str|None: + blocks=value if isinstance(value,list) else (value.get('content') if isinstance(value,dict) else None) + if not isinstance(blocks,list): return None + texts=[] + for block in blocks: + if isinstance(block,str): + text=block + elif isinstance(block,dict) and block.get('type') in {None,'text'}: + text=block.get('text') or block.get('content') or '' + else: + continue + if isinstance(text,str) and text.strip(): texts.append(text.strip()) + joined='\n\n'.join(texts).strip() + return joined or None + +def _cap_summary(text:str,n:int=6000)->str: + text=text.strip() + return text if len(text)<=n else text[:n].rstrip()+"…" + +def extract_summary(r:dict[str,Any])->str|None: + for k in ('summary','final_text'): + v=r.get(k) + if isinstance(v,str) and v.strip(): return _cap_summary(v) + ex=r.get('execution') + if isinstance(ex,dict) and isinstance(ex.get('final_text'),str) and ex.get('final_text','').strip(): return _cap_summary(ex['final_text']) + response=r.get('response') + if isinstance(response,str) and response.strip(): return _cap_summary(response) + if isinstance(response,dict): + text=_extract_text_blocks(response.get('content')) + if text: return _cap_summary(text) + transcript=r.get('transcript') + if isinstance(transcript,dict): + text=_extract_text_blocks(transcript.get('final_assistant_content')) + if text: return _cap_summary(text) + responses=transcript.get('responses') + if isinstance(responses,list) and responses: + last=responses[-1] + if isinstance(last,dict): + text=_extract_text_blocks(last.get('content')) + if text: return _cap_summary(text) + return None + +def run_existing_runner(runner:Any, command:RunCommand)->dict[str,Any]: + budget=None + if command.limits.max_turns is not None: + budget=ExecutionBudget(max_turns=command.limits.max_turns,max_tool_calls=VILLANI_TASK_BUDGET.max_tool_calls,max_seconds=VILLANI_TASK_BUDGET.max_seconds,max_no_edit_turns=VILLANI_TASK_BUDGET.max_no_edit_turns,max_reconsecutive_recon_turns=VILLANI_TASK_BUDGET.max_reconsecutive_recon_turns) + result=runner.run_villani_mode() if command.mode=='villani' else (runner.run(command.task,execution_budget=budget) if budget is not None else runner.run(command.task)) + return result if isinstance(result,dict) else {'response':result} + +def build_default_runner(command:RunCommand,event_callback,approval_callback): + from villani_code.runner_factory import build_runner + provider=command.config.provider or os.environ.get('VILLANI_PROVIDER') or 'anthropic'; model=command.config.model or os.environ.get('VILLANI_MODEL'); base_url=command.config.base_url or os.environ.get('VILLANI_BASE_URL') + if provider not in {'anthropic','openai'}: raise ValueError("provider must be 'anthropic' or 'openai'") + if not model or not base_url: raise ValueError('run config requires model and base_url, or VILLANI_MODEL and VILLANI_BASE_URL') + runner=build_runner(base_url=base_url,model=model,repo=Path(command.repo),provider=provider,api_key=command.config.api_key or os.environ.get('VILLANI_API_KEY'),villani_mode=command.mode=='villani',villani_objective=command.task if command.mode=='villani' else None,event_callback=event_callback,approval_callback=approval_callback,external_approval_mode=True) + runner.print_stream=False; return runner +@dataclass(slots=True) +class PendingApproval: + run_id:str; request_id:str; tool:str; ready:threading.Event=field(default_factory=threading.Event); approved:bool|None=None +@dataclass(slots=True) +class ActiveRun: + command:RunCommand; abort_requested:threading.Event=field(default_factory=threading.Event); thread:threading.Thread|None=None; pending_approvals:dict[str,PendingApproval]=field(default_factory=dict); touched_files:set[str]=field(default_factory=set); approval_seq:int=0; last_runner_event_type:str='run_started'; last_runner_event_at:float=field(default_factory=time.monotonic); last_bridge_event_at:float=field(default_factory=time.monotonic); heartbeat_due_at:float=field(default_factory=lambda: time.monotonic()+15) +class PiBridge: + def __init__(self,*,stdin:TextIO|None=None,stdout:TextIO|None=None,stderr:TextIO|None=None,runner_factory:Callable[...,Any]|None=None)->None: + self.stdin=stdin or sys.stdin; self.stdout=stdout or sys.stdout; self.stderr=stderr or sys.stderr; self.runner_factory=runner_factory or build_default_runner + self._events: queue.Queue[dict[str, Any] | None]=queue.Queue(); self._active: dict[str, ActiveRun]={}; self._pending_approvals: dict[str, PendingApproval]={}; self._lock=threading.Lock() + self.runs=self._active; self.lock=self._lock; self._stdio_running=False + def emit(self,e): + self.stdout.write(to_json_line(e)); self.stdout.flush() + def _queue_event(self,e:dict[str,Any]|None): + if e is not None and e.get('id'): + with self._lock: + ar=self._active.get(str(e.get('id'))) + if ar: ar.last_bridge_event_at=time.monotonic() + if self._stdio_running: self._events.put(e) + elif e is not None: self.emit(e) + def _diagnostic(self,run_id:str|None,message:str,**extra:Any)->None: + ev={'type':'bridge_diagnostic','message':message} + if run_id: ev['id']=run_id + ev.update({k:v for k,v in extra.items() if k!='api_key'}) + self._queue_event(ev) + def _drain_events(self)->None: + now=time.monotonic() + with self._lock: + active=list(self._active.items()) + for run_id,ar in active: + if now-ar.last_runner_event_at>=15 and now>=ar.heartbeat_due_at: + ar.heartbeat_due_at=now+15 + self._events.put({'type':'runner_heartbeat','id':run_id,'last_event_type':ar.last_runner_event_type,'seconds_since_last_event':int(now-ar.last_runner_event_at),'worker_alive':bool(ar.thread and ar.thread.is_alive())}) + while True: + try: ev=self._events.get_nowait() + except queue.Empty: return + if ev is not None: self.emit(ev) + def _stdin_reader(self,commands:queue.Queue[dict[str,Any]|None])->None: + try: + try: fd=self.stdin.fileno() + except (AttributeError,io.UnsupportedOperation,OSError,ValueError,TypeError): fd=None # type: ignore[name-defined] + if fd is not None: + buf=b'' + while True: + chunk=os.read(fd,4096) + if not chunk: break + buf += chunk + while b'\n' in buf: + raw,buf=buf.split(b'\n',1); line=raw.decode('utf-8',errors='replace') + if line.strip(): + try: commands.put(parse_json_line(line)) + except Exception as exc: self._queue_event({'type':'error','error':str(exc)}) + if buf.strip(): + try: commands.put(parse_json_line(buf.decode('utf-8',errors='replace'))) + except Exception as exc: self._queue_event({'type':'error','error':str(exc)}) + else: + for raw_line in self.stdin: + if not str(raw_line).strip(): continue + try: commands.put(parse_json_line(str(raw_line))) + except Exception as exc: self._queue_event({'type':'error','error':str(exc)}) + finally: + commands.put(None) + def run_stdio(self)->None: + self._stdio_running=True; self.emit(ready_event()); commands: queue.Queue[dict[str,Any]|None]=queue.Queue(); stdin_closed=False + threading.Thread(target=self._stdin_reader,args=(commands,),daemon=True).start() + while True: + self._drain_events() + if stdin_closed: + with self._lock: active=bool(self._active) + if not active: break + try: cmd=commands.get(timeout=0.02) + except queue.Empty: continue + else: + try: cmd=commands.get(timeout=0.02) + except queue.Empty: continue + if cmd is None: + stdin_closed=True; continue + self.handle(cmd) + def run_forever(self): + self.run_stdio() + def handle(self,p): + try: + t=p.get('type') + if t=='ping': self.emit({'type':'pong','id':p.get('id')}); return + if t=='run': self.start_run(parse_run_command(p)); return + if t=='abort': self.abort(str(p.get('id',''))); return + if t=='approval_response': self.approval(parse_approval_response_command(p)); return + self.emit({'type':'error','error':f'Unknown bridge command type: {t}'}) + except Exception as exc: + self.emit({'type':'error','error':str(exc)}) + def start_run(self,cmd): + repo=str(Path(cmd.repo)) + with self._lock: + if cmd.id in self._active: self.emit({'type':'error','id':cmd.id,'error':'Duplicate active run id'}); return + ar=ActiveRun(cmd); self._active[cmd.id]=ar + self._queue_event({'type':'bridge_diagnostic','id':cmd.id,'message':'run command received'}) + self._queue_event({'type':'run_started','id':cmd.id,'run_id':cmd.id,'task':cmd.task,'repo':repo,'mode':cmd.mode}) + th=threading.Thread(target=self._worker,args=(ar,),daemon=True); ar.thread=th; th.start() + def abort(self,run_id): + with self._lock: ar=self._active.get(run_id) + if not ar: self.emit({'type':'error','id':run_id,'error':'Unknown run id'}); return + ar.abort_requested.set(); + for req,p in list(ar.pending_approvals.items()): + ar.pending_approvals.pop(req,None) + with self._lock: self._pending_approvals.pop(req,None) + p.approved=False; p.ready.set() + self.emit({'type':'abort_requested','id':run_id}) + def approval(self,cmd): + with self._lock: + p=self._pending_approvals.pop(cmd.request_id,None) + ar=self._active.get(cmd.id) + if p and ar: ar.pending_approvals.pop(cmd.request_id,None) + if not ar: self.emit({'type':'error','id':cmd.id,'error':'Unknown run id'}); return + if not p: self.emit({'type':'error','id':cmd.id,'error':'Unknown approval request id'}); return + p.approved=cmd.approved; p.ready.set() + def _worker(self,ar): + cmd=ar.command; repo=Path(cmd.repo); before=[]; before_hash={} + self._diagnostic(cmd.id,'run worker started') + def ev(e): + if e.get('type')=='approval_required': return + ar.last_runner_event_type=str(e.get('type') or 'unknown'); ar.last_runner_event_at=time.monotonic(); ar.heartbeat_due_at=ar.last_runner_event_at+15 + for m in map_runner_event(cmd.id,e): self._queue_event(m) + def appr(tool,inp): + if ar.abort_requested.is_set(): return False + summary,safe_input=summarize_approval_request(str(tool),inp); ar.approval_seq += 1; req=f'{cmd.id}:{ar.approval_seq}' + p=PendingApproval(cmd.id,req,str(tool)) + with self._lock: ar.pending_approvals[req]=p; self._pending_approvals[req]=p + if not isinstance(summary,str): summary=str(summary) + if not isinstance(safe_input,dict): safe_input={} + self._queue_event({'type':'approval_required','id':cmd.id,'request_id':req,'tool':str(tool),'summary':summary,'input':safe_input}) + p.ready.wait(); self._queue_event({'type':'approval_resolved','id':cmd.id,'request_id':req,'approved':bool(p.approved)}); return bool(p.approved) and not ar.abort_requested.is_set() + try: + if ar.abort_requested.is_set(): self._queue_event({'type':'run_aborted','id':cmd.id}); return + self._diagnostic(cmd.id,'capturing initial git status') + before=git_changed_files(repo) + before_hash=hash_files(repo,before) + self._diagnostic(cmd.id,'captured initial git status') + source='pi-proxy' if cmd.config.pi_model_proxy else 'direct-config'; self._diagnostic(cmd.id,f'model configuration source={source} provider={cmd.config.provider} model={cmd.config.model} base_url={cmd.config.base_url}') + self._diagnostic(cmd.id,'creating runner') + runner=self.runner_factory(cmd,ev,appr) + self._diagnostic(cmd.id,'runner created; entering execution') + result=run_existing_runner(runner,cmd) + self._diagnostic(cmd.id,'runner.run returned') + changed,pre=attributed_changed_files(repo,before,before_hash,ar.touched_files) + if ar.abort_requested.is_set(): self._queue_event({'type':'run_aborted','id':cmd.id}); return + ex=result.get('execution') if isinstance(result.get('execution'),dict) else {}; reason=ex.get('terminated_reason') or ex.get('reason') + base={'id':cmd.id,'changed_files':changed,'preexisting_dirty_files':pre,'summary':extract_summary(result) or 'Villani completed. See transcript for details.','transcript_path':result.get('transcript_path'),'verification_passed':result.get('verification_passed'),'terminated_reason':reason} + if ex.get('completed') is False: self._queue_event({'type':'run_failed','success':False,'error':str(reason or 'Runner stopped before completion.'),**base}) + else: self._queue_event({'type':'run_completed','success':True,**base}) + except Exception as exc: + self._diagnostic(cmd.id,f'exception: {exc}') + if ar.abort_requested.is_set(): self._queue_event({'type':'run_aborted','id':cmd.id}); return + print(traceback.format_exc(),file=self.stderr); self._queue_event({'type':'run_failed','id':cmd.id,'success':False,'error':str(exc),'summary':str(exc)}) + finally: + for req,p in list(ar.pending_approvals.items()): + ar.pending_approvals.pop(req,None) + with self._lock: self._pending_approvals.pop(req,None) + p.approved=False; p.ready.set() + with self._lock: self._active.pop(cmd.id,None) +def main_stdio(): PiBridge().run_stdio() +if __name__=='__main__': main_stdio() diff --git a/villani_code/integrations/pi_bridge_protocol.py b/villani_code/integrations/pi_bridge_protocol.py new file mode 100644 index 00000000..2846e518 --- /dev/null +++ b/villani_code/integrations/pi_bridge_protocol.py @@ -0,0 +1,41 @@ +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Any, Literal +import json +PROTOCOL_VERSION=1 +BridgeMode=Literal['runner','villani'] +@dataclass(slots=True) +class BridgeConfig: + provider:str|None=None; model:str|None=None; base_url:str|None=None; api_key:str|None=None; pi_model_proxy:bool=False +@dataclass(slots=True) +class BridgeLimits: max_turns:int|None=None +@dataclass(slots=True) +class RunCommand: + id:str; task:str; repo:str; mode:BridgeMode='runner'; config:BridgeConfig=field(default_factory=BridgeConfig); limits:BridgeLimits=field(default_factory=BridgeLimits) +@dataclass(slots=True) +class ApprovalResponseCommand: + id:str; request_id:str; approved:bool + +def to_json_line(event:dict[str,Any])->str: return json.dumps(event,separators=(',',':'))+'\n' +def parse_json_line(line:str)->dict[str,Any]: + v=json.loads(line) + if not isinstance(v,dict): raise ValueError('JSON command must be an object') + return v +def _nonempty(p,k): + v=p.get(k) + if not isinstance(v,str) or not v.strip(): raise ValueError(f'{k} is required') + return v +def parse_run_command(payload:dict[str,Any])->RunCommand: + rid=_nonempty(payload,'id'); task=_nonempty(payload,'task'); repo=_nonempty(payload,'repo') + mode=payload.get('mode','runner') + if mode not in {'runner','villani'}: raise ValueError("mode must be 'runner' or 'villani'") + c=payload.get('config') or {}; l=payload.get('limits') or {} + if not isinstance(c,dict) or not isinstance(l,dict): raise ValueError('config and limits must be objects') + max_turns=l.get('max_turns') + if max_turns is not None and not isinstance(max_turns,int): raise ValueError('max_turns must be an integer') + return RunCommand(rid,task,repo,mode,BridgeConfig(provider=c.get('provider'),model=c.get('model'),base_url=c.get('base_url'),api_key=c.get('api_key'),pi_model_proxy=bool(c.get('pi_model_proxy',False))),BridgeLimits(max_turns=max_turns)) +def parse_approval_response_command(payload:dict[str,Any])->ApprovalResponseCommand: + rid=_nonempty(payload,'id'); req=_nonempty(payload,'request_id') + if not isinstance(payload.get('approved'),bool): raise ValueError('approved must be boolean') + return ApprovalResponseCommand(rid,req,payload['approved']) +def ready_event()->dict[str,Any]: return {'type':'ready','protocol_version':PROTOCOL_VERSION} diff --git a/villani_code/openai_client.py b/villani_code/openai_client.py index 5dc2449a..f34ffc58 100644 --- a/villani_code/openai_client.py +++ b/villani_code/openai_client.py @@ -86,6 +86,18 @@ def build_openai_payload(payload: dict[str, Any], stream: bool) -> dict[str, Any return out +def _coerce_tool_input(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, dict) else {} + except json.JSONDecodeError: + return {} + return {} + + def _map_openai_finish_reason_to_anthropic(finish_reason: str | None) -> str | None: mapping = { "stop": "end_turn", @@ -181,10 +193,7 @@ def convert_openai_response_to_anthropic(response: dict[str, Any]) -> dict[str, for tool_call in message.get("tool_calls", []) or []: function = tool_call.get("function", {}) arguments = function.get("arguments", "{}") - try: - parsed_arguments = json.loads(arguments) if isinstance(arguments, str) else dict(arguments) - except (json.JSONDecodeError, TypeError, ValueError): - parsed_arguments = {} + parsed_arguments = _coerce_tool_input(arguments) content.append( { "type": "tool_use", diff --git a/villani_code/repair.py b/villani_code/repair.py index 8951d801..e9266904 100644 --- a/villani_code/repair.py +++ b/villani_code/repair.py @@ -53,7 +53,7 @@ def _run_repair_prompt(runner: Any, context: RepairContext, prior_attempts: list raw = runner.client.create_message({"model": runner.model, "messages": call_messages, "system": build_system_blocks(runner.repo), "tools": tool_specs(), "max_tokens": runner.max_tokens, "stream": False}, stream=False) response = raw if isinstance(raw, dict) else {"content": []} for block in [b for b in response.get("content", []) if b.get("type") == "tool_use"]: - runner._execute_tool_with_policy(str(block.get("name", "")), dict(block.get("input", {})), str(block.get("id", "repair-tool")), len(call_messages)) + runner._execute_tool_with_policy(str(block.get("name", "")), block.get("input") if isinstance(block.get("input"), dict) else {}, str(block.get("id", "repair-tool")), len(call_messages)) text = "\n".join(b.get("text", "") for b in response.get("content", []) if isinstance(b, dict) and b.get("type") == "text") return text[:400] or "repair attempt executed" diff --git a/villani_code/runner_factory.py b/villani_code/runner_factory.py new file mode 100644 index 00000000..259289d4 --- /dev/null +++ b/villani_code/runner_factory.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +import json, os +from pathlib import Path +from typing import Any, Callable, Literal +import typer +from villani_code.anthropic_client import AnthropicClient +from villani_code.openai_client import OpenAIClient +from villani_code.runtime_safety import ensure_runtime_dependencies_not_shadowed +from villani_code.state import Runner +from villani_code.benchmark.runtime_config import BenchmarkRuntimeConfig +from villani_code.debug_mode import DebugConfig, DebugMode, build_debug_config + + +def build_runner(*, base_url: str, model: str, repo: Path, max_tokens: int = 4096, stream: bool = True, thinking: Any = None, unsafe: bool = False, verbose: bool = False, extra_json: str | None = None, redact: bool = False, dangerously_skip_permissions: bool = False, auto_accept_edits: bool = False, auto_approve: bool = False, plan_mode: Literal['off','auto','strict'] = 'auto', max_repair_attempts: int = 2, small_model: bool = False, provider: Literal['anthropic','openai'] = 'anthropic', api_key: str | None = None, villani_mode: bool = False, villani_objective: str | None = None, event_callback: Callable[[dict[str, Any]], None] | None = None, approval_callback: Callable[[str, dict[str, Any]], bool] | None = None, external_approval_mode: bool = False, benchmark_runtime_json: str | None = None, debug_mode: DebugMode = DebugMode.OFF, debug_dir: Path | None = None, memory_enabled: bool = False, memory_update_interval_tool_calls: int = 5) -> Runner: + resolved_repo = repo.resolve() + try: + ensure_runtime_dependencies_not_shadowed(resolved_repo) + except RuntimeError as exc: + raise typer.BadParameter(str(exc)) from exc + if provider == 'openai': + client = OpenAIClient(base_url=base_url, api_key=api_key or os.environ.get('OPENAI_API_KEY')) + else: + _ = api_key or os.environ.get('ANTHROPIC_API_KEY') + client = AnthropicClient(base_url=base_url) + thinking_obj = None + if thinking: + if isinstance(thinking, str): + try: thinking_obj = json.loads(thinking) + except json.JSONDecodeError: thinking_obj = thinking + else: thinking_obj = thinking + benchmark_config = BenchmarkRuntimeConfig.model_validate_json(benchmark_runtime_json) if benchmark_runtime_json else None + debug_config = build_debug_config(debug_mode.value if isinstance(debug_mode, DebugMode) else str(debug_mode), debug_dir=debug_dir) if not isinstance(debug_mode, DebugConfig) else debug_mode + return Runner(client=client, repo=resolved_repo, model=model, max_tokens=max_tokens, stream=stream, thinking=thinking_obj, unsafe=unsafe, verbose=verbose, extra_json=extra_json, redact=redact, bypass_permissions=dangerously_skip_permissions, auto_accept_edits=auto_accept_edits, auto_approve=auto_approve, plan_mode=plan_mode, max_repair_attempts=max_repair_attempts, approval_callback=approval_callback, event_callback=event_callback, small_model=small_model, villani_mode=villani_mode, villani_objective=villani_objective, benchmark_config=benchmark_config, debug_config=debug_config, provider=provider, memory_enabled=memory_enabled, memory_update_interval_tool_calls=memory_update_interval_tool_calls, external_approval_mode=external_approval_mode) diff --git a/villani_code/state.py b/villani_code/state.py index 1b4dcbf3..c3e555f5 100644 --- a/villani_code/state.py +++ b/villani_code/state.py @@ -59,6 +59,12 @@ ) +def _safe_tool_input(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + return {} + + def _dedupe_preserve(items: list[str]) -> list[str]: seen: set[str] = set() ordered: list[str] = [] @@ -491,6 +497,7 @@ def __init__( provider: str | None = None, memory_enabled: bool = False, memory_update_interval_tool_calls: int = 5, + external_approval_mode: bool = False, ): self.client = client self.repo = repo @@ -507,6 +514,7 @@ def __init__( self.bypass_permissions = bypass_permissions self.auto_accept_edits = auto_accept_edits self.auto_approve = auto_approve + self.external_approval_mode = external_approval_mode self.plan_mode = plan_mode self.max_repair_attempts = max_repair_attempts self.approval_callback = approval_callback or (lambda _n, _i: True) @@ -1500,7 +1508,7 @@ def _budget_reason( tool_results: list[dict[str, Any]] = [] for block in tool_uses: tool_name = block.get("name", "") - tool_input = dict(block.get("input", {})) + tool_input = _safe_tool_input(block.get("input")) tool_use_id = str(block.get("id")) self.event_callback( { @@ -1660,7 +1668,7 @@ def _budget_reason( consecutive_no_edit_turns += 1 mutating_tools = any( - self._is_mutating_tool_call(b.get("name", ""), dict(b.get("input", {}))) + self._is_mutating_tool_call(b.get("name", ""), _safe_tool_input(b.get("input"))) for b in tool_uses ) recon_turn = bool(tool_uses) and not mutating_tools and not edited_this_turn diff --git a/villani_code/state_runtime.py b/villani_code/state_runtime.py index e4d9cc6e..a60d3091 100644 --- a/villani_code/state_runtime.py +++ b/villani_code/state_runtime.py @@ -193,6 +193,9 @@ def run_pre_edit_failure_localization(runner: Any) -> dict[str, Any] | None: cwd=isolated_repo, capture_output=True, text=True, + stdin=subprocess.DEVNULL, + timeout=getattr(runner, "verification_timeout_sec", 60), + check=False, ) except Exception as exc: # pragma: no cover - defensive path runner.event_callback( @@ -607,7 +610,20 @@ def truncate_tool_result(tool_name: str, result: dict[str, Any]) -> dict[str, An def git_changed_files(repo: Any) -> list[str]: - proc = subprocess.run(["git", "status", "--short"], cwd=repo, capture_output=True, text=True) + try: + proc = subprocess.run( + ["git", "status", "--short"], + cwd=repo, + capture_output=True, + text=True, + stdin=subprocess.DEVNULL, + timeout=10, + check=False, + ) + except Exception: + return [] + if proc.returncode != 0: + return [] return [line[3:].strip() for line in proc.stdout.splitlines() if line.strip()] @@ -710,7 +726,7 @@ def _run_patch_sanity_check(runner: Any) -> dict[str, Any]: } cmd = [sys.executable, "-m", "py_compile", *checked_files] - proc = subprocess.run(cmd, cwd=runner.repo, capture_output=True, text=True) + proc = subprocess.run(cmd, cwd=runner.repo, capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=60, check=False) stdout = proc.stdout.strip() stderr = proc.stderr.strip() if proc.returncode != 0: @@ -769,7 +785,7 @@ def _run_patch_sanity_check(runner: Any) -> dict[str, Any]: } collect_cmd = [sys.executable, "-m", "pytest", "--collect-only", "-q"] - collect_proc = subprocess.run(collect_cmd, cwd=runner.repo, capture_output=True, text=True) + collect_proc = subprocess.run(collect_cmd, cwd=runner.repo, capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=60, check=False) collect_stdout = collect_proc.stdout.strip() collect_stderr = collect_proc.stderr.strip() telemetry["collection_sanity_ran"] = True @@ -988,7 +1004,7 @@ def run_verification(runner: Any, trigger: str = "edit") -> str: lines.append("next: inspect locked file, produce one bounded patch, or stop") cmd_results: list[dict[str, Any]] = [] for cmd in commands: - proc = subprocess.run(cmd, cwd=runner.repo, capture_output=True, text=True) + proc = subprocess.run(cmd, cwd=runner.repo, capture_output=True, text=True, stdin=subprocess.DEVNULL, timeout=60, check=False) stderr_lines = "\n".join([ln for ln in proc.stderr.splitlines() if ln][:5]) stdout = proc.stdout[:1500] cmd_results.append( @@ -1306,7 +1322,7 @@ def ensure_project_memory_and_plan(runner: Any, instruction: str) -> None: update_session_state(runner.repo, session) return - if runner.villani_mode: + if runner.villani_mode or getattr(runner, "external_approval_mode", False): runner.event_callback({"type": "plan_auto_approved", "risk": plan.risk_level.value}) update_session_state(runner.repo, session) return diff --git a/villani_code/state_tooling.py b/villani_code/state_tooling.py index a908bb8c..d493ead2 100644 --- a/villani_code/state_tooling.py +++ b/villani_code/state_tooling.py @@ -518,7 +518,13 @@ def execute_tool_with_policy( if policy.decision == Decision.DENY: return {"content": "Denied by permission policy", "is_error": True} if policy.decision == Decision.ASK: - if getattr(runner, "auto_approve", False): + if getattr(runner, "external_approval_mode", False): + runner.event_callback({"type": "approval_required", "name": tool_name, "input": tool_input}) + approved = runner.approval_callback(tool_name, tool_input) + runner.event_callback({"type": "approval_resolved", "name": tool_name, "input": tool_input, "approved": approved}) + if not approved: + return {"content": "User denied tool execution", "is_error": True} + elif getattr(runner, "auto_approve", False): runner.event_callback( { "type": "approval_auto_resolved", @@ -671,6 +677,31 @@ def _debug_callback_with_turn(event_type: str, payload: dict[str, Any]) -> None: debug_callback = getattr(runner, "_debug_tool_callback", None) if callable(debug_callback): debug_callback(event_type, callback_payload) + if event_type == "command_started": + runner.event_callback( + { + "type": "command_started", + "name": "Bash", + "command": payload.get("command"), + "cwd": payload.get("cwd"), + "tool_use_id": payload.get("tool_call_id"), + "turn_index": emit_turn_index, + } + ) + elif event_type == "command_finished": + runner.event_callback( + { + "type": "command_finished", + "name": "Bash", + "command": payload.get("command"), + "cwd": payload.get("cwd"), + "exit_code": payload.get("exit_code"), + "stdout_preview": str(payload.get("stdout") or "")[:500], + "stderr_preview": str(payload.get("stderr") or "")[:500], + "tool_use_id": payload.get("tool_call_id"), + "turn_index": emit_turn_index, + } + ) debug_recorder = getattr(runner, "_debug_recorder", None) debug_root = getattr(getattr(debug_recorder, "artifacts", None), "root", None) diff --git a/villani_code/streaming.py b/villani_code/streaming.py index 4b557645..db04c503 100644 --- a/villani_code/streaming.py +++ b/villani_code/streaming.py @@ -78,9 +78,9 @@ def assemble_anthropic_stream(events: Iterable[dict[str, Any]]) -> dict[str, Any raw = partial_json.pop(index) try: parsed = json.loads(raw) - response["content"][index]["input"] = parsed + response["content"][index]["input"] = parsed if isinstance(parsed, dict) else {} except json.JSONDecodeError: - response["content"][index]["input"] = raw + response["content"][index]["input"] = {} elif etype == "message_delta": delta = event.get("delta", {}) if isinstance(delta, dict): diff --git a/villani_code/tools.py b/villani_code/tools.py index 54a8c090..f6c79be6 100644 --- a/villani_code/tools.py +++ b/villani_code/tools.py @@ -272,7 +272,7 @@ def _run_grep( cmd = [rg_bin, "-n", data.pattern, str(base)] if data.include_hidden: cmd.append("--hidden") - proc = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, env=env) + proc = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, env=env, stdin=subprocess.DEVNULL) return "\n".join(proc.stdout.splitlines()[: data.max_results]) return "" @@ -295,7 +295,7 @@ def _run_search( return "" base = _safe_path(repo, data.path) cmd = [rg_bin, "-n", "-C", str(data.context_lines), data.query, str(base)] - proc = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, env=env) + proc = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, env=env, stdin=subprocess.DEVNULL) return proc.stdout @@ -309,7 +309,7 @@ def _run_bash(data: BashInput, repo: Path, unsafe: bool, debug_callback: Any | N if callable(debug_callback): debug_callback("command_started", {"command": data.command, "cwd": data.cwd, "tool_call_id": tool_call_id}) env = _command_environment(repo, data.command, cwd, debug_callback, tool_call_id, private_roots, shell=True) - proc = subprocess.run(data.command, shell=True, cwd=str(cwd), capture_output=True, text=True, timeout=data.timeout_sec, env=env) + proc = subprocess.run(data.command, shell=True, cwd=str(cwd), capture_output=True, text=True, timeout=data.timeout_sec, env=env, stdin=subprocess.DEVNULL) if callable(debug_callback): debug_callback( "command_finished", @@ -414,5 +414,5 @@ def _run_git(name: str, data: GitSimpleInput, repo: Path, debug_callback: Any | } cmd = ["git", *mapping[name], *data.args] env = _command_environment(repo, cmd, repo, debug_callback, tool_call_id, private_roots) - proc = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, env=env) + proc = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True, env=env, stdin=subprocess.DEVNULL) return proc.stdout or proc.stderr diff --git a/villani_code/validation_loop.py b/villani_code/validation_loop.py index 5661b150..bc702696 100644 --- a/villani_code/validation_loop.py +++ b/villani_code/validation_loop.py @@ -315,7 +315,7 @@ def run_validation(repo: Path, changed_files: list[str], event_callback: Any | N if event_callback: event_callback({"type": "validation_step_started", "name": step.name, "command": command, "reasons": planned_step.reasons}) started = time.monotonic() - proc = subprocess.run(command, shell=True, cwd=repo, text=True, capture_output=True) + proc = subprocess.run(command, shell=True, cwd=repo, text=True, capture_output=True, stdin=subprocess.DEVNULL) result = ValidationStepResult(step, command, proc.returncode, proc.stdout, proc.stderr, time.monotonic() - started, planned_step.reasons) results.append(result) if event_callback: