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..275aa60d --- /dev/null +++ b/.github/workflows/build-pi-villani-runtime-release.yml @@ -0,0 +1,99 @@ +name: Build pi-villani runtime release + +on: + workflow_dispatch: + push: + tags: + - "pi-villani-runtime-v*" + +permissions: + contents: write + +env: + RUNTIME_VERSION: "0.1.0" + +jobs: + runtime: + name: ${{ matrix.platform }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - platform: win32-x64 + os: windows-latest + extension: zip + executable: dist/runtime/villani-code/villani-code.exe + - platform: darwin-arm64 + os: macos-14 + extension: tar.gz + executable: dist/runtime/villani-code/villani-code + - platform: darwin-x64 + os: macos-15-intel + extension: tar.gz + executable: dist/runtime/villani-code/villani-code + - platform: linux-x64 + os: ubuntu-latest + extension: tar.gz + executable: dist/runtime/villani-code/villani-code + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e . pyinstaller + - name: Build runtime + run: pyinstaller --distpath dist/runtime --workpath build/runtime packaging/villani_runtime.spec + - name: Smoke test runtime + run: python packaging/smoke_test_runtime.py "${{ matrix.executable }}" + - name: Compute asset name + shell: bash + run: | + RUNTIME_VERSION="${GITHUB_REF_NAME#pi-villani-runtime-v}" + ASSET="villani-runtime-v${RUNTIME_VERSION}-${{ matrix.platform }}.${{ matrix.extension }}" + echo "RUNTIME_VERSION=${RUNTIME_VERSION}" >> "$GITHUB_ENV" + echo "ASSET=${ASSET}" >> "$GITHUB_ENV" + - name: Archive runtime (Windows) + if: matrix.extension == 'zip' + shell: pwsh + run: Compress-Archive -Path dist/runtime/villani-code -DestinationPath "$env:ASSET" + - name: Archive runtime (macOS/Linux) + if: matrix.extension == 'tar.gz' + run: tar -czf "$ASSET" -C dist/runtime villani-code + - name: Generate checksum + shell: bash + run: | + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$ASSET" > "$ASSET.sha256" + else + shasum -a 256 "$ASSET" > "$ASSET.sha256" + fi + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.platform }}-runtime + path: | + ${{ env.ASSET }} + ${{ env.ASSET }}.sha256 + + publish: + needs: runtime + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/pi-villani-runtime-v') + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Flatten artifacts and checksums + run: | + find artifacts -type f -name 'villani-runtime-*' -exec cp {} . \; + cat *.sha256 > checksums.txt + - uses: softprops/action-gh-release@v2 + with: + files: | + villani-runtime-v*.zip + villani-runtime-v*.tar.gz + checksums.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5d2173e..a353403b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,10 +61,4 @@ jobs: . .venv-smoke/bin/activate pip install dist/*.whl villani-code --help > /dev/null - python - <<'PY' -import importlib.util -assert importlib.util.find_spec("villani_code") is not None -assert importlib.util.find_spec("villani_code.cli") is not None -assert importlib.util.find_spec("ui") is None -print("packaging smoke ok") -PY + python -c 'import importlib.util; assert importlib.util.find_spec("villani_code") is not None; assert importlib.util.find_spec("villani_code.cli") is not None; assert importlib.util.find_spec("ui") is None; print("packaging smoke ok")' diff --git a/.gitignore b/.gitignore index 7d843bf4..94c8fd9c 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,6 @@ Thumbs.db # Local secrets / config *.env .env.* + +# Local backup files +*.bak diff --git a/01_qwen35_combined_success_rate.png b/01_qwen35_combined_success_rate.png deleted file mode 100644 index fcbc1d15..00000000 Binary files a/01_qwen35_combined_success_rate.png and /dev/null differ diff --git a/01_solved_tasks_by_type_fixed.png b/01_solved_tasks_by_type_fixed.png deleted file mode 100644 index b9503283..00000000 Binary files a/01_solved_tasks_by_type_fixed.png and /dev/null differ diff --git a/03_qwen35_combined_frontier_success_vs_total_runtime.png b/03_qwen35_combined_frontier_success_vs_total_runtime.png deleted file mode 100644 index 62547cc0..00000000 Binary files a/03_qwen35_combined_frontier_success_vs_total_runtime.png and /dev/null differ diff --git a/03_qwen35_combined_frontier_success_vs_total_runtime_reversed_x.png b/03_qwen35_combined_frontier_success_vs_total_runtime_reversed_x.png deleted file mode 100644 index 1ff6f171..00000000 Binary files a/03_qwen35_combined_frontier_success_vs_total_runtime_reversed_x.png and /dev/null differ diff --git a/CHANGELOG.md b/CHANGELOG.md index 682c6ca0..c4bd0bc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,4 @@ ## 0.1.0rc1 -- Hardened benchmark validation command resolution with explicit allowlisting and clear environment-failure classification. -- Added benchmark interpretation-status policy (`headline_comparable`, `informational_only`, `internal_only`) with blunt report banners. -- Expanded aggregate provenance reporting so environment/harness instability is separated from agent weakness in summaries. -- Added benchmark preflight checks for task packs, agent names, command structure, and likely executable-resolution issues. -- Curated benchmark starting packs with distinct task instructions and pack lint validation tests. -- Tightened CI release surface for Python 3.11 and Windows/core behavior, plus packaging smoke assertions. -- Updated benchmark documentation and added a practical release checklist. +- Added minimal validation evidence hygiene: shell pipefail execution, weak-evidence labeling for filtered commands, unresolved failed-validation warnings, and last-successful-validation drift warnings. diff --git a/README.md b/README.md index 14be6e5d..de97ae33 100644 --- a/README.md +++ b/README.md @@ -1,220 +1,154 @@ # Villani Code -**The runtime that forces small local models to do real repo work.** +**A terminal-first coding agent runtime built to make small local models complete real repository work.** -Most coding agents look impressive right up until you take away the easy mode. +Villani Code runs coding tasks inside a repository, gives the model tools to inspect files, modify code and execute verification, and keeps the work oriented toward a passing result. -Give them a smaller local model, a bounded repo task, a hard verifier, and no frontier-model safety blanket, and the whole thing starts to wobble. +It is designed for local and constrained backends, where the runtime has to do more than pass messages to a powerful hosted model. Villani focuses the agent on the loop that matters: -**Villani Code is built for that environment.** +1. inspect the repository and task; +2. identify the smallest useful change; +3. edit or run commands; +4. verify the result; +5. recover when verification fails. -It is a terminal-first coding agent runtime designed to get useful, verifiable work out of constrained local backends, not just produce a polished transcript and a hopeful diff. +Use Villani directly from the command line, or run it inside [Pi](https://github.com/earendil-works/pi) through the `@mmprotest/pi-villani` extension. -On the benchmark runs in this repo, **Villani Code achieved the highest solve rate at every reported Qwen3.5 size: 4B, 9B, and 27B.** The strongest result is not just that it solves more. At **27B**, it also does it in substantially less total time than the baseline runner. +## Benchmark results -![All tasks combined: success rate vs total runtime](03_qwen35_combined_frontier_success_vs_total_runtime_reversed_x.png) +Using **Qwen3.5-9B** across the full **Terminal-Bench 2.0** task suite, Villani Code achieved **92 verified completions from 445 clean attempts**, a **20.67%** score. -## The pitch +That score would place **Villani Code + Qwen3.5-9B at #126** on the current Terminal-Bench 2.0 leaderboard, above **Gemini CLI + Gemini 2.5 Pro** and **Bash Agent + TermiGen-32B**. The published **little-coder + Qwen3.5-9B** entry scores **9.2%**. Villani scores **2.25x higher with the same model class**. -This is the uncomfortable gap in the coding-agent market: +![Villani Code projected Terminal-Bench 2.0 leaderboard position](docs/assets/villani_terminal_bench_2_leaderboard_position.png) -Everyone wants the economics, privacy, and deployability of small local models. +[Read the full technical report](docs/Villani_Code_9B_Terminal_Bench_Technical_Report_Leaderboard.pdf) -Almost nobody has a runtime that makes those models perform like serious tools. +## Install Villani Code -That is what Villani Code is trying to fix. +### Requirements -The bet is simple: +- Python 3.11 or later +- A Git repository to work in +- An OpenAI-compatible model endpoint -**Small models do not need more hype. They need a better runtime.** +### Install -A loose runtime wastes tokens, drifts across the repo, edits the wrong files, and dies in verification. +Clone the repository and install Villani: -A tighter runtime can make the same class of model materially more useful. - -That is the whole game. - -## What the current benchmark says - -Across the combined task set used in the current runs, Villani Code outperformed Claude Code at every tested Qwen3.5 size. - -| Model | Villani | Claude Code | -|---|---:|---:| -| Qwen3.5 4B | **33/40 (82.5%)** | 28/40 (70.0%) | -| Qwen3.5 9B | **34/40 (85.0%)** | 30/40 (75.0%) | -| Qwen3.5 27B | **37/40 (92.5%)** | 28/40 (70.0%) | - -![All tasks combined: success rate by runner](01_qwen35_combined_success_rate.png) - -That is the headline. - -The more important point is what sits underneath it: - -- **4B:** Villani solves more tasks, but usually takes longer to get there. -- **9B:** Villani still solves more, with a meaningful gap in useful work done. -- **27B:** Villani is better on both axes. Higher solve rate, lower total runtime. - -That last result matters because it kills the easy dismissal. - -This is not just “slower but a bit more thorough.” - -At 27B, the runtime is shifting the frontier. - -## Why this matters - -Most agent benchmarks quietly reward the easiest operating conditions: - -- strong hosted models -- huge context windows -- vague task definitions -- weak verification pressure -- workflows that hide failure behind long transcripts - -That is not where the interesting product opportunity is. - -The interesting opportunity is in making **smaller, cheaper, private models** do work that people would otherwise assume requires a much stronger backend. - -If you can reliably pull useful repo work out of 4B to 30B-class local models, you get three things at once: - -- lower inference cost -- better privacy posture -- a much wider deployment surface - -That is a serious wedge. - -## Where Villani is strongest - -The current runs show that the gains are not randomly distributed. - -Villani is especially strong on **bounded repo work** such as bug fixing, file localization, and terminal-centric tasks where the runtime can keep the model disciplined. - -![Solved tasks by task type](01_solved_tasks_by_type_fixed.png) - -That split matters. - -Anyone can get a model to look clever in an open-ended coding conversation. - -The harder problem is getting it to: - -- find the right area of the repo -- make a useful patch -- avoid unnecessary drift -- survive verification - -That is where smaller models usually fall apart. - -That is exactly where runtime design starts to matter. - -## What Villani Code actually is - -Villani Code is a terminal-first coding agent runtime for: - -- bounded bug fixes -- repo navigation and localization -- test-guided patching -- constrained maintenance work -- local inference setups -- privacy-sensitive codebases - -It is not trying to be an all-purpose autonomous software engineer. +```bash +git clone https://github.com/mmprotest/villani-code.git +cd villani-code +pip install .[tui] +``` -It is trying to be something much more commercially useful: +For headless CLI use only: -**a system that turns underestimated local models into tools that can land real work.** +```bash +pip install . +``` -## What makes it different +For development: -### Built for constrained backends -Villani is designed around the failure modes of smaller models, not around the fantasy that every user has a frontier API on tap. +```bash +pip install .[dev] +``` -### Terminal first -It lives where coding work actually happens: files, diffs, commands, tests, verification, and repo state. +### Run against a local model -### Bounded and disciplined -The runtime is built to reduce drift, limit pointless wandering, and keep the model oriented toward a solvable patch. +Start an OpenAI-compatible local server, then point Villani at it: -### Verification-oriented -The target is not a nice conversation. The target is accepted work. +```bash +villani-code interactive \ + --base-url http://127.0.0.1:1234 \ + --model your-model-id \ + --repo /path/to/repository +``` -### Local-first by design -Villani fits environments where shipping private code to a hosted frontier provider is expensive, undesirable, or impossible. +Run a single task: -## The thesis +```bash +villani-code run "Fix the failing tests and verify the result." \ + --base-url http://127.0.0.1:1234 \ + --model your-model-id \ + --repo /path/to/repository +``` -**Model capability is not the whole story. Runtime quality moves the frontier too.** +## Install Villani in Pi -That is the thesis behind Villani Code. +Villani is available as a Pi extension. It runs repository tasks from inside Pi, uses the model selected in Pi, requests approval before protected edits or shell commands, and reports the final result in the Pi interface. -And the benchmark signal in this repo points in the same direction: +### Requirements -- same model family -- same size classes -- different runner -- materially different outcomes +- [Pi](https://github.com/earendil-works/pi) installed +- A model configured and working in Pi +- A Git repository +- Windows x64, macOS Apple Silicon, macOS Intel, or Linux x64 -That is not a branding trick. +### Install the extension -That is the product. +```bash +pi install npm:@mmprotest/pi-villani +``` -## Where it should win +On first use, the extension downloads the Villani runtime for your platform, verifies its SHA-256 checksum, and caches it locally. You do not need to clone this repository or install Python to use Villani from Pi. -Villani Code is a strong fit when: +Confirm that Pi loaded the extension: -- code must stay local or private -- model cost matters -- tasks are bounded and verifier-friendly -- you want more useful work from smaller backends -- you care about limiting what the agent touches +```bash +pi list +``` -## Where it is not trying to win +You should see: -Villani Code is not trying to be: +```text +npm:@mmprotest/pi-villani +``` -- a generic chat shell with tools stapled on -- a frontier-model replacement for every problem -- a flashy demo optimized for open-ended conversations -- a claim that runtime matters more than model forever +### Run Villani in Pi -That is not the point. +Open a terminal in a Git repository and launch Pi: -The point is narrower, and more valuable: +```bash +cd /path/to/repository +pi +``` -**with the right runtime, smaller local models become much harder to dismiss.** +Then invoke Villani: -## Quickstart +```text +/villani Fix the failing tests and verify the result +``` -Install with TUI support: +Stop an active run with: -```bash -pip install .[tui] +```text +/villani-abort ``` -Headless CLI only: -```bash -pip install . -``` +### Update or uninstall the Pi extension -Development dependencies: +Update: ```bash -pip install .[dev] +pi update npm:@mmprotest/pi-villani ``` -Interactive session: +Uninstall: ```bash -villani-code interactive --base-url http://127.0.0.1:1234 --model your-model --repo /path/to/repo +pi remove npm:@mmprotest/pi-villani ``` -One-shot task: +### Pi troubleshooting -```bash -villani-code run "Add retry handling to API client and update tests." --base-url http://127.0.0.1:1234 --model your-model --repo /path/to/repo -``` +If `/villani` does not appear, run `pi list` and reinstall the package. -Autonomous pass: +If Pi shows `/villani:1`, remove duplicate local or npm extension installs, then install only: ```bash -villani-code --villani-mode --base-url http://127.0.0.1:1234 --model your-model --repo /path/to/repo +pi install npm:@mmprotest/pi-villani ``` + +If a local model fails, test it with a normal Pi prompt before running Villani and confirm that the endpoint includes `/v1` and the model ID matches the configured model. diff --git a/Villani_Technical_Report.md b/Villani_Technical_Report.md deleted file mode 100644 index bdf66ee4..00000000 --- a/Villani_Technical_Report.md +++ /dev/null @@ -1,309 +0,0 @@ -# Villani Code Technical Report - -## Executive Summary - -Villani Code is a terminal-first coding agent runtime designed to extract materially better repository work from smaller local models. - -The core idea is simple: weak models fail when the runtime is loose. They drift, read too much, touch the wrong files, and collapse under ambiguous search and verification loops. Villani Code attacks that failure mode directly. It constrains the interaction surface, keeps the agent close to the repository, drives work through explicit tooling, and orients execution around passing verification rather than producing an impressive transcript. - -On the benchmark runs included in this repo, Villani Code outperformed Claude Code on the same Qwen3.5 backends at every tested model size: 4B, 9B, and 27B. At 27B, the result is especially strong because Villani is both more effective and more efficient overall. - -This report covers two things: -1. how the Villani runner works -2. what the current benchmark results show - ---- - -## 1. What Villani Code Is - -Villani Code is not a generic chat wrapper with code tools attached. - -It is a coding-agent runtime built for bounded repository work under real constraints: -- local or self-hosted model backends -- limited context budgets -- explicit filesystem and shell actions -- repository tasks that can be verified with tests, commands, diffs, and acceptance checks - -The target is useful repo work. Not vibes. Not a clever transcript. Not a demo that looks smart while failing verification. - -The runtime is aimed at the class of tasks where a model can be useful if the execution loop is disciplined: -- bounded bug fixes -- file localization and targeted patching -- repo navigation under ambiguity -- constrained maintenance tasks -- verifier-friendly tasks where success is externally testable - ---- - -## 2. Design Thesis - -The Villani thesis is that runtime design can shift the capability frontier of small models. - -Most coding-agent workflows quietly assume strong hosted models, excess context, and enough raw model ability to recover from sloppy execution. That assumption breaks down fast on smaller local models. - -Small models do not need more freedom. They need more structure. - -Villani Code is built around that premise: -- reduce drift -- keep actions grounded in repository state -- force the model to work through tools instead of hand-waving -- prefer narrow, verifiable progress over broad speculative exploration -- optimize for accepted patches, not entertaining chatter - -The consequence is important: the runner becomes part of the capability story. The model matters, but the loop around the model matters too. - ---- - -## 3. How the Villani Runner Works - -At a high level, the Villani runner is a repository execution loop that turns the model into a guided operator inside a constrained environment. - -### 3.1 Core operating model - -The runner takes: -- a task or benchmark objective -- a repository root -- a defined tool surface -- a model backend -- a set of verification signals - -It then executes a bounded loop: - -1. inspect the repo state -2. localize the relevant files and failure points -3. propose a small next action -4. execute through tools -5. observe the result -6. refine -7. stop when the patch is landed or the task boundary is reached - -This sounds obvious, but this is where most small-model failures happen. Weak models are extremely sensitive to the quality of that loop. - -### 3.2 Terminal-first tool loop - -Villani is built around the environment where repository work actually happens: -- files -- diffs -- shell commands -- test runs -- repository state -- bounded write operations - -The runner works through explicit tools rather than relying on free-form reasoning alone. That matters because a tool-mediated loop creates hard feedback. The model cannot simply assert that it fixed the problem. It has to read, act, and survive verification. - -### 3.3 Localization before patching - -A critical part of the Villani approach is front-loading localization pressure. - -Many coding agents waste performance by letting the model roam too widely. Small models are especially bad at this. They over-read, lose the thread, edit adjacent code, or patch the wrong abstraction. - -Villani counters that by pushing the loop toward: -- targeted search -- repo navigation -- explicit file selection -- narrow edit scopes -- patch attempts tied to observed evidence - -This is why the runtime should be especially strong on localization-heavy tasks. - -### 3.4 Small-step action discipline - -The runner is designed to favor small, recoverable actions over broad speculative rewrites. - -Instead of encouraging expansive behavior, it biases toward: -- incremental repo reads -- narrow patches -- rapid verifier feedback -- revision after concrete failure signals - -That discipline is not cosmetic. It is one of the main reasons a smaller backend can keep producing useful work instead of spiraling. - -### 3.5 Verification-oriented execution - -Villani is built around an external notion of success. - -The patch is not successful because the model says it is successful. It is successful because the task verifier, test command, or contract says so. - -In practice that means the loop is centered on: -- command execution -- tests -- visible verifier outputs -- repository diffs -- completion conditions defined by the task - -This is the correct way to run smaller coding models. You do not ask them to be trusted. You make them earn progress under feedback. - -### 3.6 Bounded autonomy - -Villani supports interactive use, one-shot task execution, and bounded autonomous passes. In each case, the central idea is the same: autonomy is applied inside a constrained repo workflow, not as open-ended software-engineer cosplay. - -That distinction matters. The runner is not trying to win by pretending the model is a general autonomous employee. It is trying to win by making bounded code work land reliably. - ---- - -## 4. Why This Runner Should Beat Looser Agent Workflows - -A small model plus a sloppy runtime is a bad system. - -Common failure modes in weaker coding agents include: -- reading too much irrelevant code -- patching the wrong files -- losing task boundaries -- spending tokens on narrative instead of work -- failing to use verifier feedback effectively -- turning simple bugfixes into unstable rewrites - -Villani is built to suppress exactly those behaviors. - -That gives it a structural advantage on bounded tasks where the path to success is: -- find the right place -- make the right change -- prove that it worked - -That is not the full universe of software engineering. It is, however, a very large and commercially relevant slice of repo automation. - ---- - -## 5. Benchmark Setup - -The benchmark results in this report come from the benchmark runs bundled with the current project outputs. - -### 5.1 Compared runners - -The comparison here focuses on: -- Villani Code -- Claude Code - -### 5.2 Shared backends - -Both runners were tested on the same model family and sizes: -- Qwen3.5 4B -- Qwen3.5 9B -- Qwen3.5 27B - -### 5.3 Task pool - -The plotted comparisons combine the benchmark tasks used across the available base and gapfill runs, and the task-type breakdown focuses on the three meaningful task families visible in the benchmark IDs: -- Bugfix -- Localize -- Terminal - -### 5.4 Evaluation focus - -The report emphasizes four outcome views: -- total solve performance -- total runtime -- execution time distribution -- solved-task counts by task type - ---- - -## 6. Benchmark Results - -## 6.1 Overall success rate - -Villani Code led at every tested model size. - -![Overall success rate](01_qwen35_combined_success_rate.png) - -Combined across the plotted task runs: -- **Qwen3.5 4B:** Villani solved 33/40 vs Claude Code 28/40 -- **Qwen3.5 9B:** Villani solved 34/40 vs Claude Code 30/40 -- **Qwen3.5 27B:** Villani solved 37/40 vs Claude Code 28/40 - -That pattern matters for one reason: it is consistent. This is not a single lucky model-size spike. Villani is ahead across the entire tested Qwen3.5 range. - -## 6.2 Success versus total runtime - -The cleanest chart in the set is the frontier chart. - -![Success vs runtime frontier](03_qwen35_combined_frontier_success_vs_total_runtime_reversed_x.png) - -This chart shows the real story: -- at **4B** and **9B**, Villani is more effective overall, though often slower -- at **27B**, Villani is both **better** and **faster** - -That 27B result is the strongest benchmark claim in the current set. It means the Villani runtime is not merely trading speed for quality. On the strongest backend tested, it shifts the frontier outright. - -## 6.3 Execution time distribution - -Execution time is not uniform across the sizes. - -![Execution time distribution](execution_time_distribution.png) - -The distribution view is useful because it prevents a lazy reading of the benchmark story. Villani is not simply “the faster runner” in every condition. That would be a weaker and less interesting claim anyway. - -The stronger claim is this: -- Villani converts runtime into solved work more effectively -- and at 27B, it wins both axes simultaneously - -That is a much more serious result. - -## 6.4 Task-type breakdown - -The task-family chart shows where the runner advantage is concentrated. - -![Solved tasks by task type](01_solved_tasks_by_type_fixed.png) - -Key reads: -- **27B:** Villani wins Bugfix, Localize, and Terminal -- **9B:** Villani is strongest on Terminal and ahead on Localize, while Bugfix is tied -- **4B:** Villani leads overall and is notably stronger on Localize - -The most important family here is **Localize**. - -Localization is where weak coding systems usually embarrass themselves. They search too broadly, edit the wrong place, and lose the thread before the patch even begins. Villani's task-family edge strongly suggests that its tighter repo-navigation and patch-discipline loop is doing real work. - ---- - -## 7. Why the Results Matter - -These benchmark results support a sharp claim: - -**A better runner can extract materially better coding performance from the same small local backend.** - -That is commercially important. - -If this holds in broader use, it means the market does not belong only to the companies with the biggest hosted models. There is real room for a runtime layer that makes smaller local models substantially more useful in production code workflows. - -That matters because smaller local models are: -- cheaper to run -- easier to deploy -- more private -- more controllable -- far more realistic for many organizations than constant dependence on remote frontier APIs - -Villani Code is not selling a fantasy. It is selling a stronger control loop around the model you already have. - ---- - -## 8. Practical Positioning - -Villani Code should be understood as infrastructure for constrained coding performance. - -The strongest fit is: -- organizations that want private code to stay local -- teams that care about model cost -- workflows dominated by bounded repo tasks -- environments where explicit control and verification matter more than flashy agent behavior - -This is not a toy niche. It is a large category of real engineering work. - -A runner that makes 4B to 30B-class local backends materially more useful is not just technically interesting. It is strategically valuable. - ---- - -## 9. Conclusion - -Villani Code is a runtime built around a hard truth: small models fail when the loop around them is weak. - -The answer is not to pretend they are frontier models. -The answer is to run them properly. - -That is what Villani Code does. - -It constrains the execution loop, keeps the agent close to the repository, emphasizes localization, favors small corrective actions, and drives progress through verification. The benchmark results show that this is not abstract theory. It produces stronger repo-task performance than a looser baseline on the same Qwen3.5 backends, and at 27B it wins on both solve rate and total runtime. - -The implication is straightforward. - -**Villani Code does not just use local models. It makes them hit harder.** diff --git a/Villani_Technical_Report.pdf b/Villani_Technical_Report.pdf deleted file mode 100644 index 8b4ba799..00000000 Binary files a/Villani_Technical_Report.pdf and /dev/null differ diff --git a/docs/Villani_Code_9B_Terminal_Bench_Technical_Report_Leaderboard.docx b/docs/Villani_Code_9B_Terminal_Bench_Technical_Report_Leaderboard.docx new file mode 100644 index 00000000..d7defc15 Binary files /dev/null and b/docs/Villani_Code_9B_Terminal_Bench_Technical_Report_Leaderboard.docx differ diff --git a/docs/Villani_Code_9B_Terminal_Bench_Technical_Report_Leaderboard.pdf b/docs/Villani_Code_9B_Terminal_Bench_Technical_Report_Leaderboard.pdf new file mode 100644 index 00000000..9a727716 Binary files /dev/null and b/docs/Villani_Code_9B_Terminal_Bench_Technical_Report_Leaderboard.pdf differ diff --git a/docs/assets/villani_terminal_bench_2_leaderboard_position.png b/docs/assets/villani_terminal_bench_2_leaderboard_position.png new file mode 100644 index 00000000..c9f35bc2 Binary files /dev/null and b/docs/assets/villani_terminal_bench_2_leaderboard_position.png differ diff --git a/execution_time_distribution.png b/execution_time_distribution.png deleted file mode 100644 index e6ff5130..00000000 Binary files a/execution_time_distribution.png and /dev/null differ diff --git a/integrations/pi-villani/README.md b/integrations/pi-villani/README.md new file mode 100644 index 00000000..cc2d5c0f --- /dev/null +++ b/integrations/pi-villani/README.md @@ -0,0 +1,409 @@ +# Villani for Pi + +Villani is a coding runner extension for [Pi](https://github.com/earendil-works/pi). It runs repository tasks from inside Pi, asks for approval before protected edits or shell commands, and reports the final result in the Pi interface. + +## Install in one command + +```bash +pi install npm:@mmprotest/pi-villani +``` + +That is the full Villani installation step. + +On first use, the extension automatically downloads the standalone Villani runtime for your platform, verifies its SHA-256 checksum, and caches it locally. You do **not** need to clone the Villani repository, install Python, create a virtual environment, or configure a separate Villani executable. + +## Requirements + +You need: + +- [Pi](https://github.com/earendil-works/pi) installed. +- A model configured and working in Pi. +- A Git repository to run Villani against. +- A supported platform: + - Windows x64 + - macOS Apple Silicon + - macOS Intel + - Linux x64 + +## Confirm installation + +```bash +pi list +``` + +You should see: + +```text +npm:@mmprotest/pi-villani +``` + +If you see more than one Villani package or a local Villani extension path as well as the npm package, remove the duplicate. A duplicate load can cause Pi to expose the command as `/villani:1` rather than `/villani`. + +## Use Villani + +Open a terminal in any Git repository and start Pi: + +```bash +cd /path/to/your/repository +pi +``` + +In Pi, run a coding task with `/villani`: + +```text +/villani Fix the failing tests and verify the result +``` + +Other examples: + +```text +/villani Add input validation to the parser and add tests +/villani Find the cause of the failing test, make the smallest fix, and run pytest +/villani Add a new endpoint, update tests, and verify the suite +``` + +To stop a current run: + +```text +/villani-abort +``` + +## What happens during a run + +Villani uses the model currently selected in Pi. It can inspect the repository, propose edits, apply patches, and run verification commands. + +When Villani requests a protected operation, Pi shows an approval prompt. Typical approvals include: + +- Writing or modifying a file. +- Applying a patch. +- Running a shell command such as a test suite. + +Review each request before approving it. Villani runs with your normal user permissions inside the current repository. + +At the end of a successful run, Pi displays a final Villani result with the task summary and files changed by Villani. + +## Using LM Studio + +Villani uses Pi's active model, so LM Studio must be configured in Pi first. + +### 1. Start the LM Studio server + +In LM Studio: + +1. Load a coding-capable model. +2. Start the local server. +3. Note the model identifier exposed by the server. + +A tested example model identifier is: + +```text +villanis/models/qwen3.5-9b-q8_0.gguf +``` + +The common LM Studio OpenAI-compatible server endpoint is: + +```text +http://127.0.0.1:1234/v1 +``` + +### 2. Configure the model in Pi + +Create or edit: + +- Windows: `%USERPROFILE%\.pi\agent\models.json` +- macOS/Linux: `~/.pi/agent/models.json` + +Example configuration: + +```json +{ + "providers": { + "lmstudio": { + "baseUrl": "http://127.0.0.1:1234/v1", + "api": "openai-completions", + "apiKey": "dummy", + "compat": { + "supportsDeveloperRole": false, + "supportsReasoningEffort": false + }, + "models": [ + { + "id": "villanis/models/qwen3.5-9b-q8_0.gguf", + "name": "Qwen 3.5 9B Q8 Local", + "input": ["text"], + "reasoning": false, + "contextWindow": 100000, + "maxTokens": 16384 + } + ] + } + } +} +``` + +Replace the model `id` and `name` with the model you loaded in LM Studio. + +### 3. Launch Pi using the LM Studio model + +```bash +pi --provider lmstudio --model "villanis/models/qwen3.5-9b-q8_0.gguf" +``` + +Then run: + +```text +/villani Fix the failing tests and verify the result +``` + +### PowerShell setup for the tested LM Studio model + +Windows users can create the Pi model configuration with this command: + +```powershell +$model = "villanis/models/qwen3.5-9b-q8_0.gguf" +$piDir = Join-Path $HOME ".pi\agent" +$modelsFile = Join-Path $piDir "models.json" + +New-Item -ItemType Directory -Force -Path $piDir | Out-Null + +$config = @{ + providers = @{ + lmstudio = @{ + baseUrl = "http://127.0.0.1:1234/v1" + api = "openai-completions" + apiKey = "dummy" + compat = @{ + supportsDeveloperRole = $false + supportsReasoningEffort = $false + } + models = @( + @{ + id = $model + name = "Qwen 3.5 9B Q8 Local" + input = @("text") + reasoning = $false + contextWindow = 100000 + maxTokens = 16384 + } + ) + } + } +} | ConvertTo-Json -Depth 10 + +[System.IO.File]::WriteAllText( + $modelsFile, + $config, + [System.Text.UTF8Encoding]::new($false) +) + +pi --provider lmstudio --model $model +``` + +## Using another Pi model provider + +Villani does not require LM Studio. It reuses the model selected in Pi, including supported cloud providers or other OpenAI-compatible local servers configured in Pi. + +Once a normal Pi prompt works with your chosen model, use Villani in the same session: + +```text +/villani Implement the requested change and run the relevant tests +``` + +## Runtime download and cache + +On the first Villani run, the extension downloads the runtime for your operating system from the Villani GitHub release assets and verifies it before execution. + +Runtime cache locations: + +| Platform | Cache location | +| --- | --- | +| Windows | `%LOCALAPPDATA%\pi-villani\runtime\` | +| macOS/Linux | `~/.cache/pi-villani/runtime/` | + +To force a clean runtime download on Windows: + +```powershell +Remove-Item "$env:LOCALAPPDATA\pi-villani\runtime" -Recurse -Force -ErrorAction SilentlyContinue +``` + +To force a clean runtime download on macOS or Linux: + +```bash +rm -rf ~/.cache/pi-villani/runtime +``` + +## Update Villani + +Install the latest published package update: + +```bash +pi update npm:@mmprotest/pi-villani +``` + +You can also remove and reinstall the package: + +```bash +pi remove npm:@mmprotest/pi-villani +pi install npm:@mmprotest/pi-villani +``` + +## Uninstall Villani + +```bash +pi remove npm:@mmprotest/pi-villani +``` + +Optional: delete the downloaded runtime cache. + +Windows PowerShell: + +```powershell +Remove-Item "$env:LOCALAPPDATA\pi-villani" -Recurse -Force -ErrorAction SilentlyContinue +``` + +macOS/Linux: + +```bash +rm -rf ~/.cache/pi-villani +``` + +## Troubleshooting + +### `/villani` is missing + +Check that Pi installed the extension: + +```bash +pi list +``` + +You should see `npm:@mmprotest/pi-villani`. + +Reinstall if required: + +```bash +pi remove npm:@mmprotest/pi-villani +pi install npm:@mmprotest/pi-villani +``` + +### Pi shows `/villani:1` + +Pi has loaded Villani more than once, usually because both a local development path and the npm package are installed. + +```bash +pi list +``` + +Remove every old or local Villani entry, then install only the public package: + +```bash +pi install npm:@mmprotest/pi-villani +``` + +### The model works in LM Studio but not in Pi + +First test the model with a normal Pi prompt: + +```bash +pi --provider lmstudio --model "your-model-id" +``` + +Then ask: + +```text +Reply with exactly: hello +``` + +If the normal Pi prompt fails, fix the Pi or LM Studio model configuration before testing Villani. + +Check: + +- The LM Studio server is running. +- The `baseUrl` includes `/v1`. +- The model ID exactly matches the model shown by LM Studio. +- `models.json` is valid JSON saved as UTF-8. + +### Runtime download fails + +Check access to GitHub Releases and retry. To remove a partially cached runtime, clear the runtime cache using the commands above and launch Villani again. + +### Checksum verification fails + +Villani will not execute a runtime archive that does not match its published checksum. Clear the cache and retry. If the error persists, report the runtime asset or checksum mismatch. + +### Approval prompt does not appear + +Run Pi interactively in a terminal. Approval-required operations are denied when no usable Pi confirmation UI is available. + +### A run needs to be stopped + +Use: + +```text +/villani-abort +``` + +### Debugging output + +For troubleshooting only, enable extension diagnostics before launching Pi. + +Windows PowerShell: + +```powershell +$env:VILLANI_PI_DEBUG = "1" +pi +``` + +macOS/Linux: + +```bash +VILLANI_PI_DEBUG=1 pi +``` + +Turn debug output off after troubleshooting. + +Windows PowerShell: + +```powershell +Remove-Item Env:VILLANI_PI_DEBUG -ErrorAction SilentlyContinue +``` + +macOS/Linux: + +```bash +unset VILLANI_PI_DEBUG +``` + +## Advanced development override + +Normal users do not need this. + +Developers can bypass automatic runtime download and point the extension at a local Villani executable using `VILLANI_COMMAND`: + +Windows PowerShell: + +```powershell +$env:VILLANI_COMMAND = "C:\path\to\villani-code\.venv\Scripts\villani-code.exe" +pi +``` + +macOS/Linux: + +```bash +VILLANI_COMMAND="/path/to/villani-code/.venv/bin/villani-code" pi +``` + +## Security notes + +- Pi packages execute code with your normal user permissions. +- Villani can read and modify files in the repository where it is run. +- Review approval prompts before allowing edits or commands. +- The downloaded standalone runtime is verified using the published SHA-256 checksum before it is executed. +- In normal operation, Villani uses the active Pi model through a temporary local proxy bound to `127.0.0.1`. + +## Reference links + +- Pi package installation documentation: +- Pi custom model configuration documentation: +- Villani Code repository: +- Villani Pi npm package: diff --git a/integrations/pi-villani/docs/pi-model-bridge.md b/integrations/pi-villani/docs/pi-model-bridge.md new file mode 100644 index 00000000..9d2e4fac --- /dev/null +++ b/integrations/pi-villani/docs/pi-model-bridge.md @@ -0,0 +1,53 @@ +# Pi-backed model bridge + +The Pi extension reuses Pi's active model by default through a temporary localhost OpenAI-compatible proxy. It resolves Pi-managed API keys and provider headers with `ctx.modelRegistry.getApiKeyAndHeaders(model)` and passes them only into Pi AI calls inside the Node process. This avoids asking users to configure provider/model/base URL/API key twice and avoids exposing upstream credentials to the Python child. + +```text +Villani Runner OpenAIClient + -> http://127.0.0.1:/v1/chat/completions + -> pi-villani local proxy + -> @earendil-works/pi-ai complete() + -> user's configured Pi provider/model/auth +``` + +## Villani API surface implemented + +Villani's `OpenAIClient` calls: + +- `POST /v1/chat/completions` +- payload fields: `model`, `messages`, `max_tokens`, `stream`, optional `tools`, optional `stream_options` +- OpenAI function tool calls and tool-result messages +- non-streaming JSON responses +- SSE streaming responses using `data: ...` lines followed by `data: [DONE]` + +The proxy implements exactly that path. It translates: + +- OpenAI `system` messages into Pi `systemPrompt` +- OpenAI user messages into Pi user messages +- OpenAI assistant tool calls into Pi `toolCall` content +- OpenAI tool messages into Pi `toolResult` messages +- OpenAI function tool definitions into Pi `Tool` definitions +- Pi text/tool-call assistant content back into OpenAI `message.content` and `message.tool_calls` + +## Runtime behavior + +- One proxy is started per active `/villani` run. +- The proxy binds to `127.0.0.1` only. +- The OS chooses a random available port. +- The proxy is stopped on success, failure, abort and subprocess startup failure. +- The Python child receives no Pi provider credentials: only the localhost proxy URL and neutral model id are sent through the bridge. + +## Configuration precedence + +1. Default: use Pi's active model through the local proxy. +2. If `VILLANI_USE_PI_MODEL=false`, skip the proxy and use explicit `VILLANI_PROVIDER`, `VILLANI_MODEL`, `VILLANI_BASE_URL` and optional `VILLANI_API_KEY`. +3. If Pi has no active model and explicit fallback is not enabled, `/villani` fails with a configuration message rather than guessing. + +## Streaming limitation + +The current proxy uses `@earendil-works/pi-ai` `complete()` and emits the completed assistant response as a single OpenAI-compatible SSE chunk when Villani asks for streaming. This exercises Villani's streaming client path but does not provide token-by-token streaming. If Pi returns `stopReason: "error"`, throws a provider/auth failure, or the per-run abort signal fires, the proxy returns an HTTP error instead of `[DONE]` or an empty successful completion. True token streaming can be added later by translating Pi `stream()` events to OpenAI SSE chunks. + + +## Permission boundary + +The model proxy is separate from tool approval. Pi-managed model credentials stay inside the proxy, while tool permissions are enforced by Villani's Python runner and bridged back to Pi with `approval_required` / `approval_response` JSONL messages. The proxy never grants file or shell permissions, and approval prompts never include provider API keys or OAuth headers. diff --git a/integrations/pi-villani/docs/releasing.md b/integrations/pi-villani/docs/releasing.md new file mode 100644 index 00000000..8cfb9bb1 --- /dev/null +++ b/integrations/pi-villani/docs/releasing.md @@ -0,0 +1,18 @@ +# Releasing pi-villani + +The Pi extension and the standalone Villani runtime currently share version `0.1.0`. + +1. Update `integrations/pi-villani/package.json` and `integrations/pi-villani/src/runtimeConfig.ts` to the new version. +2. Push a tag named `pi-villani-runtime-vX.Y.Z`. +3. Confirm GitHub Actions workflow `Build pi-villani runtime release` builds and smoke-tests every supported runtime archive. +4. Confirm the GitHub Release contains: + - `villani-runtime-vX.Y.Z-win32-x64.zip` + - `villani-runtime-vX.Y.Z-darwin-arm64.tar.gz` + - `villani-runtime-vX.Y.Z-darwin-x64.tar.gz` + - `villani-runtime-vX.Y.Z-linux-x64.tar.gz` + - `checksums.txt` +5. Run `cd integrations/pi-villani && npm ci && npm run build && npm test && npm pack --dry-run`. +6. Publish the npm package only after the runtime assets exist for the version referenced by `VILLANI_RUNTIME_VERSION`. +7. In a clean Pi environment, run `pi install npm:pi-villani`, open Pi in a repository, and smoke-test `/villani` plus `/villani-abort`. + +Do not publish an npm package that references runtime assets that have not been uploaded yet. diff --git a/integrations/pi-villani/package-lock.json b/integrations/pi-villani/package-lock.json new file mode 100644 index 00000000..bd70dcc8 --- /dev/null +++ b/integrations/pi-villani/package-lock.json @@ -0,0 +1,3444 @@ +{ + "name": "@mmprotest/pi-villani", + "version": "0.1.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@mmprotest/pi-villani", + "version": "0.1.1", + "license": "MIT", + "dependencies": { + "adm-zip": "^0.5.16", + "tar": "^7.4.3" + }, + "devDependencies": { + "@earendil-works/pi-ai": "^0.77.0", + "@earendil-works/pi-coding-agent": "^0.77.0", + "@types/adm-zip": "^0.5.7", + "@types/node": "^20.11.30", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=22.19.0" + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@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/@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.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.15.tgz", + "integrity": "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@aws-sdk/xml-builder": "^3.972.26", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.5", + "@smithy/signature-v4": "^5.4.5", + "@smithy/types": "^4.14.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.41.tgz", + "integrity": "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.43.tgz", + "integrity": "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.5.tgz", + "integrity": "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.45.tgz", + "integrity": "sha512-sJe5ZWibO4s7RWjFQ8Zol76KxoJcIYyEZH1/wxQSBMSIAAxzaJ8cS/ITAaIHWUQvDKQdt18+cJAHKWB7n1Jmrg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-env": "^3.972.41", + "@aws-sdk/credential-provider-http": "^3.972.43", + "@aws-sdk/credential-provider-login": "^3.972.45", + "@aws-sdk/credential-provider-process": "^3.972.41", + "@aws-sdk/credential-provider-sso": "^3.972.45", + "@aws-sdk/credential-provider-web-identity": "^3.972.45", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/credential-provider-imds": "^4.3.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.45.tgz", + "integrity": "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.46.tgz", + "integrity": "sha512-cS4w0jzDRb1jOlkiJS3y80OxddHzkky/MN9k3NYs5jganNKVLjF0lpvjlwS118oGMr3cdAfOlVdo8gLurTSE7w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.41", + "@aws-sdk/credential-provider-http": "^3.972.43", + "@aws-sdk/credential-provider-ini": "^3.972.45", + "@aws-sdk/credential-provider-process": "^3.972.41", + "@aws-sdk/credential-provider-sso": "^3.972.45", + "@aws-sdk/credential-provider-web-identity": "^3.972.45", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/credential-provider-imds": "^4.3.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.41.tgz", + "integrity": "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.45.tgz", + "integrity": "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/token-providers": "3.1056.0", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1056.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1056.0.tgz", + "integrity": "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.45.tgz", + "integrity": "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.18.tgz", + "integrity": "sha512-QPQhwY/fstR8fMZFWrsJRNoTP6D1RjRPHGRX7u9/VkF3opCsvD0oXPz6qzkX94SchzvuS5vyFZbJbPcMEs2Jeg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.14.tgz", + "integrity": "sha512-DoZ4djVj/74XQ6M/IwxuKh543tTvLCL7u1Dx+VDHMgW9yGNrFSJJ1l0LrUQRaekic5CB12wUiiOoHL0VI6H0gg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.23", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.23.tgz", + "integrity": "sha512-F0d4A9pJFiwljyKgSwU1Z5n+CXSv8bp+V5SthbS2rftB8wBN9z1K2Yyv3xbeK0AM2T0g4q6Ptf0shFF+oQZyiA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/signature-v4": "^5.4.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.13.tgz", + "integrity": "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg==", + "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.15", + "@aws-sdk/signature-v4-multi-region": "^3.996.30", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.5.tgz", + "integrity": "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz", + "integrity": "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/signature-v4": "^5.4.5", + "@smithy/types": "^4.14.2", + "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.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", + "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "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/@aws-sdk/xml-builder": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.26.tgz", + "integrity": "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.2", + "fast-xml-parser": "5.7.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.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.77.0.tgz", + "integrity": "sha512-H21BrQDPf3ydaeBmS5maNDHxUGFMiKBF/n3WnE+OTWloIZSayeL+/NVEgG3aKQw8fZL6HAMYAGpUIVJgFuKtnw==", + "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.1", + "@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.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.77.0.tgz", + "integrity": "sha512-huS+k+dhQRR9PlTK7crLfeSRUw3a96V6JYfP0ZH3Zkko/m10gsYk8dKQmwScSy5Dll516pXorz19BURfD6S2qQ==", + "dev": true, + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "^0.77.0", + "@earendil-works/pi-ai": "^0.77.0", + "@earendil-works/pi-tui": "^0.77.0", + "@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", + "typebox": "1.1.38", + "undici": "8.3.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.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.77.0.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "^0.77.0", + "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.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.77.0.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.1", + "@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.77.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.77.0.tgz", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "15.0.12" + }, + "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.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", + "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + } + }, + "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/@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.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "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/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "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": "15.0.12", + "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", + "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "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.5.9", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.9.tgz", + "integrity": "sha512-Od4muIm3HW1AouyHF5lONOf1FWo3hY1NbFDoy191X9GzhpgW1clCoaFjfVs2rKJNFYpTNJbje4cbAIDBZJ63ZA==", + "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.0", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "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/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.3.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", + "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", + "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.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "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/@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.1", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.1.tgz", + "integrity": "sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + } + }, + "node_modules/@nodable/entities": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.1.tgz", + "integrity": "sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "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/inquire": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", + "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.24.5", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz", + "integrity": "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==", + "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/@smithy/credential-provider-imds": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.5.tgz", + "integrity": "sha512-yiF8xHpdkaTfzLVqFzsP6WvNghEK+qZzLYWFD13L2SsFhbXwBGlxdocKF95qjr7s5lE5NRage+EJFK4mAsx88Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.5.tgz", + "integrity": "sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "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.4.5", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.5.tgz", + "integrity": "sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "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/@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.8", + "resolved": "https://registry.npmjs.org/@types/adm-zip/-/adm-zip-0.5.8.tgz", + "integrity": "sha512-RVVH7QvZYbN+ihqZ4kX/dMiowf6o+Jk1fNwiSdx0NahBJLU787zkULhGhJM8mf/obmLGmgdMM0bXsQTmyfbR7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.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.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "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/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/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/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.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/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/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/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/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/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/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/protobufjs": { + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz", + "integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==", + "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/inquire": "^1.1.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/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/tar": { + "version": "7.5.15", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.15.tgz", + "integrity": "sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "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.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "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/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/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/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..07a276bd --- /dev/null +++ b/integrations/pi-villani/package.json @@ -0,0 +1,49 @@ +{ + "name": "@mmprotest/pi-villani", + "version": "0.1.1", + "description": "Pi extension bridge for running Villani Code as a delegated coding runner.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "npm run build && node --test dist/*.test.js" + }, + "devDependencies": { + "@earendil-works/pi-ai": "^0.77.0", + "@earendil-works/pi-coding-agent": "^0.77.0", + "@types/node": "^20.11.30", + "typescript": "^5.4.0", + "@types/adm-zip": "^0.5.7" + }, + "engines": { + "node": ">=22.19.0" + }, + "type": "module", + "keywords": [ + "pi-package", + "pi-extension", + "villani-code" + ], + "license": "MIT", + "files": [ + "dist", + "!dist/*.test.js", + "!dist/*.test.d.ts", + "README.md", + "docs" + ], + "pi": { + "extensions": [ + "./dist/index.js" + ] + }, + "peerDependencies": { + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-coding-agent": "*" + }, + "dependencies": { + "adm-zip": "^0.5.16", + "tar": "^7.4.3" + } +} diff --git a/integrations/pi-villani/src/extension.test.ts b/integrations/pi-villani/src/extension.test.ts new file mode 100644 index 00000000..c45be6cc --- /dev/null +++ b/integrations/pi-villani/src/extension.test.ts @@ -0,0 +1,628 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import type { Model } from "@earendil-works/pi-ai"; +import villaniPiExtension, { __setApprovalPrompterForTests, __setBridgeStarterForTests } from "./index.js"; +import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent"; +import { VillaniBridgeProcess, type BridgeProcessOptions } from "./process.js"; +import { resolveRuntimeAsset, VILLANI_RUNTIME_VERSION } from "./runtimeConfig.js"; + +async function mockNodeBridgeModule(exitAfterRun = false): Promise { + const dir = await mkdtemp(join(tmpdir(), "villani-extension-")); + const modulePath = join(dir, "bridge.mjs"); + await writeFile(modulePath, ` + process.stdout.write('{"type":"ready","protocol_version":1}\\n'); + process.stdin.setEncoding('utf8'); + let buffer = ''; + const approvalResponses = new Map(); + process.stdin.on('data', chunk => { + buffer += chunk; + for (;;) { + const idx = buffer.indexOf('\\n'); + if (idx < 0) break; + const line = buffer.slice(0, idx); buffer = buffer.slice(idx + 1); + if (!line.trim()) continue; + const msg = JSON.parse(line); + if (msg.type === 'run') { + process.stdout.write(JSON.stringify({type:'run_started', id:msg.id, run_id:msg.id, task:msg.task, repo:msg.repo, mode:msg.mode}) + '\\n'); + if (process.env.MOCK_APPROVAL === '1') { + process.stdout.write(JSON.stringify({type:'approval_required', id:msg.id, request_id:'approval-1', tool:'Write', summary:'Write file: safe-test.txt', input:{path:'safe-test.txt'}}) + '\\n'); + continue; + } + if (process.env.MOCK_APPROVAL === '2') { + process.stdout.write(JSON.stringify({type:'approval_required', id:msg.id, request_id:'approval-1', tool:'Write', summary:'Write file: one.txt', input:{path:'one.txt'}}) + '\\n'); + continue; + } + if (process.env.MOCK_APPROVAL === 'BASH') { + process.stdout.write(JSON.stringify({type:'approval_required', id:msg.id, request_id:'approval-bash', tool:'Bash', summary:'Run command: pip install package-name', input:{command:'pip install package-name'}}) + '\\n'); + continue; + } + if (${JSON.stringify(exitAfterRun)}) { process.stdout.write(JSON.stringify({type:'run_completed', id:msg.id, success:true, changed_files:[], preexisting_dirty_files:[], verification_passed:null, summary:'done', transcript_path:null}) + '\\n'); setTimeout(() => process.exit(0), 10); } + } + if (msg.type === 'approval_response') { + const count = (approvalResponses.get(msg.request_id) || 0) + 1; + approvalResponses.set(msg.request_id, count); + process.stdout.write(JSON.stringify({type:'phase', id:msg.id, phase:'test', message:'approval_response:' + msg.request_id + ':' + msg.approved + ':count:' + count}) + '\\n'); + process.stdout.write(JSON.stringify({type:'approval_resolved', id:msg.id, request_id:msg.request_id, tool: msg.request_id === 'approval-2' ? 'Patch' : 'Write', approved:msg.approved}) + '\\n'); + if (process.env.MOCK_APPROVAL === '2' && msg.request_id === 'approval-1') { + process.stdout.write(JSON.stringify({type:'approval_required', id:msg.id, request_id:'approval-2', tool:'Patch', summary:'Apply patch to: two.txt', input:{path:'two.txt'}}) + '\\n'); + process.env.MOCK_APPROVAL = 'DONE'; + continue; + } + process.stdout.write(JSON.stringify({type:'run_completed', id:msg.id, success:true, changed_files:[], preexisting_dirty_files:[], verification_passed:null, summary:'done', transcript_path:null}) + '\\n'); + setTimeout(() => process.exit(0), 10); + } + if (msg.type === 'abort') { process.stdout.write(JSON.stringify({type:'run_aborted', id:msg.id, success:false, summary:'Aborted by test', changed_files:[], preexisting_dirty_files:[]}) + '\\n'); setTimeout(() => process.exit(0), 10); } + } + }); + `, "utf8"); + return modulePath; +} + +function installMockBridgeStarter(modulePath: string, calls: BridgeProcessOptions[] = []): () => void { + return __setBridgeStarterForTests(async (options) => { + calls.push(options); + const bridge = new VillaniBridgeProcess({ + spec: { + executable: process.execPath, + args: [modulePath], + display: process.execPath, + }, + cwd: options.cwd, + env: options.env, + signal: options.signal, + readyTimeoutMs: options.readyTimeoutMs ?? 1000, + }); + await bridge.waitUntilReady(); + return bridge; + }); +} + +async function installCachedRuntimeBridge(): Promise<{ cacheRoot: string; executable: string }> { + const cacheRoot = await mkdtemp(join(tmpdir(), "villani-runtime-cache-")); + const asset = resolveRuntimeAsset(); + const finalDir = join(cacheRoot, VILLANI_RUNTIME_VERSION, asset.platformKey); + const runtimeExecutable = join(finalDir, asset.executableRelativePath); + const runtimeDir = join(finalDir, "villani-code"); + await mkdir(runtimeDir, { recursive: true }); + await writeFile(runtimeExecutable, "test placeholder; not executed\n", "utf8"); + if (process.platform !== "win32") await chmod(runtimeExecutable, 0o755); + await writeFile(join(finalDir, ".verified.json"), JSON.stringify({ runtimeVersion: VILLANI_RUNTIME_VERSION, assetName: asset.assetName, checksum: "a".repeat(64) }), "utf8"); + return { cacheRoot, executable: runtimeExecutable }; +} + +async function waitForCondition(predicate: () => boolean, timeoutMs = 3000): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) throw new Error("Timed out waiting for test condition."); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + +function createHost() { + const commands = new Map Promise>(); + const sentMessages: Array<{ content?: unknown }> = []; + const api = { + registerCommand(name: string, options: { handler: (args: string, ctx: ExtensionCommandContext) => Promise }) { + commands.set(name, options.handler); + }, + sendMessage(message: { content?: unknown }) { + sentMessages.push(message); + }, + } as unknown as ExtensionAPI; + return { api, commands, sentMessages }; +} + +function sentMessageText(host: ReturnType): string { + return host.sentMessages + .map((message) => typeof message.content === "string" ? message.content : String(message.content ?? "")) + .join("\n"); +} + +function fakeModel(): Model { + return { + id: "pi-test", + name: "Pi Test", + api: "openai-completions", + provider: "pi", + baseUrl: "pi://current", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + } as Model; +} + +function createContext(messages: string[], options: { model?: Model; authDelayMs?: number; signal?: AbortSignal; throwStatusMatching?: RegExp; throwWidgetMatching?: RegExp; confirmResult?: boolean; confirmError?: Error } = {}): ExtensionCommandContext { + return { + cwd: process.cwd(), + model: options.model, + signal: options.signal, + modelRegistry: { + getApiKeyAndHeaders: async (model: Model) => { + if (options.authDelayMs) await new Promise((resolve) => setTimeout(resolve, options.authDelayMs)); + messages.push(`auth:${model.id}`); + return { ok: true, apiKey: "pi-secret", headers: { Authorization: "Bearer pi-token" } }; + }, + }, + hasUI: true, + ui: { + notify: (message: string) => messages.push(message), + confirm: async (title: string, message: string) => { + messages.push(`confirm:${title}:${message}`); + if (options.confirmError) throw options.confirmError; + return options.confirmResult ?? false; + }, + setStatus: (_key: string, text: string | undefined) => { + const rendered = text ? `status:${text}` : "status:clear"; + if (text && options.throwStatusMatching?.test(text)) throw new Error("status failed"); + messages.push(rendered); + if (text) messages.push(text); + }, + setWidget: (_key: string, lines: string[] | undefined, widgetOptions?: { placement?: string }) => { + const rendered = lines ? `widget:${lines.join("|")}:placement:${widgetOptions?.placement ?? "none"}` : "widget:clear"; + if (lines && options.throwWidgetMatching?.test(lines.join("\n"))) throw new Error("widget failed"); + messages.push(rendered); + }, + }, + } as unknown as ExtensionCommandContext; +} + +test("extension registers /villani, /villani-abort, and /villani-confirm-test", () => { + const host = createHost(); + villaniPiExtension(host.api); + assert.equal(host.commands.has("villani"), true); + assert.equal(host.commands.has("villani-abort"), true); + assert.equal(host.commands.has("villani-confirm-test"), true); +}); + + +test("/villani-confirm-test uses ctx.ui.confirm", async () => { + const host = createHost(); + const messages: string[] = []; + villaniPiExtension(host.api); + await host.commands.get("villani-confirm-test")!("", createContext(messages, { confirmResult: true })); + const joined = messages.join("\n"); + assert.match(joined, /confirm:Villani confirmation smoke test/); + assert.match(joined, /Villani confirm smoke test: approved/); +}); + +test("/villani-abort reports no active run", async () => { + const host = createHost(); + const messages: string[] = []; + villaniPiExtension(host.api); + await host.commands.get("villani-abort")!("", createContext(messages)); + assert.match(messages.join("\n"), /No active Villani run/); +}); + +test("prevents overlapping runs and aborts active bridge run", async () => { + const oldCommand = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const oldProvider = process.env.VILLANI_PROVIDER; + const oldModel = process.env.VILLANI_MODEL; + const oldBase = process.env.VILLANI_BASE_URL; + const restoreBridge = installMockBridgeStarter(await mockNodeBridgeModule(false)); + process.env.VILLANI_COMMAND = "mock-villani"; + process.env.VILLANI_USE_PI_MODEL = "false"; + process.env.VILLANI_PROVIDER = "openai"; + process.env.VILLANI_MODEL = "fake"; + process.env.VILLANI_BASE_URL = "http://127.0.0.1:9"; + try { + const host = createHost(); + const messages: string[] = []; + villaniPiExtension(host.api); + const ctx = createContext(messages); + const runPromise = host.commands.get("villani")!("fix it", ctx); + await waitForCondition(() => messages.some((message) => /Villani started/.test(message))); + await host.commands.get("villani")!("second", ctx); + assert.match(messages.join("\n"), /already running/); + await host.commands.get("villani-abort")!("", ctx); + await runPromise; + assert.match(sentMessageText(host), /Villani aborted/); + } finally { + restoreBridge(); + restoreEnv({ oldCommand, oldUsePi, oldProvider, oldModel, oldBase }); + } +}); + +test("Pi model path resolves model auth", async () => { + const oldCommand = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const restoreBridge = installMockBridgeStarter(await mockNodeBridgeModule(true)); + process.env.VILLANI_COMMAND = "mock-villani"; + delete process.env.VILLANI_USE_PI_MODEL; + try { + const host = createHost(); + const messages: string[] = []; + villaniPiExtension(host.api); + await host.commands.get("villani")!("fix it", createContext(messages, { model: fakeModel() })); + assert.equal(messages.includes("auth:pi-test"), true); + assert.match(sentMessageText(host), /Villani completed/); + } finally { + restoreBridge(); + setOrDelete("VILLANI_COMMAND", oldCommand); + setOrDelete("VILLANI_USE_PI_MODEL", oldUsePi); + } +}); + +test("explicit Villani config does not resolve Pi credentials", async () => { + const oldCommand = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const oldProvider = process.env.VILLANI_PROVIDER; + const oldModel = process.env.VILLANI_MODEL; + const oldBase = process.env.VILLANI_BASE_URL; + const restoreBridge = installMockBridgeStarter(await mockNodeBridgeModule(true)); + process.env.VILLANI_COMMAND = "mock-villani"; + process.env.VILLANI_USE_PI_MODEL = "false"; + process.env.VILLANI_PROVIDER = "openai"; + process.env.VILLANI_MODEL = "fake"; + process.env.VILLANI_BASE_URL = "http://127.0.0.1:9"; + try { + const host = createHost(); + const messages: string[] = []; + villaniPiExtension(host.api); + await host.commands.get("villani")!("fix it", createContext(messages, { model: fakeModel() })); + assert.equal(messages.some((line) => line.startsWith("auth:")), false); + } finally { + restoreBridge(); + restoreEnv({ oldCommand, oldUsePi, oldProvider, oldModel, oldBase }); + } +}); + +test("abort during startup is recognized before bridge exists", async () => { + const host = createHost(); + const messages: string[] = []; + villaniPiExtension(host.api); + const ctx = createContext(messages, { model: fakeModel(), authDelayMs: 500 }); + const runPromise = host.commands.get("villani")!("fix it", ctx); + await new Promise((resolve) => setTimeout(resolve, 50)); + await host.commands.get("villani-abort")!("", ctx); + await runPromise; + assert.doesNotMatch(messages.join("\n"), /No active Villani run/); + assert.match(messages.join("\n"), /cancelled/); +}); + + +test("default path uses cached downloaded runtime before launching bridge", async () => { + const oldCommand = process.env.VILLANI_COMMAND; + const oldCache = process.env.VILLANI_RUNTIME_CACHE_DIR; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const oldProvider = process.env.VILLANI_PROVIDER; + const oldModel = process.env.VILLANI_MODEL; + const oldBase = process.env.VILLANI_BASE_URL; + const bridgeCalls: BridgeProcessOptions[] = []; + const restoreBridge = installMockBridgeStarter(await mockNodeBridgeModule(true), bridgeCalls); + const cachedRuntime = await installCachedRuntimeBridge(); + process.env.VILLANI_RUNTIME_CACHE_DIR = cachedRuntime.cacheRoot; + delete process.env.VILLANI_COMMAND; + process.env.VILLANI_USE_PI_MODEL = "false"; + process.env.VILLANI_PROVIDER = "openai"; + process.env.VILLANI_MODEL = "fake"; + process.env.VILLANI_BASE_URL = "http://127.0.0.1:9"; + try { + const host = createHost(); + const messages: string[] = []; + villaniPiExtension(host.api); + await host.commands.get("villani")!("fix it", createContext(messages)); + assert.match(sentMessageText(host), /Villani completed/); + assert.equal(bridgeCalls[0]?.command, cachedRuntime.executable); + } finally { + restoreBridge(); + restoreEnv({ oldCommand, oldUsePi, oldProvider, oldModel, oldBase }); + setOrDelete("VILLANI_RUNTIME_CACHE_DIR", oldCache); + } +}); + + + +test("startup and final events update visible persistent UI", async () => { + const oldCommand = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const oldProvider = process.env.VILLANI_PROVIDER; + const oldModel = process.env.VILLANI_MODEL; + const oldBase = process.env.VILLANI_BASE_URL; + const restoreBridge = installMockBridgeStarter(await mockNodeBridgeModule(true)); + process.env.VILLANI_COMMAND = "mock-villani"; + process.env.VILLANI_USE_PI_MODEL = "false"; + process.env.VILLANI_PROVIDER = "openai"; + process.env.VILLANI_MODEL = "fake"; + process.env.VILLANI_BASE_URL = "http://127.0.0.1:9"; + try { + const host = createHost(); + const messages: string[] = []; + villaniPiExtension(host.api); + await host.commands.get("villani")!("fix it", createContext(messages)); + const joined = messages.join("\n"); + assert.match(joined, /status:Villani: starting/); + assert.match(joined, /status:Villani: starting model proxy/); + assert.match(joined, /status:Villani: starting runtime/); + assert.match(joined, /status:Villani: running/); + assert.match(joined, /status:clear/); + assert.match(joined, /widget:clear/); + assert.match(sentMessageText(host), /done/); + } finally { + restoreBridge(); + restoreEnv({ oldCommand, oldUsePi, oldProvider, oldModel, oldBase }); + } +}); + +function restoreEnv(values: { oldCommand?: string; oldUsePi?: string; oldProvider?: string; oldModel?: string; oldBase?: string }): void { + setOrDelete("VILLANI_COMMAND", values.oldCommand); + setOrDelete("VILLANI_USE_PI_MODEL", values.oldUsePi); + setOrDelete("VILLANI_PROVIDER", values.oldProvider); + setOrDelete("VILLANI_MODEL", values.oldModel); + setOrDelete("VILLANI_BASE_URL", values.oldBase); +} + +function setOrDelete(name: string, value: string | undefined): void { + if (value === undefined) delete process.env[name]; + else process.env[name] = value; +} + + +function approvalResponseLines(messages: string[], requestId = "approval-1"): string[] { + return messages.filter((message) => message.includes(`approval_response:${requestId}:`)); +} + +function assertOneApprovalResponse(messages: string[], approved: boolean, requestId = "approval-1"): void { + assert.deepEqual(approvalResponseLines(messages, requestId), [`Villani: approval_response:${requestId}:${approved}:count:1`]); +} + +test("approval event is confirmed and answered", async () => { + const oldCommand = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const oldProvider = process.env.VILLANI_PROVIDER; + const oldModel = process.env.VILLANI_MODEL; + const oldBase = process.env.VILLANI_BASE_URL; + const oldApproval = process.env.MOCK_APPROVAL; + const restoreBridge = installMockBridgeStarter(await mockNodeBridgeModule(false)); + process.env.VILLANI_COMMAND = "mock-villani"; + process.env.VILLANI_USE_PI_MODEL = "false"; + process.env.VILLANI_PROVIDER = "openai"; + process.env.VILLANI_MODEL = "fake"; + process.env.VILLANI_BASE_URL = "http://127.0.0.1:9"; + process.env.MOCK_APPROVAL = "1"; + try { + const host = createHost(); + const messages: string[] = []; + villaniPiExtension(host.api); + await host.commands.get("villani")!("fix it", createContext(messages, { confirmResult: true })); + const joined = messages.join("\n"); + assert.match(joined, /status:Villani: awaiting approval for Write/); + assert.match(joined, /widget:Villani is awaiting approval\|Write file: safe-test.txt:placement:aboveEditor/); + assert.match(joined, /confirm:Villani wants to write a file/); + assert.match(joined, /File: safe-test.txt/); + assert.doesNotMatch(joined, /Approved Villani Write request/); + assertOneApprovalResponse(messages, true); + assert.match(sentMessageText(host), /Villani completed/); + } finally { + restoreBridge(); + restoreEnv({ oldCommand, oldUsePi, oldProvider, oldModel, oldBase }); + setOrDelete("MOCK_APPROVAL", oldApproval); + } +}); + +test("approval confirmation denial sends one denial response", async () => { + const oldCommand = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const oldProvider = process.env.VILLANI_PROVIDER; + const oldModel = process.env.VILLANI_MODEL; + const oldBase = process.env.VILLANI_BASE_URL; + const oldApproval = process.env.MOCK_APPROVAL; + const restoreBridge = installMockBridgeStarter(await mockNodeBridgeModule(false)); + process.env.VILLANI_COMMAND = "mock-villani"; + process.env.VILLANI_USE_PI_MODEL = "false"; + process.env.VILLANI_PROVIDER = "openai"; + process.env.VILLANI_MODEL = "fake"; + process.env.VILLANI_BASE_URL = "http://127.0.0.1:9"; + process.env.MOCK_APPROVAL = "1"; + try { + const host = createHost(); + const messages: string[] = []; + villaniPiExtension(host.api); + await host.commands.get("villani")!("fix it", createContext(messages, { confirmResult: false })); + assert.match(messages.join("\n"), /confirm:Villani wants to write a file/); + assert.match(messages.join("\n"), /Denied Villani Write request/); + assert.match(messages.join("\n"), /status:Villani: denied Write approval/); + assert.match(messages.join("\n"), /widget:Villani approval denied\|Write file: safe-test.txt:placement:aboveEditor/); + assertOneApprovalResponse(messages, false); + } finally { + restoreBridge(); + restoreEnv({ oldCommand, oldUsePi, oldProvider, oldModel, oldBase }); + setOrDelete("MOCK_APPROVAL", oldApproval); + } +}); + +test("approval status rendering failure sends one denial response", async () => { + const oldCommand = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const oldProvider = process.env.VILLANI_PROVIDER; + const oldModel = process.env.VILLANI_MODEL; + const oldBase = process.env.VILLANI_BASE_URL; + const oldApproval = process.env.MOCK_APPROVAL; + const restoreBridge = installMockBridgeStarter(await mockNodeBridgeModule(false)); + process.env.VILLANI_COMMAND = "mock-villani"; + process.env.VILLANI_USE_PI_MODEL = "false"; + process.env.VILLANI_PROVIDER = "openai"; + process.env.VILLANI_MODEL = "fake"; + process.env.VILLANI_BASE_URL = "http://127.0.0.1:9"; + process.env.MOCK_APPROVAL = "1"; + try { + const host = createHost(); + const messages: string[] = []; + villaniPiExtension(host.api); + await host.commands.get("villani")!("fix it", createContext(messages, { throwStatusMatching: /awaiting approval/, confirmError: new Error("confirmation should not open after status failure") })); + assert.match(messages.join("\n"), /Approval UI failed; denied Write request. status failed/); + assertOneApprovalResponse(messages, false); + } finally { + restoreBridge(); + restoreEnv({ oldCommand, oldUsePi, oldProvider, oldModel, oldBase }); + setOrDelete("MOCK_APPROVAL", oldApproval); + } +}); + +test("approval widget rendering failure sends one denial response", async () => { + const oldCommand = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const oldProvider = process.env.VILLANI_PROVIDER; + const oldModel = process.env.VILLANI_MODEL; + const oldBase = process.env.VILLANI_BASE_URL; + const oldApproval = process.env.MOCK_APPROVAL; + const restoreBridge = installMockBridgeStarter(await mockNodeBridgeModule(false)); + process.env.VILLANI_COMMAND = "mock-villani"; + process.env.VILLANI_USE_PI_MODEL = "false"; + process.env.VILLANI_PROVIDER = "openai"; + process.env.VILLANI_MODEL = "fake"; + process.env.VILLANI_BASE_URL = "http://127.0.0.1:9"; + process.env.MOCK_APPROVAL = "1"; + try { + const host = createHost(); + const messages: string[] = []; + villaniPiExtension(host.api); + await host.commands.get("villani")!("fix it", createContext(messages, { throwWidgetMatching: /awaiting approval/, confirmError: new Error("confirmation should not open after widget failure") })); + assert.match(messages.join("\n"), /Approval UI failed; denied Write request. widget failed/); + assertOneApprovalResponse(messages, false); + } finally { + restoreBridge(); + restoreEnv({ oldCommand, oldUsePi, oldProvider, oldModel, oldBase }); + setOrDelete("MOCK_APPROVAL", oldApproval); + } +}); + +test("approval confirmation failure sends one denial response", async () => { + const oldCommand = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const oldProvider = process.env.VILLANI_PROVIDER; + const oldModel = process.env.VILLANI_MODEL; + const oldBase = process.env.VILLANI_BASE_URL; + const oldApproval = process.env.MOCK_APPROVAL; + const restoreBridge = installMockBridgeStarter(await mockNodeBridgeModule(false)); + process.env.VILLANI_COMMAND = "mock-villani"; + process.env.VILLANI_USE_PI_MODEL = "false"; + process.env.VILLANI_PROVIDER = "openai"; + process.env.VILLANI_MODEL = "fake"; + process.env.VILLANI_BASE_URL = "http://127.0.0.1:9"; + process.env.MOCK_APPROVAL = "1"; + try { + const host = createHost(); + const messages: string[] = []; + villaniPiExtension(host.api); + await host.commands.get("villani")!("fix it", createContext(messages, { confirmError: new Error("dialog missing") })); + assert.match(messages.join("\n"), /confirm:Villani wants to write a file/); + assert.match(messages.join("\n"), /Approval UI failed; denied Write request. dialog missing/); + assert.match(messages.join("\n"), /Denied Villani Write request/); + assertOneApprovalResponse(messages, false); + } finally { + restoreBridge(); + restoreEnv({ oldCommand, oldUsePi, oldProvider, oldModel, oldBase }); + setOrDelete("MOCK_APPROVAL", oldApproval); + } +}); + +test("approval prompt content includes path or command", async () => { + const oldCommand = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const oldProvider = process.env.VILLANI_PROVIDER; + const oldModel = process.env.VILLANI_MODEL; + const oldBase = process.env.VILLANI_BASE_URL; + const oldApproval = process.env.MOCK_APPROVAL; + const restoreBridge = installMockBridgeStarter(await mockNodeBridgeModule(false)); + process.env.VILLANI_COMMAND = "mock-villani"; + process.env.VILLANI_USE_PI_MODEL = "false"; + process.env.VILLANI_PROVIDER = "openai"; + process.env.VILLANI_MODEL = "fake"; + process.env.VILLANI_BASE_URL = "http://127.0.0.1:9"; + process.env.MOCK_APPROVAL = "BASH"; + try { + const host = createHost(); + const messages: string[] = []; + villaniPiExtension(host.api); + await host.commands.get("villani")!("fix it", createContext(messages)); + assert.match(messages.join("\n"), /confirm:Villani wants to run a shell command/); + assert.match(messages.join("\n"), /pip install package-name/); + assert.doesNotMatch(messages.join("\n"), /pi-secret|pi-token/); + } finally { + restoreBridge(); + restoreEnv({ oldCommand, oldUsePi, oldProvider, oldModel, oldBase }); + setOrDelete("MOCK_APPROVAL", oldApproval); + } +}); + +test("abort during pending approval sends denial and abort", async () => { + const oldCommand = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const oldProvider = process.env.VILLANI_PROVIDER; + const oldModel = process.env.VILLANI_MODEL; + const oldBase = process.env.VILLANI_BASE_URL; + const oldApproval = process.env.MOCK_APPROVAL; + let unblock!: (value: boolean) => void; + const restorePrompt = __setApprovalPrompterForTests(async () => new Promise((resolve) => { unblock = resolve; })); + const restoreBridge = installMockBridgeStarter(await mockNodeBridgeModule(false)); + process.env.VILLANI_COMMAND = "mock-villani"; + process.env.VILLANI_USE_PI_MODEL = "false"; + process.env.VILLANI_PROVIDER = "openai"; + process.env.VILLANI_MODEL = "fake"; + process.env.VILLANI_BASE_URL = "http://127.0.0.1:9"; + process.env.MOCK_APPROVAL = "1"; + try { + const host = createHost(); + const messages: string[] = []; + villaniPiExtension(host.api); + const ctx = createContext(messages); + const runPromise = host.commands.get("villani")!("fix it", ctx); + await waitForCondition(() => typeof unblock === "function"); + await host.commands.get("villani-abort")!("", ctx); + unblock(true); + await runPromise; + const responses = approvalResponseLines(messages); + assert.ok(responses.length <= 1, `expected at most one approval response, got ${responses.join(", ")}`); + if (responses.length === 1) assert.deepEqual(responses, ["Villani: approval_response:approval-1:false:count:1"]); + assert.match(messages.join("\n"), /status:clear/); + assert.doesNotMatch(messages.join("\n"), /Approved Villani Write request/); + } finally { + restorePrompt(); + restoreBridge(); + restoreEnv({ oldCommand, oldUsePi, oldProvider, oldModel, oldBase }); + setOrDelete("MOCK_APPROVAL", oldApproval); + } +}); + +test("multiple sequential approvals keep request ids distinct", async () => { + const oldCommand = process.env.VILLANI_COMMAND; + const oldUsePi = process.env.VILLANI_USE_PI_MODEL; + const oldProvider = process.env.VILLANI_PROVIDER; + const oldModel = process.env.VILLANI_MODEL; + const oldBase = process.env.VILLANI_BASE_URL; + const oldApproval = process.env.MOCK_APPROVAL; + const seen: string[] = []; + const restorePrompt = __setApprovalPrompterForTests(async (request) => { + seen.push(request.request_id); + return request.request_id === "approval-1"; + }); + const restoreBridge = installMockBridgeStarter(await mockNodeBridgeModule(false)); + process.env.VILLANI_COMMAND = "mock-villani"; + process.env.VILLANI_USE_PI_MODEL = "false"; + process.env.VILLANI_PROVIDER = "openai"; + process.env.VILLANI_MODEL = "fake"; + process.env.VILLANI_BASE_URL = "http://127.0.0.1:9"; + process.env.MOCK_APPROVAL = "2"; + try { + const host = createHost(); + const messages: string[] = []; + villaniPiExtension(host.api); + await host.commands.get("villani")!("fix it", createContext(messages)); + assert.deepEqual(seen, ["approval-1", "approval-2"]); + assert.doesNotMatch(messages.join("\n"), /Approved Villani Write request/); + assert.match(messages.join("\n"), /Denied Villani Patch request/); + assertOneApprovalResponse(messages, true, "approval-1"); + assertOneApprovalResponse(messages, false, "approval-2"); + } finally { + restorePrompt(); + restoreBridge(); + restoreEnv({ oldCommand, oldUsePi, oldProvider, oldModel, oldBase }); + setOrDelete("MOCK_APPROVAL", oldApproval); + } +}); diff --git a/integrations/pi-villani/src/index.ts b/integrations/pi-villani/src/index.ts new file mode 100644 index 00000000..a302fe4e --- /dev/null +++ b/integrations/pi-villani/src/index.ts @@ -0,0 +1,525 @@ +import { randomUUID } from "node:crypto"; +import type { ExtensionAPI, ExtensionCommandContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent"; +import type { Model } from "@earendil-works/pi-ai"; +import { BridgeEvent, RunCommand, VillaniMode } from "./protocol.js"; +import { startVillaniBridgeProcess, VillaniBridgeProcess } from "./process.js"; +import { resolveVillaniExecutable } from "./runtime.js"; +import { PiModelProxy } from "./modelProxy.js"; +import { PiLikeOutput, renderEvent } from "./render.js"; + +type ActiveRunPhase = "starting" | "running" | "aborting" | "completed"; + +const VILLANI_UI_KEY = "villani"; + +interface ActiveVillaniRun { + id: string; + repo: string; + phase: ActiveRunPhase; + abortController: AbortController; + bridge?: VillaniBridgeProcess; + proxy?: PiModelProxy; + pendingApprovals: Set; + done: Promise; + resolveDone: () => void; +} + +export type ApprovalPrompter = (request: Extract, ctx: ExtensionCommandContext, signal: AbortSignal) => Promise; + +type BridgeStarter = typeof startVillaniBridgeProcess; + +let approvalPrompter: ApprovalPrompter = askUserForApproval; +let bridgeStarter: BridgeStarter = startVillaniBridgeProcess; + +let activeRun: ActiveVillaniRun | undefined; + +export default function villaniPiExtension(pi: ExtensionAPI): void { + pi.registerCommand("villani", { + description: "Delegate a repository coding task to Villani Code", + handler: async (args: string, ctx: ExtensionCommandContext) => runVillaniCommand(args, ctx, pi), + }); + pi.registerCommand("villani-abort", { + description: "Abort the active Villani Code run", + handler: async (_args: string, ctx: ExtensionCommandContext) => abortVillaniRun(ctx), + }); + pi.registerCommand("villani-confirm-test", { + description: "Development smoke test for the Pi confirmation UI", + handler: async (_args: string, ctx: ExtensionCommandContext) => runConfirmSmokeTest(ctx), + }); +} + +export async function runVillaniCommand(args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Promise { + const task = args.trim(); + const output = uiOutput(ctx.ui); + if (!task) { + output.warn?.("Usage: /villani "); + return; + } + if (activeRun) { + output.warn?.(`Villani is already running in ${activeRun.repo}. Use /villani-abort to stop it before starting another run.`); + return; + } + + const repo = ctx.cwd || process.cwd(); + const runId = randomUUID(); + const abortController = new AbortController(); + let resolveDone!: () => void; + const done = new Promise((resolve) => { resolveDone = resolve; }); + const run: ActiveVillaniRun = { id: runId, repo, phase: "starting", abortController, pendingApprovals: new Set(), done, resolveDone }; + activeRun = run; + debug(output, `run starting id=${runId} repo=${repo} task_chars=${task.length}`); + setVillaniStatus(ctx.ui, "Villani: starting"); + + let finished = false; + let heartbeat: ReturnType | undefined; + let heartbeatSequence = 0; + let resolveFinal!: () => void; + const finalEvent = new Promise((resolve) => { resolveFinal = resolve; }); + ctx.signal?.addEventListener("abort", () => { + void abortActiveRun("Pi command cancellation requested"); + }, { once: true }); + + try { + const explicitConfig = useExplicitVillaniConfig(); + const model = ctx.model as Model | undefined; + debug(output, explicitConfig + ? `model configuration mode=direct provider=${process.env.VILLANI_PROVIDER ?? ""} model=${process.env.VILLANI_MODEL ?? ""} base_url=${redactUrl(process.env.VILLANI_BASE_URL) ?? ""}` + : `model configuration mode=pi-proxy model=${model?.id ?? ""}`); + if (!explicitConfig && !model) { + throw new Error("Villani could not start: no active Pi model is selected. Select a model in Pi, or set VILLANI_USE_PI_MODEL=false and configure Villani explicitly."); + } + if (abortController.signal.aborted) throw new Error("Villani run cancelled during startup."); + + const auth = !explicitConfig && model ? await resolveModelAuth(ctx, model) : undefined; + if (abortController.signal.aborted) throw new Error("Villani run cancelled during startup."); + + setVillaniStatus(ctx.ui, "Villani: starting model proxy"); + run.proxy = !explicitConfig && model ? new PiModelProxy({ model, apiKey: auth?.apiKey, headers: auth?.headers, signal: abortController.signal }) : undefined; + const proxyUrl = run.proxy ? await run.proxy.start() : undefined; + if (proxyUrl) debug(output, `Pi model proxy listening at ${redactUrl(proxyUrl)}`); + if (abortController.signal.aborted) throw new Error("Villani run cancelled during startup."); + + setVillaniStatus(ctx.ui, "Villani: starting runtime"); + const executable = await resolveVillaniExecutable({ + overrideCommand: process.env.VILLANI_COMMAND, + signal: abortController.signal, + onProgress: (message) => output.info?.(message), + }); + if (abortController.signal.aborted) throw new Error("Villani run cancelled during runtime setup."); + reportRuntimeSource(executable, output); + + debug(output, `runtime executable resolved path=${executable.executable}`); + run.bridge = await bridgeStarter({ + command: executable.executable, + cwd: repo, + signal: abortController.signal, + onDiagnostic: (message) => debug(output, `bridge process: ${message}`), + onStderr: (text) => debug(output, `bridge stderr: ${trimDiagnostic(text)}`), + }); + run.phase = "running"; + setVillaniStatus(ctx.ui, "Villani: running"); + run.bridge.onEvent((event: BridgeEvent) => { + if (event.type !== "pong") { + debug(output, `bridge event ${summarizeBridgeEvent(event)}`); + } + if (abortController.signal.aborted && event.type === "run_completed") return; + if (event.type === "approval_required") { + debug(output, `approval_required emitted id=${event.id} request_id=${event.request_id} tool=${event.tool}`); + void handleApprovalRequired(run, event, ctx, output).catch((error: unknown) => { + safeWarn(output, `Approval handler failed unexpectedly; denied ${event.tool} request. ${formatUnknownError(error)}`); + denyApprovalIfPending(run, event, output); + }); + return; + } + if (event.type === "run_completed" || event.type === "run_failed" || event.type === "run_aborted") { + finished = event.type === "run_completed"; + showFinalRunMessage(pi, event); + clearVillaniUi(ctx.ui); + resolveFinal(); + return; + } + + renderEvent(event, output); + + if (event.type === "error") output.error?.(`Villani bridge error: ${event.error}`); + }); + + const command: RunCommand = { + type: "run", + id: runId, + task, + repo, + mode: (process.env.VILLANI_MODE as VillaniMode | undefined) || "runner", + config: buildRunConfig(proxyUrl, model), + }; + debug(output, `sending run command id=${runId} mode=${command.mode}`); + run.bridge.send(command); + + heartbeat = setInterval(() => { + if (!run.bridge || run.phase !== "running" || abortController.signal.aborted) return; + heartbeatSequence += 1; + try { + run.bridge.send({ type: "ping", id: `${runId}-heartbeat-${heartbeatSequence}` }); + } catch { + // Normal during shutdown or bridge exit. + } + }, 250); + + await Promise.race([ + finalEvent, + run.bridge.waitForExit().then((code) => { + if (!finished && !abortController.signal.aborted) throw new Error(`Villani bridge exited before a final event with code ${code}. ${run.bridge?.stderr() ?? ""}`.trim()); + }), + ]); + } catch (error) { + if (abortController.signal.aborted) { + output.warn?.(!run.bridge ? "Villani run cancelled during startup." : "Villani run cancelled."); + showDurableMessage(ctx.ui, !run.bridge ? "Villani run cancelled during startup." : "Villani run cancelled."); + } else { + const message = error instanceof Error ? error.message : String(error); + debug(output, `exception: ${sanitizeErrorMessage(message)}`); + output.error?.(message); + showDurableMessage(ctx.ui, `Villani failed: ${message}`); + } + } finally { + if (heartbeat) clearInterval(heartbeat); + clearVillaniUi(ctx.ui); + run.phase = "completed"; + if (!finished && run.bridge && !abortController.signal.aborted) { + try { run.bridge.abort(runId); } catch { /* ignore cleanup races */ } + } + run.bridge?.kill(); + await run.proxy?.stop(); + if (activeRun?.id === runId) activeRun = undefined; + resolveDone(); + } +} + +export async function runConfirmSmokeTest(ctx: ExtensionCommandContext): Promise { + const output = uiOutput(ctx.ui); + try { + if (!ctx.hasUI || typeof ctx.ui.confirm !== "function") { + output.warn?.("Villani confirm smoke test: ctx.ui.confirm is not available."); + return; + } + const approved = await ctx.ui.confirm("Villani confirmation smoke test", "Approve this test dialog? No Villani runner will be started.", { signal: ctx.signal }); + output.info?.(`Villani confirm smoke test: ${approved ? "approved" : "denied"}.`); + } catch (error) { + output.error?.(`Villani confirm smoke test failed: ${formatUnknownError(error)}`); + } +} + +export async function abortVillaniRun(ctx: ExtensionCommandContext): Promise { + const output = uiOutput(ctx.ui); + if (!activeRun) { + output.info?.("No active Villani run to abort."); + return; + } + output.warn?.(activeRun.phase === "starting" ? "Aborting Villani run during startup…" : "Aborting active Villani run…"); + setVillaniStatus(ctx.ui, "Villani: aborting"); + await abortActiveRun("Aborted by /villani-abort"); +} + +async function abortActiveRun(_reason: string): Promise { + const run = activeRun; + if (!run) return; + run.phase = "aborting"; + run.abortController.abort(); + for (const requestId of Array.from(run.pendingApprovals)) { + try { run.bridge?.respondToApproval(run.id, requestId, false); } catch { /* bridge may already be closed */ } + run.pendingApprovals.delete(requestId); + } + try { + run.bridge?.abort(run.id); + } catch { + run.bridge?.kill(); + } + await Promise.race([ + run.done, + new Promise((resolve) => setTimeout(resolve, 5_000)).then(() => run.bridge?.kill()), + ]); +} + +async function handleApprovalRequired( + run: ActiveVillaniRun, + event: Extract, + ctx: ExtensionCommandContext, + output: PiLikeOutput, +): Promise { + run.pendingApprovals.add(event.request_id); + let approved = false; + + try { + setVillaniStatus(ctx.ui, `Villani: awaiting approval for ${event.tool}`); + setVillaniWidget(ctx.ui, ["Villani is awaiting approval", event.summary]); + approved = !run.abortController.signal.aborted && await approvalPrompter(event, ctx, run.abortController.signal); + } catch (error) { + approved = false; + safeWarn(output, `Approval UI failed; denied ${event.tool} request. ${formatUnknownError(error)}`); + } + + if (run.abortController.signal.aborted || activeRun?.id !== run.id) approved = false; + if (!sendApprovalResponseIfPending(run, event, approved, output)) return; + + if (approved) { + safeSetVillaniStatus(ctx.ui, "Villani: running", output); + safeSetVillaniWidget(ctx.ui, undefined, output); + } else { + safeSetVillaniStatus(ctx.ui, `Villani: denied ${event.tool} approval`, output); + safeSetVillaniWidget(ctx.ui, ["Villani approval denied", event.summary], output); + } + if (approved) { + debug(output, `approved ${event.tool} request: ${event.summary}`); + } else { + safeWarn(output, `Denied Villani ${event.tool} request: ${event.summary}`); + } +} + +function denyApprovalIfPending( + run: ActiveVillaniRun, + event: Extract, + output: PiLikeOutput, +): void { + sendApprovalResponseIfPending(run, event, false, output); +} + +function sendApprovalResponseIfPending( + run: ActiveVillaniRun, + event: Extract, + approved: boolean, + output: PiLikeOutput, +): boolean { + const stillPending = run.pendingApprovals.delete(event.request_id); + if (!stillPending) return false; + try { + run.bridge?.respondToApproval(run.id, event.request_id, approved); + } catch { + if (!run.abortController.signal.aborted) safeWarn(output, `Could not send approval response for ${event.tool}; bridge is no longer available.`); + } + return true; +} + +function safeSetVillaniStatus(ui: ExtensionUIContext, text: string | undefined, output: PiLikeOutput): void { + try { + setVillaniStatus(ui, text); + } catch (error) { + safeWarn(output, `Could not update Villani status UI. ${formatUnknownError(error)}`); + } +} + +function safeSetVillaniWidget(ui: ExtensionUIContext, lines: string[] | undefined, output: PiLikeOutput): void { + try { + setVillaniWidget(ui, lines); + } catch (error) { + safeWarn(output, `Could not update Villani widget UI. ${formatUnknownError(error)}`); + } +} + +function safeWarn(output: PiLikeOutput, message: string): void { + try { + output.warn?.(message); + } catch { + // Ignore notification failures so approval responses are never blocked by warning UI. + } +} + +function debug(output: PiLikeOutput, message: string): void { + if (process.env.VILLANI_PI_DEBUG !== "1") return; + safeInfo(output, `Villani debug: ${message}`); +} + +function safeInfo(output: PiLikeOutput, message: string): void { + try { + output.info?.(message); + } catch { + // Ignore diagnostics failures; they must not affect runtime startup. + } +} + +function formatUnknownError(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + if (error === undefined || error === null) return ""; + return String(error); +} + +function summarizeBridgeEvent(event: BridgeEvent): string { + const id = "id" in event && typeof event.id === "string" ? event.id : ""; + const runId = "run_id" in event && typeof event.run_id === "string" ? event.run_id : id; + const parts = [`type=${event.type}`, `id=${id}`, `run_id=${runId}`]; + if (event.type === "phase") parts.push(`phase=${event.phase}`); + if (event.type === "tool_started" || event.type === "tool_finished") parts.push(`tool=${event.tool}`); + if (event.type === "approval_required" || event.type === "approval_resolved") parts.push(`request_id=${event.request_id}`, `tool=${event.tool}`); + if (event.type === "error") parts.push(`error=${sanitizeErrorMessage(event.error)}`); + return parts.join(" "); +} + +function trimDiagnostic(text: string): string { + return sanitizeErrorMessage(text.replace(/\s+/g, " ").trim()).slice(0, 500); +} + +function redactUrl(value: string | undefined): string | undefined { + if (!value) return value; + return value.replace(/(api[_-]?key=)[^&]+/gi, "$1[redacted]").replace(/:\/\/[^/@]+@/, "://[redacted]@"); +} + +function reportRuntimeSource(executable: Awaited>, output: PiLikeOutput): void { + const version = executable.version ? ` v${executable.version}` : ""; + if (executable.source === "override") { + safeInfo(output, `Villani runtime: using VILLANI_COMMAND override (${executable.executable}).`); + return; + } + safeInfo(output, `Villani runtime: using ${executable.source}${version} at ${executable.executable}.`); +} + +function setVillaniStatus(ui: ExtensionUIContext, text: string | undefined): void { + const setter = ui.setStatus as ((key: string, text?: string) => void) | undefined; + setter?.(VILLANI_UI_KEY, text); +} + +function setVillaniWidget(ui: ExtensionUIContext, lines: string[] | undefined): void { + const setter = ui.setWidget as ((key: string, lines?: string[], options?: { placement?: "aboveEditor" }) => void) | undefined; + setter?.(VILLANI_UI_KEY, lines, { placement: "aboveEditor" }); +} + +function clearVillaniWidget(ui: ExtensionUIContext): void { + setVillaniWidget(ui, undefined); +} + +function clearVillaniUi(ui: ExtensionUIContext): void { + setVillaniStatus(ui, undefined); + clearVillaniWidget(ui); +} + +function showDurableMessage(ui: ExtensionUIContext, message: string): void { + ui.notify?.(message); +} + +function showFinalRunMessage( + pi: ExtensionAPI, + event: Extract, +): void { + const status = + event.type === "run_completed" + ? "Villani completed" + : event.type === "run_aborted" + ? "Villani aborted" + : "Villani failed"; + + const summary = + event.type === "run_failed" + ? event.error || event.summary || status + : event.summary || status; + + const changedFiles = + "changed_files" in event && Array.isArray(event.changed_files) + ? event.changed_files.filter(isUserFacingChangedFile) + : []; + + const changedFilesSection = + changedFiles.length > 0 + ? `\n\nChanged files:\n${changedFiles.map((file) => `- ${file}`).join("\n")}` + : ""; + + pi.sendMessage({ + customType: "villani-result", + content: `${status}\n\n${summary}${changedFilesSection}`, + display: true, + details: event, + }); +} + +function isUserFacingChangedFile(file: string): boolean { + const normalized = file.replaceAll("\\", "/"); + const segments = normalized.split("/"); + + return ( + normalized !== ".villani" && + !normalized.startsWith(".villani/") && + normalized !== ".villani_code" && + !normalized.startsWith(".villani_code/") && + !segments.includes("__pycache__") && + !normalized.endsWith(".pyc") + ); +} + +async function askUserForApproval(request: Extract, ctx: ExtensionCommandContext, signal: AbortSignal): Promise { + if (!ctx.hasUI || typeof ctx.ui.confirm !== "function") return false; + return ctx.ui.confirm(approvalTitle(request), approvalMessage(request), { signal }); +} + +function approvalTitle(request: Extract): string { + if (request.tool === "Write") return "Villani wants to write a file"; + if (request.tool === "Patch") return "Villani wants to apply a patch"; + if (request.tool === "Bash") return "Villani wants to run a shell command"; + return `Villani wants approval for ${request.tool}`; +} + +function approvalMessage(request: Extract): string { + const path = typeof request.input.path === "string" ? request.input.path : undefined; + const command = typeof request.input.command === "string" ? request.input.command : undefined; + const lines = [request.summary, ""]; + if (path) lines.push(`File: ${path}`, ""); + if (command) lines.push("Command:", command, ""); + lines.push("Allow this operation?"); + return lines.join("\n"); +} + +export function __setApprovalPrompterForTests(prompter: ApprovalPrompter): () => void { + const previous = approvalPrompter; + approvalPrompter = prompter; + return () => { approvalPrompter = previous; }; +} + +export function __setBridgeStarterForTests(starter: BridgeStarter): () => void { + const previous = bridgeStarter; + bridgeStarter = starter; + return () => { bridgeStarter = previous; }; +} + +async function resolveModelAuth(ctx: ExtensionCommandContext, model: Model): Promise<{ apiKey?: string; headers?: Record }> { + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); + if (!auth.ok) throw new Error(`Villani could not resolve Pi model authentication: ${sanitizeErrorMessage(auth.error)}`); + return { apiKey: auth.apiKey, headers: auth.headers }; +} + +function buildRunConfig(proxyUrl: string | undefined, model: Model | undefined): RunCommand["config"] { + if (proxyUrl) { + return { + provider: "openai", + model: model?.id ?? "pi-current-model", + base_url: proxyUrl, + pi_model_proxy: true, + }; + } + 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, + }; +} + +function useExplicitVillaniConfig(): boolean { + return String(process.env.VILLANI_USE_PI_MODEL ?? "").toLowerCase() === "false"; +} + +function uiOutput(ui: ExtensionUIContext): PiLikeOutput { + return { + info: (message: string) => ui.notify(message, "info"), + warn: (message: string) => ui.notify(message, "warning"), + error: (message: string) => ui.notify(message, "error"), + markdown: (message: string) => ui.notify(message, "info"), + }; +} + +function sanitizeErrorMessage(message: string): string { + const apiKey = process.env.VILLANI_API_KEY; + let sanitized = message + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]") + .replace(/api[_-]?key[=:]\s*[^\s,;]+/gi, "api_key=[redacted]"); + if (apiKey) sanitized = sanitized.split(apiKey).join("[redacted]"); + return sanitized.slice(0, 500); +} + +export function __getActiveRunForTests(): ActiveVillaniRun | undefined { + return activeRun; +} diff --git a/integrations/pi-villani/src/modelProxy.test.ts b/integrations/pi-villani/src/modelProxy.test.ts new file mode 100644 index 00000000..619ae5ae --- /dev/null +++ b/integrations/pi-villani/src/modelProxy.test.ts @@ -0,0 +1,294 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { AssistantMessage, Model } from "@earendil-works/pi-ai"; +import { openAIChatToPiContext, PiModelProxy, piAssistantToOpenAIResponse } from "./modelProxy.js"; + +function fakeModel(): Model { + return { + id: "pi-test", + name: "Pi Test", + api: "openai-completions", + provider: "pi", + baseUrl: "pi://current", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 4096, + } as Model; +} + +function successMessage(text = "ok"): AssistantMessage { + return { + role: "assistant", + api: "openai-completions", + provider: "pi", + model: "pi-test", + content: [{ type: "text", text }], + usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0, totalTokens: 3, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "stop", + timestamp: 1700000000000, + }; +} + +test("translates OpenAI chat payloads to Pi context", () => { + const context = openAIChatToPiContext({ + model: "villani-proxy", + messages: [ + { role: "system", content: "system one" }, + { role: "user", content: "hello" }, + { + role: "assistant", + content: "using tool", + tool_calls: [{ id: "call-1", type: "function", function: { name: "Read", arguments: "{\"path\":\"src/foo.py\"}" } }], + }, + { role: "tool", tool_call_id: "call-1", name: "Read", content: "file contents" }, + ], + tools: [{ type: "function", function: { name: "Read", description: "Read file", parameters: { type: "object" } } }], + }); + + assert.equal(context.systemPrompt, "system one"); + assert.equal(context.messages.length, 3); + assert.equal(context.messages[0].role, "user"); + assert.equal(context.messages[1].role, "assistant"); + assert.equal(context.messages[2].role, "toolResult"); + assert.equal(context.tools?.[0].name, "Read"); +}); + +test("translates Pi assistant tool calls to OpenAI response", () => { + const message: AssistantMessage = { + role: "assistant", + api: "openai-completions", + provider: "pi", + model: "m", + content: [ + { type: "text", text: "I'll inspect it." }, + { type: "toolCall", id: "call-2", name: "Read", arguments: { path: "src/foo.py" } }, + ], + usage: { input: 3, output: 4, cacheRead: 0, cacheWrite: 0, totalTokens: 7, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "toolUse", + timestamp: 1700000000000, + }; + + const response = piAssistantToOpenAIResponse(message); + const choice = (response.choices as Array)[0]; + assert.equal(choice.finish_reason, "tool_calls"); + assert.equal(choice.message.content, "I'll inspect it."); + assert.equal(choice.message.tool_calls[0].function.name, "Read"); + assert.equal(choice.message.tool_calls[0].function.arguments, '{"path":"src/foo.py"}'); + assert.deepEqual(response.usage, { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 }); +}); + +test("preserves already-serialized Pi tool arguments without double encoding", () => { + const message: AssistantMessage = { + role: "assistant", + api: "openai-completions", + provider: "pi", + model: "m", + content: [ + { type: "toolCall", id: "call-string", name: "Write", arguments: '{"path":"calculator.py","content":"hello"}' as any }, + ], + usage: { input: 3, output: 4, cacheRead: 0, cacheWrite: 0, totalTokens: 7, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "toolUse", + timestamp: 1700000000000, + }; + + const response = piAssistantToOpenAIResponse(message); + const choice = (response.choices as Array)[0]; + + assert.equal( + choice.message.tool_calls[0].function.arguments, + '{"path":"calculator.py","content":"hello"}', + ); +}); + +test("unwraps double-serialized Pi tool arguments for Villani", () => { + const message: AssistantMessage = { + role: "assistant", + api: "openai-completions", + provider: "pi", + model: "m", + content: [ + { + type: "toolCall", + id: "call-double-string", + name: "Write", + arguments: '"{\\"path\\":\\"calculator.py\\",\\"content\\":\\"hello\\"}"' as any, + }, + ], + usage: { input: 3, output: 4, cacheRead: 0, cacheWrite: 0, totalTokens: 7, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + stopReason: "toolUse", + timestamp: 1700000000000, + }; + + const response = piAssistantToOpenAIResponse(message); + const choice = (response.choices as Array)[0]; + + assert.equal( + choice.message.tool_calls[0].function.arguments, + '{"path":"calculator.py","content":"hello"}', + ); +}); + +test("proxy passes Pi auth options into completion call", async () => { + let seenOptions: any; + const proxy = new PiModelProxy({ + model: fakeModel(), + apiKey: "secret-api-key", + headers: { Authorization: "Bearer secret-token", "x-provider": "pi" }, + completeFn: async (_model, _context, options) => { + seenOptions = options; + return successMessage("auth ok"); + }, + }); + const url = await proxy.start(); + try { + const response = await fetch(`${url}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "pi-test", messages: [{ role: "user", content: "hello" }] }), + }); + assert.equal(response.status, 200); + assert.equal(seenOptions.apiKey, "secret-api-key"); + assert.deepEqual(seenOptions.headers, { Authorization: "Bearer secret-token", "x-provider": "pi" }); + } finally { + await proxy.stop(); + } +}); + +test("proxy serves minimal OpenAI chat completions", async () => { + const proxy = new PiModelProxy({ + model: fakeModel(), + completeFn: async (_model, context) => successMessage(`saw ${context.messages.length} message`), + }); + const url = await proxy.start(); + try { + assert.match(url, /^http:\/\/127\.0\.0\.1:\d+$/); + const response = await fetch(`${url}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "pi-test", messages: [{ role: "user", content: "hello" }] }), + }); + assert.equal(response.status, 200); + const body = await response.json() as any; + assert.equal(body.choices[0].message.content, "saw 1 message"); + } finally { + await proxy.stop(); + } +}); + +test("proxy returns HTTP error for Pi assistant stopReason error", async () => { + const proxy = new PiModelProxy({ + model: fakeModel(), + completeFn: async () => ({ ...successMessage(""), content: [], stopReason: "error", errorMessage: "OpenAI API key is required. Bearer secret-token" }), + }); + const url = await proxy.start(); + try { + const response = await fetch(`${url}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "pi-test", messages: [{ role: "user", content: "hello" }] }), + }); + assert.equal(response.status, 502); + const body = await response.json() as any; + assert.match(body.error.message, /OpenAI API key is required/); + assert.doesNotMatch(body.error.message, /secret-token/); + assert.equal(body.error.type, "upstream_error"); + assert.equal(body.choices, undefined); + } finally { + await proxy.stop(); + } +}); + +test("proxy returns sanitized HTTP error for thrown provider failure", async () => { + const proxy = new PiModelProxy({ + model: fakeModel(), + completeFn: async () => { throw new Error("provider failed api_key=super-secret"); }, + }); + const url = await proxy.start(); + try { + const response = await fetch(`${url}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "pi-test", messages: [{ role: "user", content: "hello" }] }), + }); + assert.equal(response.status, 502); + const body = await response.json() as any; + assert.match(body.error.message, /provider failed/); + assert.doesNotMatch(body.error.message, /super-secret/); + } finally { + await proxy.stop(); + } +}); + +test("streaming failure does not emit normal completion", async () => { + const proxy = new PiModelProxy({ + model: fakeModel(), + completeFn: async () => ({ ...successMessage(""), content: [], stopReason: "error", errorMessage: "upstream down" }), + }); + const url = await proxy.start(); + try { + const response = await fetch(`${url}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "pi-test", stream: true, messages: [{ role: "user", content: "hello" }] }), + }); + assert.equal(response.status, 502); + const body = await response.text(); + assert.doesNotMatch(body, /\[DONE\]/); + assert.match(body, /upstream down/); + } finally { + await proxy.stop(); + } +}); + +test("proxy aborts in-flight Pi completion via signal", async () => { + const controller = new AbortController(); + let completeSignal: AbortSignal | undefined; + const proxy = new PiModelProxy({ + model: fakeModel(), + signal: controller.signal, + completeFn: async (_model, _context, options) => { + completeSignal = options?.signal; + await new Promise((resolve) => options?.signal?.addEventListener("abort", () => resolve(), { once: true })); + return { ...successMessage(""), content: [], stopReason: "aborted" }; + }, + }); + const url = await proxy.start(); + try { + const pending = fetch(`${url}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "pi-test", messages: [{ role: "user", content: "hello" }] }), + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + controller.abort(); + const response = await pending; + assert.equal(completeSignal?.aborted, true); + assert.equal(response.status, 499); + } finally { + await proxy.stop(); + } +}); + +test("proxy serves Villani-compatible streaming chat completions", async () => { + const proxy = new PiModelProxy({ + model: fakeModel(), + completeFn: async () => successMessage("streamed final"), + }); + const url = await proxy.start(); + try { + const response = await fetch(`${url}/v1/chat/completions`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "pi-test", stream: true, messages: [{ role: "user", content: "hello" }] }), + }); + assert.equal(response.headers.get("content-type")?.startsWith("text/event-stream"), true); + const body = await response.text(); + assert.match(body, /data: /); + assert.match(body, /streamed final/); + assert.match(body, /\[DONE\]/); + } finally { + await proxy.stop(); + } +}); diff --git a/integrations/pi-villani/src/modelProxy.ts b/integrations/pi-villani/src/modelProxy.ts new file mode 100644 index 00000000..c19ed1e2 --- /dev/null +++ b/integrations/pi-villani/src/modelProxy.ts @@ -0,0 +1,335 @@ +import { createServer, IncomingMessage, Server, ServerResponse } from "node:http"; +import { AddressInfo } from "node:net"; +import { complete } from "@earendil-works/pi-ai"; +import type { AssistantMessage, Context, Message, Model, ProviderStreamOptions, Tool, Usage } from "@earendil-works/pi-ai"; + +export interface OpenAIMessage { + role: "system" | "user" | "assistant" | "tool"; + content?: string | null; + tool_call_id?: string; + name?: string; + tool_calls?: Array<{ + id?: string; + type?: "function"; + function?: { name?: string; arguments?: string }; + }>; +} + +export interface OpenAIChatCompletionRequest { + model?: string; + messages?: OpenAIMessage[]; + tools?: Array<{ + type?: "function"; + function?: { name?: string; description?: string; parameters?: Record }; + }>; + max_tokens?: number; + temperature?: number; + stream?: boolean; +} + +export type PiCompleteFunction = (model: Model, context: Context, options?: ProviderStreamOptions) => Promise; + +export interface PiModelProxyOptions { + model: Model; + apiKey?: string; + headers?: Record; + signal?: AbortSignal; + timeoutMs?: number; + completeFn?: PiCompleteFunction; +} + +export class PiModelProxy { + private server?: Server; + private url?: string; + + constructor(private readonly options: PiModelProxyOptions) {} + + async start(): Promise { + if (this.url) return this.url; + if (this.options.signal?.aborted) throw new Error("Pi model proxy startup aborted"); + this.server = createServer((req, res) => { + void this.handle(req, res); + }); + await new Promise((resolve, reject) => { + const abort = () => { + this.server?.close(); + reject(abortError()); + }; + this.options.signal?.addEventListener("abort", abort, { once: true }); + this.server?.once("error", reject); + this.server?.listen(0, "127.0.0.1", () => { + this.options.signal?.removeEventListener("abort", abort); + resolve(); + }); + }); + const address = this.server.address() as AddressInfo; + this.url = `http://127.0.0.1:${address.port}`; + return this.url; + } + + async stop(): Promise { + const server = this.server; + this.server = undefined; + this.url = undefined; + if (!server) return; + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + try { + if (req.method !== "POST" || !req.url?.startsWith("/v1/chat/completions")) { + writeJson(res, 404, { error: { message: "Pi Villani proxy only supports POST /v1/chat/completions", type: "not_found" } }); + return; + } + if (this.options.signal?.aborted) throw abortError(); + const payload = JSON.parse(await readBody(req)) as OpenAIChatCompletionRequest; + const context = openAIChatToPiContext(payload); + const assistant = await this.complete(context, payload); + if (this.options.signal?.aborted || assistant.stopReason === "aborted") throw abortError(); + if (assistant.stopReason === "error") { + throw new UpstreamModelError(assistant.errorMessage ?? "Pi model request failed"); + } + if (payload.stream) { + writeOpenAIStream(res, assistant); + } else { + writeJson(res, 200, piAssistantToOpenAIResponse(assistant)); + } + } catch (error) { + writeProxyError(res, error); + } + } + + private complete(context: Context, payload: OpenAIChatCompletionRequest): Promise { + const completeFn = this.options.completeFn ?? complete; + return completeFn(this.options.model, context, { + apiKey: this.options.apiKey, + headers: this.options.headers, + maxTokens: payload.max_tokens, + temperature: payload.temperature, + signal: this.options.signal, + timeoutMs: this.options.timeoutMs, + }); + } +} + +class UpstreamModelError extends Error { + readonly status = 502; + readonly type = "upstream_error"; +} + +class AbortRequestError extends Error { + readonly status = 499; + readonly type = "aborted"; +} + +export function openAIChatToPiContext(request: OpenAIChatCompletionRequest): Context { + const messages = request.messages ?? []; + const systemPrompt = messages + .filter((message) => message.role === "system" && message.content) + .map((message) => String(message.content)) + .join("\n\n") || undefined; + const converted: Message[] = []; + for (const message of messages) { + if (message.role === "system") continue; + const timestamp = Date.now(); + if (message.role === "user") { + converted.push({ role: "user", content: String(message.content ?? ""), timestamp }); + continue; + } + if (message.role === "tool") { + converted.push({ + role: "toolResult", + toolCallId: String(message.tool_call_id ?? ""), + toolName: String(message.name ?? ""), + content: [{ type: "text", text: String(message.content ?? "") }], + isError: false, + timestamp, + }); + continue; + } + if (message.role === "assistant") { + const content: AssistantMessage["content"] = []; + if (message.content) content.push({ type: "text", text: String(message.content) }); + for (const toolCall of message.tool_calls ?? []) { + const fn = toolCall.function ?? {}; + content.push({ + type: "toolCall", + id: String(toolCall.id ?? `tool-${content.length}`), + name: String(fn.name ?? ""), + arguments: parseArguments(fn.arguments), + }); + } + converted.push({ + role: "assistant", + content, + api: "openai-completions", + provider: "pi", + model: String(request.model ?? "pi"), + usage: zeroUsage(), + stopReason: content.some((block) => block.type === "toolCall") ? "toolUse" : "stop", + timestamp, + }); + } + } + const tools: Tool[] = (request.tools ?? []) + .filter((tool) => tool.type === "function" && tool.function?.name) + .map((tool) => ({ + name: String(tool.function?.name ?? ""), + description: String(tool.function?.description ?? ""), + parameters: (tool.function?.parameters ?? { type: "object", properties: {} }) as Tool["parameters"], + })); + return { systemPrompt, messages: converted, ...(tools.length ? { tools } : {}) }; +} + +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 text = message.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join("\n\n"); + const toolCalls = message.content + .filter((block) => block.type === "toolCall") + .map((block, index) => ({ + id: block.id || `call_${index}`, + type: "function", + function: { name: block.name, arguments: serializeToolArguments(block.arguments) }, + })); + return { + 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), + }; +} + +function writeOpenAIStream(res: ServerResponse, message: AssistantMessage): void { + const response = piAssistantToOpenAIResponse(message); + const choice = (response.choices as Array>)[0]; + const fullMessage = choice.message as Record; + 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(); +} + +function serializeToolArguments(value: unknown): string { + let current = value; + + // Pi providers may return: + // { path: "file.py" } + // '{"path":"file.py"}' + // '"{\\"path\\":\\"file.py\\"}"' + // Decode up to two string layers, then emit one valid OpenAI arguments JSON object. + for (let depth = 0; depth < 2 && typeof current === "string"; depth += 1) { + try { + current = JSON.parse(current); + } catch { + return JSON.stringify({}); + } + } + + if (current && typeof current === "object" && !Array.isArray(current)) { + return JSON.stringify(current); + } + + return JSON.stringify({}); +} + +function toOpenAIFinishReason(reason: AssistantMessage["stopReason"]): string { + if (reason === "toolUse") return "tool_calls"; + if (reason === "length") return "length"; + return "stop"; +} + +function toOpenAIUsage(usage: Usage): Record { + return { + prompt_tokens: usage.input, + completion_tokens: usage.output, + total_tokens: usage.totalTokens || usage.input + usage.output, + }; +} + +function zeroUsage(): Usage { + return { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }; +} + +function parseArguments(value: unknown): Record { + if (typeof value !== "string") return {}; + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : {}; + } catch { + return {}; + } +} + +async function readBody(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +} + +function writeJson(res: ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { "content-type": "application/json; charset=utf-8" }); + res.end(JSON.stringify(body)); +} + +function writeProxyError(res: ServerResponse, error: unknown): void { + const err = error as { status?: number; type?: string; message?: string; name?: string }; + const aborted = err.type === "aborted" || err.name === "AbortError"; + const status = aborted ? 499 : err.status ?? 502; + const type = aborted ? "aborted" : err.type ?? "upstream_error"; + const prefix = aborted ? "Pi model request was aborted" : "Villani model request failed through Pi"; + writeJson(res, status, { + error: { + message: `${prefix}: ${sanitizeErrorMessage(err.message ?? String(error))}`, + type, + }, + }); +} + +function abortError(): AbortRequestError { + return new AbortRequestError("request aborted"); +} + +function sanitizeErrorMessage(message: string): string { + return message + .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]") + .replace(/api[_-]?key[=:]\s*[^\s,;]+/gi, "api_key=[redacted]") + .slice(0, 500); +} diff --git a/integrations/pi-villani/src/process.test.ts b/integrations/pi-villani/src/process.test.ts new file mode 100644 index 00000000..23abfc6c --- /dev/null +++ b/integrations/pi-villani/src/process.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { commandToSpec, DEFAULT_VILLANI_COMMAND, VillaniBridgeProcess } from "./process.js"; + +async function mockBridgeSpec(script: string) { + const dir = await mkdtemp(join(tmpdir(), "villani-bridge-")); + const modulePath = join(dir, "bridge.mjs"); + await writeFile(modulePath, script, "utf8"); + return { + executable: process.execPath, + args: [modulePath], + display: process.execPath, + }; +} + +test("default command is villani-code", () => { + assert.equal(DEFAULT_VILLANI_COMMAND, "villani-code"); + assert.deepEqual(commandToSpec(""), { executable: "villani-code", args: [], display: "villani-code" }); +}); + +test("VILLANI_COMMAND path with spaces is treated as one executable", () => { + const command = "C:\\Program Files\\Python\\Scripts\\villani-code.exe"; + assert.deepEqual(commandToSpec(command), { executable: command, args: [], display: command }); +}); + +test("reports missing executable without unhandled process error", async () => { + const bridge = new VillaniBridgeProcess({ command: "definitely-not-real-villani-command", cwd: process.cwd(), readyTimeoutMs: 500 }); + await assert.rejects(bridge.waitUntilReady(), /executable was not found/); +}); + +test("rejects when bridge exits before ready", async () => { + const spec = await mockBridgeSpec("process.stderr.write('bad startup'); process.exit(7);\n"); + const bridge = new VillaniBridgeProcess({ spec, cwd: process.cwd(), readyTimeoutMs: 1000 }); + await assert.rejects(bridge.waitUntilReady(), /exited before ready.*bad startup/); +}); + +test("rejects malformed bridge output", async () => { + const spec = await mockBridgeSpec("process.stdout.write('not json\\n'); setTimeout(() => {}, 1000);\n"); + const bridge = new VillaniBridgeProcess({ spec, cwd: process.cwd(), readyTimeoutMs: 1000 }); + await assert.rejects(bridge.waitUntilReady(), /Malformed bridge JSONL output/); +}); + +test("processes successful ready handshake", async () => { + const spec = await mockBridgeSpec("process.stdout.write('{\"type\":\"ready\",\"protocol_version\":1}\\n'); setTimeout(() => process.exit(0), 50);\n"); + const bridge = new VillaniBridgeProcess({ spec, cwd: process.cwd(), readyTimeoutMs: 1000 }); + await bridge.waitUntilReady(); + assert.equal(await bridge.waitForExit(), 0); +}); + +test("exit during active run is observable", async () => { + const spec = await mockBridgeSpec("process.stdout.write('{\"type\":\"ready\",\"protocol_version\":1}\\n'); setTimeout(() => process.exit(3), 50);\n"); + const bridge = new VillaniBridgeProcess({ spec, cwd: process.cwd(), readyTimeoutMs: 1000 }); + await bridge.waitUntilReady(); + assert.equal(await bridge.waitForExit(), 3); +}); diff --git a/integrations/pi-villani/src/process.ts b/integrations/pi-villani/src/process.ts new file mode 100644 index 00000000..6fb1a41b --- /dev/null +++ b/integrations/pi-villani/src/process.ts @@ -0,0 +1,246 @@ +import { ChildProcessWithoutNullStreams, spawn } from "node:child_process"; +import { StringDecoder } from "node:string_decoder"; +import { AbortCommand, ApprovalResponseCommand, BridgeCommand, BridgeEvent, commandToLine } from "./protocol.js"; + +export const DEFAULT_VILLANI_COMMAND = "villani-code"; + +export interface BridgeProcessOptions { + command?: string; + cwd: string; + readyTimeoutMs?: number; + env?: NodeJS.ProcessEnv; + signal?: AbortSignal; + onDiagnostic?: (message: string) => void; + onStderr?: (text: string) => void; +} + +export interface CommandSpec { + executable: string; + args: string[]; + display: string; + isFallback?: boolean; +} + +export class VillaniBridgeProcess { + private child: ChildProcessWithoutNullStreams; + private stderrChunks: string[] = []; + private listeners: Array<(event: BridgeEvent) => void> = []; + private readyPromise: Promise; + private exitPromise: Promise; + private closed = false; + private stdoutBuffer = ""; + private stdoutDecoder = new StringDecoder("utf8"); + private startupSettled = false; + private startupReject?: (error: Error) => void; + private startupResolve?: () => void; + private onDiagnostic?: (message: string) => void; + + constructor(options: BridgeProcessOptions & { spec?: CommandSpec }) { + this.onDiagnostic = options.onDiagnostic; + const spec = options.spec ?? commandToSpec(options.command ?? DEFAULT_VILLANI_COMMAND); + const args = [...spec.args, "bridge", "--stdio"]; + options.onDiagnostic?.(`launch command=${spec.display} args=${JSON.stringify(args)} cwd=${options.cwd}`); + this.child = spawn(spec.executable, args, { + cwd: options.cwd, + env: { ...process.env, ...options.env }, + shell: false, + windowsHide: true, + }); + + this.readyPromise = new Promise((resolve, reject) => { + this.startupResolve = () => { + this.startupSettled = true; + resolve(); + }; + this.startupReject = (error: Error) => { + this.startupSettled = true; + reject(error); + }; + }); + + this.child.on("error", (error: NodeJS.ErrnoException) => { + this.closed = true; + options.onDiagnostic?.(`child process error: ${error.message}`); + this.rejectStartup(formatSpawnError(error, spec.display)); + }); + + this.child.stderr.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8"); + this.stderrChunks.push(text); + options.onStderr?.(text); + if (this.stderrChunks.length > 20) this.stderrChunks.shift(); + }); + + this.child.stdout.on("data", (chunk: Buffer) => { + this.consumeStdout(this.stdoutDecoder.write(chunk)); + }); + this.child.stdout.on("end", () => { + const tail = this.stdoutDecoder.end(); + if (tail) this.consumeStdout(tail); + if (this.stdoutBuffer.trim()) this.failProtocol(`Incomplete bridge JSONL line before stdout closed: ${this.stdoutBuffer.slice(0, 200)}`); + }); + + this.exitPromise = new Promise((resolve) => { + this.child.on("exit", (code, signal) => { + this.closed = true; + options.onDiagnostic?.(`child process exit code=${code ?? "null"} signal=${signal ?? "null"}`); + if (!this.startupSettled) { + this.rejectStartup(new Error(`Villani bridge exited before ready (code ${code ?? "null"}, signal ${signal ?? "null"}).${this.stderrSuffix()}`)); + } + resolve(code); + }); + }); + + const onAbort = () => { + this.rejectStartup(new Error("Villani bridge startup aborted.")); + this.kill(); + }; + if (options.signal?.aborted) onAbort(); + else options.signal?.addEventListener("abort", onAbort, { once: true }); + + const timeoutMs = options.readyTimeoutMs ?? 15_000; + const timer = setTimeout(() => { + if (!this.startupSettled) { + this.rejectStartup(new Error(`Timed out waiting for Villani bridge ready after ${timeoutMs}ms.${this.stderrSuffix()}`)); + this.kill(); + } + }, timeoutMs); + this.readyPromise.finally(() => { + clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); + }).catch(() => { + clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); + }); + } + + onEvent(listener: (event: BridgeEvent) => void): void { + this.listeners.push(listener); + } + + waitUntilReady(): Promise { + return this.readyPromise; + } + + waitForExit(): Promise { + return this.exitPromise; + } + + send(command: BridgeCommand): void { + if (this.closed || !this.child.stdin.writable) { + throw new Error(`Villani bridge is not writable.${this.stderrSuffix()}`); + } + try { + const ok = this.child.stdin.write(commandToLine(command)); + if (!ok) this.onDiagnostic?.(`stdin write returned false for command=${command.type}`); + } catch (error) { + this.onDiagnostic?.(`stdin write failure for command=${command.type}: ${error instanceof Error ? error.message : String(error)}`); + throw new Error(`Villani bridge write failed: ${error instanceof Error ? error.message : String(error)}${this.stderrSuffix()}`); + } + } + + abort(runId: string): void { + const command: AbortCommand = { type: "abort", id: runId }; + this.send(command); + } + + respondToApproval(runId: string, requestId: string, approved: boolean): void { + const command: ApprovalResponseCommand = { type: "approval_response", id: runId, request_id: requestId, approved }; + this.send(command); + } + + kill(): void { + if (!this.closed) this.child.kill(); + } + + stderr(): string { + return this.stderrChunks.join("").slice(-4000); + } + + private consumeStdout(text: string): void { + this.stdoutBuffer += text; + while (true) { + const newline = this.stdoutBuffer.indexOf("\n"); + if (newline < 0) return; + const line = this.stdoutBuffer.slice(0, newline).trim(); + this.stdoutBuffer = this.stdoutBuffer.slice(newline + 1); + if (!line) continue; + let event: BridgeEvent; + try { + event = JSON.parse(line) as BridgeEvent; + } catch { + this.failProtocol(`Malformed bridge JSONL output: ${line.slice(0, 200)}`); + return; + } + if (event.type === "ready") this.resolveStartup(); + this.listeners.forEach((listener) => listener(event)); + } + } + + private failProtocol(message: string): void { + this.onDiagnostic?.(`protocol parse error: ${message}`); + const event: BridgeEvent = { type: "error", error: message }; + this.listeners.forEach((listener) => listener(event)); + this.rejectStartup(new Error(`${message}${this.stderrSuffix()}`)); + this.kill(); + } + + private resolveStartup(): void { + if (!this.startupSettled) this.startupResolve?.(); + } + + private rejectStartup(error: Error): void { + if (!this.startupSettled) this.startupReject?.(error); + } + + private stderrSuffix(): string { + const stderr = this.stderr().trim(); + return stderr ? ` stderr: ${stderr}` : ""; + } +} + +export async function startVillaniBridgeProcess(options: BridgeProcessOptions): Promise { + if (options.command) { + const bridgeProcess = new VillaniBridgeProcess(options); + await bridgeProcess.waitUntilReady(); + return bridgeProcess; + } + + const primarySpec = commandToSpec(DEFAULT_VILLANI_COMMAND); + try { + const bridgeProcess = new VillaniBridgeProcess({ ...options, spec: primarySpec }); + await bridgeProcess.waitUntilReady(); + return bridgeProcess; + } catch (error) { + if (!isExecutableMissingError(error)) throw error; + } + + const fallbackSpec: CommandSpec = { + executable: process.platform === "win32" ? "python" : "python3", + args: ["-m", "villani_code.cli"], + display: `${process.platform === "win32" ? "python" : "python3"} -m villani_code.cli`, + isFallback: true, + }; + const fallback = new VillaniBridgeProcess({ ...options, spec: fallbackSpec }); + await fallback.waitUntilReady(); + return fallback; +} + +export function commandToSpec(command: string): CommandSpec { + const trimmed = command.trim(); + const executable = trimmed || DEFAULT_VILLANI_COMMAND; + return { executable, args: [], display: executable }; +} + +function formatSpawnError(error: NodeJS.ErrnoException, display: string): Error { + if (error.code === "ENOENT") { + return new Error( + `Unable to start Villani. The \`${display}\` executable was not found. Install Villani Code in the active environment or set VILLANI_COMMAND to the executable path.`, + ); + } + return new Error(`Unable to start Villani with \`${display}\`: ${error.message}`); +} + +function isExecutableMissingError(error: unknown): boolean { + return error instanceof Error && error.message.includes("executable was not found"); +} diff --git a/integrations/pi-villani/src/protocol.ts b/integrations/pi-villani/src/protocol.ts new file mode 100644 index 00000000..3426e90b --- /dev/null +++ b/integrations/pi-villani/src/protocol.ts @@ -0,0 +1,68 @@ +export const PROTOCOL_VERSION = 1; + +export type VillaniMode = "runner" | "villani"; + +export interface BridgeConfig { + provider?: string; + model?: string; + base_url?: string; + api_key?: string; + pi_model_proxy?: boolean; +} + +export interface BridgeLimits { + max_turns?: number; +} + +export interface PingCommand { + type: "ping"; + id: string; +} + +export interface RunCommand { + type: "run"; + id: string; + task: string; + repo: string; + mode: VillaniMode; + config?: BridgeConfig; + limits?: BridgeLimits; +} + +export interface AbortCommand { + type: "abort"; + id: string; +} + +export interface ApprovalResponseCommand { + type: "approval_response"; + id: string; + request_id: string; + approved: boolean; +} + +export type BridgeCommand = PingCommand | RunCommand | AbortCommand | ApprovalResponseCommand; + +export type BridgeEvent = + | { type: "ready"; protocol_version: number } + | { type: "pong"; id: string } + | { type: "run_started"; id: string; run_id: string; task: string; repo: string; mode: VillaniMode } + | { type: "phase"; id: string; phase: string; message: string } + | { type: "bridge_diagnostic"; id?: string; message: string } + | { type: "tool_started"; id: string; tool: string; path?: string | null; command?: string | null } + | { type: "tool_finished"; id: string; tool: string; ok: boolean; summary: string } + | { type: "workspace_changed"; id: string; files: string[] } + | { type: "verification_started"; id: string; command: string } + | { type: "verification_finished"; id: string; command: string; passed: boolean; summary: string } + | { type: "governor_redirect"; id: string; message: string } + | { type: "abort_requested"; id: string } + | { type: "approval_required"; id: string; request_id: string; tool: string; summary: string; input: Record } + | { type: "approval_resolved"; id: string; request_id: string; tool: string; approved: boolean } + | { type: "run_completed"; id: string; success: true; changed_files: string[]; preexisting_dirty_files?: string[]; verification_passed: boolean | null; summary: string; transcript_path?: string | null } + | { type: "run_failed"; id: string; success: false; error: string; summary: string; changed_files?: string[]; preexisting_dirty_files?: string[]; transcript_path?: string | null } + | { type: "run_aborted"; id: string; success: false; summary: string; changed_files?: string[]; preexisting_dirty_files?: string[]; transcript_path?: string | null } + | { type: "error"; id?: string; error: string }; + +export function commandToLine(command: BridgeCommand): string { + return `${JSON.stringify(command)}\n`; +} diff --git a/integrations/pi-villani/src/render.test.ts b/integrations/pi-villani/src/render.test.ts new file mode 100644 index 00000000..6ecc62fa --- /dev/null +++ b/integrations/pi-villani/src/render.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { BridgeEvent } from "./protocol.js"; +import { renderFinalSummary } from "./render.js"; + +function render(event: Extract): string { + const messages: string[] = []; + renderFinalSummary(event, { markdown: (message) => messages.push(message) }); + return messages.join("\n"); +} + +function completed(verification_passed?: boolean | null): Extract { + return { + type: "run_completed", + id: "run-1", + success: true, + changed_files: [], + preexisting_dirty_files: [], + verification_passed: verification_passed as boolean | null, + summary: "done", + transcript_path: null, + }; +} + +test("final summary renders passed verification", () => { + const output = render(completed(true)); + assert.match(output, /\*\*Verification:\*\* passed/); + assert.doesNotMatch(output, /Verification passed:/); +}); + +test("final summary renders failed verification", () => { + const output = render(completed(false)); + assert.match(output, /\*\*Verification:\*\* failed/); + assert.doesNotMatch(output, /Verification passed:/); +}); + +test("final summary renders null verification as not reported", () => { + const output = render(completed(null)); + assert.match(output, /\*\*Verification:\*\* not reported/); + assert.doesNotMatch(output, /Verification passed: null/); +}); + +test("final summary renders missing verification as not reported", () => { + const event = completed(true) as Partial> as Extract; + delete (event as { verification_passed?: boolean | null }).verification_passed; + const output = render(event); + assert.match(output, /\*\*Verification:\*\* not reported/); + assert.doesNotMatch(output, /Verification passed: undefined/); +}); diff --git a/integrations/pi-villani/src/render.ts b/integrations/pi-villani/src/render.ts new file mode 100644 index 00000000..70619d84 --- /dev/null +++ b/integrations/pi-villani/src/render.ts @@ -0,0 +1,83 @@ +import { BridgeEvent } from "./protocol.js"; + +export interface PiLikeOutput { + info?: (message: string) => void; + warn?: (message: string) => void; + error?: (message: string) => void; + markdown?: (message: string) => void; + log?: (message: string) => void; +} + +export function renderEvent(event: BridgeEvent, output: PiLikeOutput = console): void { + const write = output.info ?? output.log ?? console.log; + const warn = output.warn ?? write; + const error = output.error ?? warn; + switch (event.type) { + case "run_started": + write(`Villani started: ${event.task}`); + break; + case "phase": + write(`Villani: ${event.message}`); + break; + case "bridge_diagnostic": + if (process.env.VILLANI_PI_DEBUG === "1") { + write(`Villani debug: ${event.message}`); + } + break; + case "tool_started": + write(`Tool started: ${event.tool}${event.path ? ` ${event.path}` : ""}${event.command ? ` ${event.command}` : ""}`); + break; + case "tool_finished": + write(`Tool ${event.ok ? "finished" : "failed"}: ${event.summary}`); + break; + case "workspace_changed": + write(`Workspace changed: ${event.files.join(", ")}`); + break; + case "verification_started": + write(`Verification started: ${event.command}`); + break; + case "verification_finished": + write(`Verification ${event.passed ? "passed" : "failed"}: ${event.command}`); + break; + case "governor_redirect": + warn(`Villani governor: ${event.message}`); + break; + case "run_completed": + case "run_failed": + case "run_aborted": + renderFinalSummary(event, output); + break; + case "error": + error(`Villani bridge error: ${event.error}`); + break; + } +} + +export function renderFinalSummary(event: Extract, output: PiLikeOutput = console): void { + const markdown = output.markdown ?? output.info ?? output.log ?? console.log; + const changed = "changed_files" in event && event.changed_files?.length ? event.changed_files : []; + const preexisting = "preexisting_dirty_files" in event && event.preexisting_dirty_files?.length ? event.preexisting_dirty_files : []; + const changedFiles = changed.length ? changed.map((file) => `- ${file}`).join("\n") : "None reported"; + const preexistingFiles = preexisting.length ? preexisting.map((file) => `- ${file}`).join("\n") : ""; + const verification = formatVerificationStatus("verification_passed" in event ? event.verification_passed : undefined); + const status = event.type === "run_completed" ? "completed" : event.type === "run_aborted" ? "aborted" : "failed"; + markdown([ + `### Villani ${status}`, + "", + "**Summary:**", + event.summary || "No summary reported.", + "", + `**Changed by Villani**\n${changedFiles}`, + preexistingFiles ? `\n**Pre-existing workspace changes excluded from attribution**\n${preexistingFiles}` : "", + "", + `**Verification:** ${verification}`, + `**Transcript:** ${event.transcript_path ?? "not reported"}`, + "error" in event && event.error ? `**Error:** ${event.error}` : "", + ].filter(Boolean).join("\n")); +} + +export function formatVerificationStatus(value: boolean | null | undefined): "passed" | "failed" | "not reported" { + if (value === true) return "passed"; + if (value === false) return "failed"; + return "not reported"; +} diff --git a/integrations/pi-villani/src/runtime.test.ts b/integrations/pi-villani/src/runtime.test.ts new file mode 100644 index 00000000..a8cb8211 --- /dev/null +++ b/integrations/pi-villani/src/runtime.test.ts @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { resolveRuntimeAsset, VILLANI_RUNTIME_VERSION } from "./runtimeConfig.js"; +import { parseChecksum, resolveVillaniExecutable, runtimeInstallDir, sha256 } from "./runtime.js"; + +function response(body: string | Buffer, status = 200): Response { + const payload: BodyInit = typeof body === "string" ? body : new Uint8Array(body); + return new Response(payload, { status }); +} + +async function tempRoot(): Promise { + return mkdtemp(join(tmpdir(), "pi-villani-runtime-test-")); +} + +test("runtime platform mapping covers supported platforms", () => { + assert.equal(resolveRuntimeAsset("win32", "x64").assetName, `villani-runtime-v${VILLANI_RUNTIME_VERSION}-win32-x64.zip`); + assert.equal(resolveRuntimeAsset("win32", "x64").executableRelativePath, "villani-code/villani-code.exe"); + assert.equal(resolveRuntimeAsset("darwin", "arm64").platformKey, "darwin-arm64"); + assert.equal(resolveRuntimeAsset("darwin", "x64").platformKey, "darwin-x64"); + assert.equal(resolveRuntimeAsset("linux", "x64").platformKey, "linux-x64"); + assert.throws(() => resolveRuntimeAsset("linux", "arm64"), /not yet available/); +}); + +test("override returns exact command and skips downloads", async () => { + const command = "C:\\Program Files\\Python\\Scripts\\villani-code.exe"; + const resolved = await resolveVillaniExecutable({ + overrideCommand: command, + fetchImpl: async () => { throw new Error("should not fetch"); }, + }); + assert.deepEqual(resolved, { executable: command, source: "override" }); +}); + +test("verified cached runtime is reused without downloading", async () => { + const cacheRoot = await tempRoot(); + try { + const asset = resolveRuntimeAsset("linux", "x64"); + const dir = runtimeInstallDir(cacheRoot, asset.platformKey); + const executable = join(dir, asset.executableRelativePath); + await mkdir(join(dir, "villani-code"), { recursive: true }); + await writeFile(executable, "#!/bin/sh\n", { mode: 0o755 }); + await writeFile(join(dir, ".verified.json"), JSON.stringify({ runtimeVersion: VILLANI_RUNTIME_VERSION, assetName: asset.assetName, checksum: "a".repeat(64) }), "utf8"); + const resolved = await resolveVillaniExecutable({ platform: "linux", arch: "x64", cacheRoot, fetchImpl: async () => { throw new Error("should not fetch"); } }); + assert.equal(resolved.source, "cached-runtime"); + assert.equal(resolved.executable, executable); + } finally { + await rm(cacheRoot, { recursive: true, force: true }); + } +}); + +test("unverified cache is not executed and correct checksum installs runtime", async () => { + const cacheRoot = await tempRoot(); + try { + const asset = resolveRuntimeAsset("linux", "x64"); + const archive = Buffer.from("fake archive"); + const checksum = sha256(archive); + let fetches = 0; + const resolved = await resolveVillaniExecutable({ + platform: "linux", + arch: "x64", + cacheRoot, + fetchImpl: async (url) => { + fetches += 1; + return String(url).endsWith("checksums.txt") ? response(`${checksum} ${asset.assetName}\n`) : response(archive); + }, + extractArchive: async (_archivePath, destination) => { + await mkdir(join(destination, "villani-code"), { recursive: true }); + await writeFile(join(destination, asset.executableRelativePath), "#!/bin/sh\n", { mode: 0o755 }); + }, + }); + assert.equal(resolved.source, "downloaded-runtime"); + assert.equal(fetches, 2); + const marker = JSON.parse(await readFile(join(runtimeInstallDir(cacheRoot, asset.platformKey), ".verified.json"), "utf8")); + assert.equal(marker.checksum, checksum); + + const cached = await resolveVillaniExecutable({ platform: "linux", arch: "x64", cacheRoot, fetchImpl: async () => { throw new Error("should not refetch"); } }); + assert.equal(cached.source, "cached-runtime"); + } finally { + await rm(cacheRoot, { recursive: true, force: true }); + } +}); + +test("checksum mismatch, non-200, missing executable, and abort fail safely", async () => { + const cacheRoot = await tempRoot(); + try { + const asset = resolveRuntimeAsset("linux", "x64"); + await assert.rejects(resolveVillaniExecutable({ + platform: "linux", + arch: "x64", + cacheRoot, + fetchImpl: async (url) => String(url).endsWith("checksums.txt") ? response(`${"0".repeat(64)} ${asset.assetName}\n`) : response(Buffer.from("bad")), + extractArchive: async () => undefined, + }), /integrity verification/); + + await assert.rejects(resolveVillaniExecutable({ + platform: "linux", + arch: "x64", + cacheRoot, + fetchImpl: async () => response("nope", 404), + }), /HTTP 404/); + + const archive = Buffer.from("archive"); + const checksum = sha256(archive); + await assert.rejects(resolveVillaniExecutable({ + platform: "linux", + arch: "x64", + cacheRoot, + fetchImpl: async (url) => String(url).endsWith("checksums.txt") ? response(`${checksum} ${asset.assetName}\n`) : response(archive), + extractArchive: async (_archivePath, destination) => { await mkdir(destination, { recursive: true }); }, + }), /expected executable was not found/); + + const controller = new AbortController(); + controller.abort(); + await assert.rejects(resolveVillaniExecutable({ platform: "linux", arch: "x64", cacheRoot, signal: controller.signal }), /cancelled during runtime setup/); + await assert.rejects(stat(runtimeInstallDir(cacheRoot, asset.platformKey)), /ENOENT/); + } finally { + await rm(cacheRoot, { recursive: true, force: true }); + } +}); + +test("parseChecksum requires matching asset", () => { + assert.equal(parseChecksum(`${"a".repeat(64)} asset.zip\n`, "asset.zip"), "a".repeat(64)); + assert.throws(() => parseChecksum("", "asset.zip"), /Missing SHA-256/); +}); diff --git a/integrations/pi-villani/src/runtime.ts b/integrations/pi-villani/src/runtime.ts new file mode 100644 index 00000000..2313625e --- /dev/null +++ b/integrations/pi-villani/src/runtime.ts @@ -0,0 +1,169 @@ +import { createHash } from "node:crypto"; +import { constants as fsConstants } from "node:fs"; +import { access, chmod, mkdir, mkdtemp, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import AdmZip from "adm-zip"; +import * as tar from "tar"; +import { RuntimeAsset, resolveRuntimeAsset, VILLANI_RUNTIME_VERSION } from "./runtimeConfig.js"; + +export interface ResolvedVillaniExecutable { + executable: string; + source: "override" | "cached-runtime" | "downloaded-runtime"; + version?: string; +} + +export interface RuntimeResolverOptions { + overrideCommand?: string; + onProgress?: (message: string) => void; + signal?: AbortSignal; + platform?: NodeJS.Platform; + arch?: string; + cacheRoot?: string; + fetchImpl?: typeof fetch; + extractArchive?: (archivePath: string, destination: string, asset: RuntimeAsset) => Promise; +} + +interface VerificationMarker { + runtimeVersion: string; + assetName: string; + checksum: string; + installedAt: string; +} + +export async function resolveVillaniExecutable(options: RuntimeResolverOptions = {}): Promise { + const override = options.overrideCommand?.trim(); + if (override) return { executable: override, source: "override" }; + + const asset = resolveRuntimeAsset(options.platform, options.arch ?? process.arch); + const cacheRoot = options.cacheRoot ?? defaultRuntimeCacheRoot(); + const finalDir = runtimeInstallDir(cacheRoot, asset.platformKey); + const markerPath = join(finalDir, ".verified.json"); + const executable = join(finalDir, asset.executableRelativePath); + if (await isVerifiedRuntime(markerPath, executable, asset)) { + return { executable, source: "cached-runtime", version: VILLANI_RUNTIME_VERSION }; + } + + throwIfAborted(options.signal); + options.onProgress?.(`Downloading Villani runtime for ${asset.platformKey}...`); + await mkdir(dirname(finalDir), { recursive: true }); + const tempDir = await mkdtemp(join(dirname(finalDir), `.install-${asset.platformKey}-`)); + try { + const fetchImpl = options.fetchImpl ?? fetch; + const checksums = await fetchText(fetchImpl, asset.checksumsUrl, options.signal, "checksums.txt"); + throwIfAborted(options.signal); + const expectedChecksum = parseChecksum(checksums, asset.assetName); + const archiveBytes = await fetchBytes(fetchImpl, asset.downloadUrl, options.signal, asset.assetName); + throwIfAborted(options.signal); + const actualChecksum = sha256(archiveBytes); + if (actualChecksum !== expectedChecksum) { + throw new Error("Villani runtime download failed integrity verification and was not executed."); + } + + const archivePath = join(tempDir, asset.assetName); + const extractDir = join(tempDir, "extract"); + await mkdir(extractDir, { recursive: true }); + await writeFile(archivePath, archiveBytes); + await (options.extractArchive ?? extractRuntimeArchive)(archivePath, extractDir, asset); + const extractedExecutable = join(extractDir, asset.executableRelativePath); + await assertExecutableExists(extractedExecutable, asset); + if (asset.archiveType !== "zip") await chmod(extractedExecutable, 0o755); + + const marker: VerificationMarker = { + runtimeVersion: VILLANI_RUNTIME_VERSION, + assetName: asset.assetName, + checksum: actualChecksum, + installedAt: new Date().toISOString(), + }; + await writeFile(join(extractDir, ".verified.json"), JSON.stringify(marker, null, 2), "utf8"); + await rm(finalDir, { recursive: true, force: true }); + try { + await rename(extractDir, finalDir); + } catch (error) { + if (await isVerifiedRuntime(markerPath, executable, asset)) { + return { executable, source: "cached-runtime", version: VILLANI_RUNTIME_VERSION }; + } + throw error; + } + options.onProgress?.("Villani runtime installed."); + return { executable, source: "downloaded-runtime", version: VILLANI_RUNTIME_VERSION }; + } catch (error) { + if (isAbortError(error) || options.signal?.aborted) { + throw new Error("Villani run cancelled during runtime setup."); + } + throw error; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +} + +export function defaultRuntimeCacheRoot(): string { + if (process.env.VILLANI_RUNTIME_CACHE_DIR) return process.env.VILLANI_RUNTIME_CACHE_DIR; + if (process.platform === "win32" && process.env.LOCALAPPDATA) return join(process.env.LOCALAPPDATA, "pi-villani", "runtime"); + return join(homedir(), ".cache", "pi-villani", "runtime"); +} + +export function runtimeInstallDir(cacheRoot: string, platformKey: string): string { + return join(cacheRoot, VILLANI_RUNTIME_VERSION, platformKey); +} + +export function parseChecksum(checksumsText: string, assetName: string): string { + for (const rawLine of checksumsText.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line) continue; + const match = line.match(/^([a-fA-F0-9]{64})\s+\*?(.+)$/); + if (match && match[2].trim() === assetName) return match[1].toLowerCase(); + } + throw new Error(`Missing SHA-256 checksum for ${assetName}.`); +} + +export function sha256(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +async function isVerifiedRuntime(markerPath: string, executable: string, asset: RuntimeAsset): Promise { + try { + await access(executable, fsConstants.X_OK); + const marker = JSON.parse(await readFile(markerPath, "utf8")) as Partial; + return marker.runtimeVersion === VILLANI_RUNTIME_VERSION && marker.assetName === asset.assetName && typeof marker.checksum === "string"; + } catch { + return false; + } +} + +async function assertExecutableExists(path: string, asset: RuntimeAsset): Promise { + try { + const info = await stat(path); + if (!info.isFile()) throw new Error("not a file"); + } catch (error) { + throw new Error(`Villani runtime archive is invalid: expected executable was not found at ${asset.executableRelativePath}.`, { cause: error }); + } +} + +async function extractRuntimeArchive(archivePath: string, destination: string, asset: RuntimeAsset): Promise { + if (asset.archiveType === "zip") { + new AdmZip(archivePath).extractAllTo(destination, true); + return; + } + await tar.x({ file: archivePath, cwd: destination }); +} + +async function fetchText(fetchImpl: typeof fetch, url: string, signal: AbortSignal | undefined, label: string): Promise { + const response = await fetchImpl(url, { signal }); + if (!response.ok) throw new Error(`Villani could not download ${label} from GitHub Releases (HTTP ${response.status}).`); + return response.text(); +} + +async function fetchBytes(fetchImpl: typeof fetch, url: string, signal: AbortSignal | undefined, label: string): Promise { + const response = await fetchImpl(url, { signal }); + if (!response.ok) throw new Error(`Villani could not download ${label} from GitHub Releases (HTTP ${response.status}).`); + return Buffer.from(await response.arrayBuffer()); +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw new Error("Villani run cancelled during runtime setup."); +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && (error.name === "AbortError" || error.message.includes("aborted")); +} diff --git a/integrations/pi-villani/src/runtimeConfig.ts b/integrations/pi-villani/src/runtimeConfig.ts new file mode 100644 index 00000000..f6c36e9e --- /dev/null +++ b/integrations/pi-villani/src/runtimeConfig.ts @@ -0,0 +1,38 @@ +export const VILLANI_RUNTIME_VERSION = "0.1.0"; +export const VILLANI_RUNTIME_REPOSITORY = "mmprotest/villani-code"; +export const VILLANI_RUNTIME_TAG = `pi-villani-runtime-v${VILLANI_RUNTIME_VERSION}`; + +export type RuntimePlatformKey = "win32-x64" | "darwin-arm64" | "darwin-x64" | "linux-x64"; + +export interface RuntimeAsset { + platformKey: RuntimePlatformKey; + assetName: string; + archiveType: "zip" | "tar.gz"; + executableRelativePath: string; + downloadUrl: string; + checksumsUrl: string; +} + +export function resolveRuntimeAsset(platform: NodeJS.Platform = process.platform, arch: string = process.arch): RuntimeAsset { + const key = `${platform}-${arch}`; + if (!isSupportedRuntimePlatform(key)) { + throw new Error(`Villani runtime is not yet available for platform ${key}. Set VILLANI_COMMAND to a locally installed Villani executable to continue.`); + } + const archiveType = platform === "win32" ? "zip" : "tar.gz"; + const suffix = archiveType === "zip" ? ".zip" : ".tar.gz"; + const assetName = `villani-runtime-v${VILLANI_RUNTIME_VERSION}-${key}${suffix}`; + const base = `https://github.com/${VILLANI_RUNTIME_REPOSITORY}/releases/download/${VILLANI_RUNTIME_TAG}`; + return { + platformKey: key, + assetName, + archiveType, + executableRelativePath: platform === "win32" ? "villani-code/villani-code.exe" : "villani-code/villani-code", + downloadUrl: `${base}/${assetName}`, + checksumsUrl: `${base}/checksums.txt`, + }; +} + +function isSupportedRuntimePlatform(key: string): key is RuntimePlatformKey { + return key === "win32-x64" || key === "darwin-arm64" || key === "darwin-x64" || key === "linux-x64"; +} + diff --git a/integrations/pi-villani/tsconfig.json b/integrations/pi-villani/tsconfig.json new file mode 100644 index 00000000..789063c8 --- /dev/null +++ b/integrations/pi-villani/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packaging/smoke_test_runtime.py b/packaging/smoke_test_runtime.py new file mode 100644 index 00000000..31717d6d --- /dev/null +++ b/packaging/smoke_test_runtime.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import json +import queue +import subprocess +import sys +import threading +from pathlib import Path +from typing import TextIO + + +def read_line_with_timeout(stdout: TextIO, timeout: float = 15) -> str: + lines: queue.Queue[str | BaseException] = queue.Queue() + + def read_line() -> None: + try: + lines.put(stdout.readline()) + except BaseException as exc: # noqa: BLE001 + lines.put(exc) + + threading.Thread(target=read_line, daemon=True).start() + try: + line = lines.get(timeout=timeout) + except queue.Empty as exc: + raise TimeoutError(f"timed out waiting for runtime output after {timeout} seconds") from exc + if isinstance(line, BaseException): + raise line + if line == "": + raise EOFError("runtime stdout closed before expected response") + return line.strip() + + +def cleanup(proc: subprocess.Popen[str]) -> str: + if proc.stdin is not None and not proc.stdin.closed: + proc.stdin.close() + try: + proc.wait(timeout=15) + except subprocess.TimeoutExpired: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + return proc.stderr.read() if proc.stderr is not None else "" + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: smoke_test_runtime.py ", file=sys.stderr) + return 2 + executable = Path(sys.argv[1]) + proc = subprocess.Popen( + [str(executable), "bridge", "--stdio"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + assert proc.stdin is not None + assert proc.stdout is not None + events: list[dict[str, object]] = [] + stderr = "" + try: + events.append(json.loads(read_line_with_timeout(proc.stdout))) + proc.stdin.write('{"type":"ping","id":"release-smoke"}\n') + proc.stdin.flush() + events.append(json.loads(read_line_with_timeout(proc.stdout))) + if proc.poll() is not None: + raise RuntimeError(f"runtime exited before stdin closed with code {proc.returncode}") + except Exception as exc: # noqa: BLE001 + stderr = cleanup(proc) + print(f"runtime smoke test failed: {exc}; events={events}; stderr={stderr}", file=sys.stderr) + return 1 + stderr = cleanup(proc) + if events[:2] != [{"type": "ready", "protocol_version": 1}, {"type": "pong", "id": "release-smoke"}]: + print(f"unexpected events: {events}; stderr={stderr}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packaging/villani_runtime.spec b/packaging/villani_runtime.spec new file mode 100644 index 00000000..8fe3926c --- /dev/null +++ b/packaging/villani_runtime.spec @@ -0,0 +1,54 @@ +# -*- mode: python ; coding: utf-8 -*- + +from pathlib import Path +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + +block_cipher = None +ROOT = Path.cwd() + +hiddenimports = collect_submodules("villani_code") +datas = collect_data_files("villani_code") + +a = Analysis( + ["villani_runtime_entry.py"], + pathex=[str(ROOT), str(ROOT / "packaging")], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=["tests", "pytest"], + 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, + [], + exclude_binaries=True, + name="villani-code", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) +coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name="villani-code", +) diff --git a/packaging/villani_runtime_entry.py b/packaging/villani_runtime_entry.py new file mode 100644 index 00000000..ae46e003 --- /dev/null +++ b/packaging/villani_runtime_entry.py @@ -0,0 +1,15 @@ +"""Frozen Villani runtime entrypoint for PyInstaller builds.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +if __package__ is None and not getattr(sys, "frozen", False): + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from villani_code.cli import app + + +if __name__ == "__main__": + app() diff --git a/pyproject.toml b/pyproject.toml index ae9415ba..8ed7bb55 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dev = [ "pytest-asyncio>=0.23", "ruff>=0.6.0", "mypy>=1.10", + "pyinstaller>=6.0", ] [project.scripts] diff --git a/tests/integrations/test_pi_bridge.py b/tests/integrations/test_pi_bridge.py new file mode 100644 index 00000000..63bb916e --- /dev/null +++ b/tests/integrations/test_pi_bridge.py @@ -0,0 +1,577 @@ +from __future__ import annotations + +import io +import json +import queue +import subprocess +import sys +import threading +import time +from pathlib import Path +from typing import Any + +from villani_code.integrations.pi_bridge import PiBridge, map_runner_event, summarize_approval_request +from villani_code.state import Runner +from villani_code import state_runtime + + +class DummyRunner: + def __init__(self, events: list[dict[str, Any]] | None = None, result: dict[str, Any] | None = None, error: Exception | None = None) -> None: + self.event_callback = lambda _event: None + self.events = events or [] + self.result = result or { + "response": {"content": [{"type": "text", "text": "Fixed failing test."}]}, + "transcript_path": ".villani_code/runs/run/transcript.json", + "execution": {"final_text": "Fixed failing test."}, + } + self.error = error + + def run(self, instruction: str, **_kwargs: Any) -> dict[str, Any]: + if self.error: + raise self.error + for event in self.events: + self.event_callback(event) + return self.result + + +def collect_json_lines(output: io.StringIO) -> list[dict[str, Any]]: + return [json.loads(line) for line in output.getvalue().splitlines() if line.strip()] + + +def seed_planning_repo(repo: Path) -> None: + (repo / "villani_code").mkdir(parents=True, exist_ok=True) + (repo / "villani_code" / "__init__.py").write_text("", encoding="utf-8") + (repo / "pyproject.toml").write_text("[project]\nname='x'\n", encoding="utf-8") + + +class PlanningClient: + def create_message(self, _payload: Any, stream: bool) -> dict[str, Any]: + return {"content": [{"type": "text", "text": "ok"}]} + + +def test_bridge_planning_does_not_emit_execution_plan_approval(tmp_path: Path) -> None: + seed_planning_repo(tmp_path) + + class PlanningRunner(Runner): + def run(self, instruction: str, **_kwargs: Any) -> dict[str, Any]: + state_runtime.ensure_project_memory_and_plan(self, instruction) + return { + "response": {"content": [{"type": "text", "text": "planned"}]}, + "execution": {"final_text": "planned"}, + "transcript_path": None, + } + + def factory(_command: Any, event_callback: Any, approval_callback: Any) -> PlanningRunner: + runner = PlanningRunner(client=PlanningClient(), repo=tmp_path, model="m", stream=False) + runner.event_callback = event_callback + runner.approval_callback = approval_callback + return runner + + stdout = io.StringIO() + bridge = PiBridge(stdout=stdout, runner_factory=factory) + bridge._handle_command({"type": "run", "id": "run-plan", "task": "delete files and rewrite history", "repo": str(tmp_path)}) + deadline = time.time() + 2 + while bridge._active and time.time() < deadline: + time.sleep(0.01) + bridge._drain_events() + + events = collect_json_lines(stdout) + assert any(event.get("type") == "phase" and event.get("phase") == "planning_started" for event in events) + assert not any(event.get("type") == "approval_required" and event.get("tool") == "ExecutionPlan" for event in events) + assert events[-1]["type"] == "run_completed" + + + +def read_json_line_with_timeout(stdout: Any, timeout: float = 5) -> dict[str, Any]: + lines: queue.Queue[str | BaseException] = queue.Queue() + + def read_line() -> None: + try: + lines.put(stdout.readline()) + except BaseException as exc: # noqa: BLE001 + lines.put(exc) + + thread = threading.Thread(target=read_line, daemon=True) + thread.start() + try: + line = lines.get(timeout=timeout) + except queue.Empty as exc: + raise AssertionError(f"timed out waiting for bridge output after {timeout} seconds") from exc + if isinstance(line, BaseException): + raise line + assert line, "bridge stdout closed before expected event" + return json.loads(line) + + +def start_cli_bridge_process() -> subprocess.Popen[str]: + return subprocess.Popen( + [sys.executable, "-u", "-m", "villani_code.cli", "bridge", "--stdio"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + ) + + +def stop_cli_bridge_process(proc: subprocess.Popen[str]) -> None: + if proc.stdin is not None and not proc.stdin.closed: + proc.stdin.close() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + + +def test_cli_bridge_persistent_stdin_ping_responds_before_stdin_closes() -> None: + proc = start_cli_bridge_process() + assert proc.stdin is not None + assert proc.stdout is not None + try: + assert read_json_line_with_timeout(proc.stdout) == {"type": "ready", "protocol_version": 1} + proc.stdin.write('{"type":"ping","id":"persistent-ping"}\n') + proc.stdin.flush() + assert read_json_line_with_timeout(proc.stdout) == {"type": "pong", "id": "persistent-ping"} + assert proc.poll() is None + finally: + stop_cli_bridge_process(proc) + + +def test_cli_bridge_persistent_stdin_accepts_multiple_commands_before_close() -> None: + proc = start_cli_bridge_process() + assert proc.stdin is not None + assert proc.stdout is not None + try: + assert read_json_line_with_timeout(proc.stdout) == {"type": "ready", "protocol_version": 1} + proc.stdin.write('{"type":"ping","id":"persistent-ping-1"}\n') + proc.stdin.flush() + assert read_json_line_with_timeout(proc.stdout) == {"type": "pong", "id": "persistent-ping-1"} + proc.stdin.write('{"type":"ping","id":"persistent-ping-2"}\n') + proc.stdin.flush() + assert read_json_line_with_timeout(proc.stdout) == {"type": "pong", "id": "persistent-ping-2"} + assert proc.poll() is None + finally: + stop_cli_bridge_process(proc) + +def test_stdio_ping_emits_ready_and_pong() -> None: + stdin = io.StringIO('{"type":"ping","id":"abc"}\n') + stdout = io.StringIO() + PiBridge(stdin=stdin, stdout=stdout).run_stdio() + assert collect_json_lines(stdout) == [ + {"type": "ready", "protocol_version": 1}, + {"type": "pong", "id": "abc"}, + ] + + +def test_malformed_json_emits_error_and_processes_next_command() -> None: + stdin = io.StringIO('{bad json}\n{"type":"ping","id":"still-alive"}\n') + stdout = io.StringIO() + PiBridge(stdin=stdin, stdout=stdout).run_stdio() + events = collect_json_lines(stdout) + assert events[0]["type"] == "ready" + assert events[1]["type"] == "error" + assert events[2] == {"type": "pong", "id": "still-alive"} + + +def test_unknown_command_emits_error() -> None: + stdin = io.StringIO('{"type":"wat"}\n') + stdout = io.StringIO() + PiBridge(stdin=stdin, stdout=stdout).run_stdio() + events = collect_json_lines(stdout) + assert events[1]["type"] == "error" + assert "Unknown bridge command type" in events[1]["error"] + + +def test_run_command_constructs_runner_and_completes(tmp_path: Path) -> None: + seen: dict[str, Any] = {} + + def factory(command: Any, event_callback: Any) -> DummyRunner: + seen["command"] = command + runner = DummyRunner( + events=[ + {"type": "diagnosis_attempted"}, + {"type": "tool_started", "name": "Read", "input": {"path": "src/foo.py"}}, + {"type": "tool_finished", "name": "Read", "input": {"path": "src/foo.py"}, "is_error": False}, + {"type": "validation_step_started", "name": "pytest", "command": "pytest tests/test_foo.py"}, + {"type": "validation_step_finished", "name": "pytest", "command": "pytest tests/test_foo.py", "exit_code": 0}, + ] + ) + runner.event_callback = event_callback + return runner + + stdout = io.StringIO() + bridge = PiBridge(stdout=stdout, runner_factory=factory) + bridge._handle_command( + { + "type": "run", + "id": "run-123", + "task": "Fix tests", + "repo": str(tmp_path), + "mode": "runner", + "config": {"provider": "openai", "model": "m", "base_url": "http://localhost", "api_key": "dummy"}, + "limits": {"max_turns": 3}, + } + ) + deadline = time.time() + 2 + while bridge._active and time.time() < deadline: + time.sleep(0.01) + bridge._drain_events() + + events = collect_json_lines(stdout) + assert seen["command"].limits.max_turns == 3 + assert events[0]["type"] == "run_started" + assert any(event["type"] == "phase" and event.get("phase") == "diagnosis_attempted" for event in events) + assert any(event["type"] == "bridge_diagnostic" and "model configuration" in event.get("message", "") for event in events) + assert any(event["type"] == "bridge_diagnostic" and "tool call requested" in event.get("message", "") for event in events) + assert any(event["type"] == "verification_finished" and event["passed"] for event in events) + assert events[-1]["type"] == "run_completed" + assert events[-1]["transcript_path"] == ".villani_code/runs/run/transcript.json" + + +def test_failing_runner_emits_run_failed(tmp_path: Path) -> None: + def factory(_command: Any, event_callback: Any) -> DummyRunner: + runner = DummyRunner(error=RuntimeError("boom")) + runner.event_callback = event_callback + return runner + + stdout = io.StringIO() + bridge = PiBridge(stdout=stdout, stderr=io.StringIO(), runner_factory=factory) + bridge._handle_command({"type": "run", "id": "run-fail", "task": "Fix", "repo": str(tmp_path)}) + deadline = time.time() + 2 + while bridge._active and time.time() < deadline: + time.sleep(0.01) + bridge._drain_events() + events = collect_json_lines(stdout) + assert events[0]["type"] == "run_started" + assert events[-1]["type"] == "run_failed" + assert events[-1]["error"] == "boom" + + +def test_abort_is_reported_after_cooperative_runner_stops(tmp_path: Path) -> None: + started = threading.Event() + + class BlockingRunner(DummyRunner): + def run(self, instruction: str, **_kwargs: Any) -> dict[str, Any]: + started.set() + while not bridge._active["run-abort"].abort_requested.is_set(): + time.sleep(0.01) + return self.result + + def factory(_command: Any, event_callback: Any) -> BlockingRunner: + runner = BlockingRunner() + runner.event_callback = event_callback + return runner + + stdout = io.StringIO() + bridge = PiBridge(stdout=stdout, runner_factory=factory) + bridge._handle_command({"type": "run", "id": "run-abort", "task": "Fix", "repo": str(tmp_path)}) + assert started.wait(timeout=2) + bridge._handle_command({"type": "abort", "id": "run-abort"}) + active = next(iter(bridge._active.values())) + assert active.thread is not None + active.thread.join(timeout=2) + bridge._drain_events() + events = collect_json_lines(stdout) + assert any(event["type"] == "abort_requested" for event in events) + assert events[-1]["type"] == "run_aborted" + + +def test_runner_event_mapping_does_not_expose_prompts() -> None: + mapped = map_runner_event("run-1", {"type": "stream_text", "text": "hidden prompt-ish text"}) + assert mapped == [] + +class EditingRunner(DummyRunner): + def __init__(self, repo: Path, edits: dict[str, str], events: list[dict[str, Any]] | None = None) -> None: + super().__init__(events=events) + self.repo = repo + self.edits = edits + self.run_calls = 0 + self.villani_calls = 0 + + def run(self, instruction: str, **_kwargs: Any) -> dict[str, Any]: + self.run_calls += 1 + for rel, content in self.edits.items(): + target = self.repo / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + self.event_callback({"type": "tool_finished", "name": "Write", "input": {"path": rel}, "is_error": False}) + return self.result + + def run_villani_mode(self) -> dict[str, Any]: + self.villani_calls += 1 + return self.run("villani") + + +def init_git_repo(repo: Path) -> None: + import subprocess + + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=repo, check=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=repo, check=True) + + +def commit_all(repo: Path) -> None: + import subprocess + + subprocess.run(["git", "add", "."], cwd=repo, check=True) + subprocess.run(["git", "commit", "-m", "init"], cwd=repo, check=True, capture_output=True) + + +def run_bridge_command(repo: Path, runner: DummyRunner, mode: str = "runner") -> dict[str, Any]: + stdout = io.StringIO() + + def factory(_command: Any, event_callback: Any) -> DummyRunner: + runner.event_callback = event_callback + return runner + + bridge = PiBridge(stdout=stdout, runner_factory=factory) + bridge._handle_command({"type": "run", "id": "run-change", "task": "Fix", "repo": str(repo), "mode": mode}) + deadline = time.time() + 2 + while bridge._active and time.time() < deadline: + time.sleep(0.01) + bridge._drain_events() + return collect_json_lines(stdout)[-1] + + +def test_changed_files_clean_repo_new_change(tmp_path: Path) -> None: + init_git_repo(tmp_path) + (tmp_path / "src.py").write_text("old\n", encoding="utf-8") + commit_all(tmp_path) + + event = run_bridge_command(tmp_path, EditingRunner(tmp_path, {"src.py": "new\n"})) + + assert event["type"] == "run_completed" + assert event["changed_files"] == ["src.py"] + assert event["preexisting_dirty_files"] == [] + + +def test_changed_files_preexisting_dirty_unchanged_not_attributed(tmp_path: Path) -> None: + init_git_repo(tmp_path) + (tmp_path / "notes.txt").write_text("clean\n", encoding="utf-8") + commit_all(tmp_path) + (tmp_path / "notes.txt").write_text("dirty before\n", encoding="utf-8") + + event = run_bridge_command(tmp_path, EditingRunner(tmp_path, {})) + + assert event["changed_files"] == [] + assert event["preexisting_dirty_files"] == ["notes.txt"] + + +def test_changed_files_dirty_repo_new_clean_file_modified(tmp_path: Path) -> None: + init_git_repo(tmp_path) + (tmp_path / "notes.txt").write_text("clean\n", encoding="utf-8") + (tmp_path / "src.py").write_text("old\n", encoding="utf-8") + commit_all(tmp_path) + (tmp_path / "notes.txt").write_text("dirty before\n", encoding="utf-8") + + event = run_bridge_command(tmp_path, EditingRunner(tmp_path, {"src.py": "new\n"})) + + assert event["changed_files"] == ["src.py"] + assert event["preexisting_dirty_files"] == ["notes.txt"] + + +def test_changed_files_preexisting_dirty_modified_further(tmp_path: Path) -> None: + init_git_repo(tmp_path) + (tmp_path / "notes.txt").write_text("clean\n", encoding="utf-8") + commit_all(tmp_path) + (tmp_path / "notes.txt").write_text("dirty before\n", encoding="utf-8") + + event = run_bridge_command(tmp_path, EditingRunner(tmp_path, {"notes.txt": "dirty after villani\n"})) + + assert event["changed_files"] == ["notes.txt"] + assert event["preexisting_dirty_files"] == ["notes.txt"] + + +def test_changed_files_change_then_revert_not_attributed(tmp_path: Path) -> None: + init_git_repo(tmp_path) + (tmp_path / "src.py").write_text("old\n", encoding="utf-8") + commit_all(tmp_path) + + event = run_bridge_command(tmp_path, EditingRunner(tmp_path, {"src.py": "old\n"})) + + assert event["changed_files"] == [] + assert event["preexisting_dirty_files"] == [] + + +def test_mode_runner_calls_run_and_villani_calls_run_villani(tmp_path: Path) -> None: + runner = EditingRunner(tmp_path, {}) + run_bridge_command(tmp_path, runner, mode="runner") + assert runner.run_calls == 1 + assert runner.villani_calls == 0 + + villani_runner = EditingRunner(tmp_path, {}) + run_bridge_command(tmp_path, villani_runner, mode="villani") + assert villani_runner.villani_calls == 1 + + +class ApprovalRunner(DummyRunner): + def __init__(self, repo: Path, tool: str, payload: dict[str, Any]) -> None: + super().__init__() + self.repo = repo + self.tool = tool + self.payload = payload + self.approval_callback = lambda _name, _input: False + self.executed = threading.Event() + self.denied = threading.Event() + + def run(self, instruction: str, **_kwargs: Any) -> dict[str, Any]: + approved = self.approval_callback(self.tool, self.payload) + self.event_callback({"type": "approval_resolved", "name": self.tool, "input": self.payload, "approved": approved}) + if not approved: + self.denied.set() + return {"response": {"content": [{"type": "text", "text": "Denied."}]}, "execution": {"final_text": "Denied."}} + if self.tool == "Bash": + marker = self.repo / "bash-ran.txt" + marker.write_text(str(self.payload.get("command", "")), encoding="utf-8") + else: + path = self.payload.get("path") or self.payload.get("file_path") + if path: + target = self.repo / str(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(str(self.payload.get("content", "approved")), encoding="utf-8") + self.event_callback({"type": "tool_finished", "name": self.tool, "input": self.payload, "is_error": False}) + self.executed.set() + return self.result + + +def start_approval_bridge(repo: Path, runner: ApprovalRunner) -> tuple[PiBridge, io.StringIO]: + stdout = io.StringIO() + + def factory(_command: Any, event_callback: Any) -> ApprovalRunner: + runner.event_callback = event_callback + return runner + + bridge = PiBridge(stdout=stdout, runner_factory=factory) + bridge._handle_command({"type": "run", "id": "run-approval", "task": "Fix", "repo": str(repo), "mode": "runner"}) + return bridge, stdout + + +def wait_for_event(bridge: PiBridge, output: io.StringIO, event_type: str, timeout: float = 2) -> dict[str, Any]: + deadline = time.time() + timeout + while time.time() < deadline: + bridge._drain_events() + events = collect_json_lines(output) + for event in events: + if event.get("type") == event_type: + return event + time.sleep(0.01) + raise AssertionError(f"timed out waiting for {event_type}; saw {collect_json_lines(output)}") + + +def finish_bridge(bridge: PiBridge, timeout: float = 2) -> None: + deadline = time.time() + timeout + while bridge._active and time.time() < deadline: + time.sleep(0.01) + bridge._drain_events() + + +def test_approval_required_write_blocks_until_approved(tmp_path: Path) -> None: + runner = ApprovalRunner(tmp_path, "Write", {"path": "src/foo.py", "content": "approved"}) + bridge, stdout = start_approval_bridge(tmp_path, runner) + + approval = wait_for_event(bridge, stdout, "approval_required") + assert approval["id"] == "run-approval" + assert approval["tool"] == "Write" + assert approval["input"] == {"path": "src/foo.py", "content_chars": 8} + assert "src/foo.py" in approval["summary"] + assert not (tmp_path / "src/foo.py").exists() + + bridge._handle_command({"type": "approval_response", "id": "run-approval", "request_id": approval["request_id"], "approved": True}) + finish_bridge(bridge) + + assert runner.executed.is_set() + assert (tmp_path / "src/foo.py").read_text(encoding="utf-8") == "approved" + events = collect_json_lines(stdout) + assert any(event["type"] == "approval_resolved" and event["approved"] is True for event in events) + assert events[-1]["type"] == "run_completed" + assert events[-1]["changed_files"] == ["src/foo.py"] + + +def test_rejected_write_is_blocked(tmp_path: Path) -> None: + runner = ApprovalRunner(tmp_path, "Write", {"path": "blocked.txt", "content": "nope"}) + bridge, stdout = start_approval_bridge(tmp_path, runner) + + approval = wait_for_event(bridge, stdout, "approval_required") + bridge._handle_command({"type": "approval_response", "id": "run-approval", "request_id": approval["request_id"], "approved": False}) + finish_bridge(bridge) + + assert runner.denied.is_set() + assert not runner.executed.is_set() + assert not (tmp_path / "blocked.txt").exists() + events = collect_json_lines(stdout) + assert any(event["type"] == "approval_resolved" and event["approved"] is False for event in events) + assert events[-1]["changed_files"] == [] + + +def test_bash_approval_shows_command_and_blocks_execution(tmp_path: Path) -> None: + command = "pip install package-name" + runner = ApprovalRunner(tmp_path, "Bash", {"command": command}) + bridge, stdout = start_approval_bridge(tmp_path, runner) + + approval = wait_for_event(bridge, stdout, "approval_required") + assert approval["tool"] == "Bash" + assert approval["input"] == {"command": command} + assert command in approval["summary"] + assert not (tmp_path / "bash-ran.txt").exists() + + bridge._handle_command({"type": "approval_response", "id": "run-approval", "request_id": approval["request_id"], "approved": True}) + finish_bridge(bridge) + + assert runner.executed.is_set() + assert (tmp_path / "bash-ran.txt").read_text(encoding="utf-8") == command + + +def test_abort_while_approval_pending_denies_and_aborts(tmp_path: Path) -> None: + runner = ApprovalRunner(tmp_path, "Write", {"path": "abort.txt", "content": "no"}) + bridge, stdout = start_approval_bridge(tmp_path, runner) + + wait_for_event(bridge, stdout, "approval_required") + bridge._handle_command({"type": "abort", "id": "run-approval"}) + finish_bridge(bridge) + + assert runner.denied.is_set() + assert not runner.executed.is_set() + assert not (tmp_path / "abort.txt").exists() + events = collect_json_lines(stdout) + assert any(event["type"] == "abort_requested" for event in events) + assert events[-1]["type"] == "run_aborted" + + +def test_unknown_duplicate_and_malformed_approval_responses_are_safe(tmp_path: Path) -> None: + runner = ApprovalRunner(tmp_path, "Write", {"path": "once.txt", "content": "once"}) + bridge, stdout = start_approval_bridge(tmp_path, runner) + approval = wait_for_event(bridge, stdout, "approval_required") + + bridge._handle_command({"type": "approval_response", "id": "run-approval", "request_id": "missing", "approved": True}) + assert collect_json_lines(stdout)[-1]["type"] == "error" + + bridge._handle_command({"type": "approval_response", "id": "run-approval", "request_id": approval["request_id"], "approved": True}) + bridge._handle_command({"type": "approval_response", "id": "run-approval", "request_id": approval["request_id"], "approved": False}) + finish_bridge(bridge) + + assert (tmp_path / "once.txt").read_text(encoding="utf-8") == "once" + events = collect_json_lines(stdout) + assert sum(1 for event in events if event.get("type") == "approval_resolved" and event.get("request_id") == approval["request_id"]) == 1 + assert any(event["type"] == "error" and "Unknown approval request" in event["error"] for event in events) + + try: + bridge._handle_command({"type": "approval_response", "id": "run-approval", "request_id": "x", "approved": "yes"}) + except ValueError as exc: + bridge.emit({"type": "error", "error": str(exc)}) + assert "requires boolean approved" in collect_json_lines(stdout)[-1]["error"] + + +def test_approval_request_summary_is_bounded() -> None: + summary, safe_input = summarize_approval_request("Write", {"path": "a.py", "content": "x" * 5000}) + assert summary == "Write file: a.py" + assert safe_input == {"path": "a.py", "content_chars": 5000} + + bash_summary, bash_input = summarize_approval_request("Bash", {"command": "x" * 3000}) + assert len(bash_input["command"]) == 2000 + assert bash_input["command"].endswith("…") + assert bash_summary.startswith("Run command: ") diff --git a/tests/test_debug_mode_and_recorder.py b/tests/test_debug_mode_and_recorder.py index 596ebd72..d0f1c000 100644 --- a/tests/test_debug_mode_and_recorder.py +++ b/tests/test_debug_mode_and_recorder.py @@ -130,11 +130,28 @@ def test_mission_state_event_records_turn_index_when_available(tmp_path: Path) - assert mission_event["turn_index"] == 3 -def test_debug_metadata_records_configured_provider_not_internal_format(tmp_path: Path) -> None: +def test_debug_metadata_separates_api_compatibility_from_inference_provider(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VILLANI_INFERENCE_PROVIDER", "lmstudio") recorder = DebugRecorder(build_debug_config("trace", tmp_path), "prov", "obj", tmp_path, "execution", "m", provider="openai") session_meta = json.loads((tmp_path / "prov" / "session_meta.json").read_text(encoding="utf-8")) events = [json.loads(line) for line in (tmp_path / "prov" / "events.jsonl").read_text(encoding="utf-8").splitlines()] run_started = next(e for e in events if e["event_type"] == "run_started") + expected_model_metadata = {"identifier": "m", "inference_provider": "lmstudio", "api_compatibility": "openai"} assert session_meta["provider"] == "openai" + assert session_meta["agent"]["name"] == "villani-code" + assert session_meta["agent"]["version"] + assert session_meta["model_metadata"] == expected_model_metadata assert run_started["payload"]["provider"] == "openai" + assert run_started["payload"]["model_metadata"] == expected_model_metadata + +def test_openai_provider_does_not_imply_lmstudio(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("VILLANI_INFERENCE_PROVIDER", raising=False) + recorder = DebugRecorder(build_debug_config("trace", tmp_path), "prov2", "obj", tmp_path, "execution", "m", provider="openai") + session_meta = json.loads((tmp_path / "prov2" / "session_meta.json").read_text(encoding="utf-8")) + + assert session_meta["model_metadata"] == { + "identifier": "m", + "inference_provider": "openai", + "api_compatibility": "openai", + } \ No newline at end of file diff --git a/tests/test_execution_context_isolation.py b/tests/test_execution_context_isolation.py new file mode 100644 index 00000000..ba4b28fe --- /dev/null +++ b/tests/test_execution_context_isolation.py @@ -0,0 +1,468 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path + +from villani_code.debug_mode import build_debug_config +from villani_code.execution import ExecutionBudget +from villani_code.execution_context import ( + MAX_AGENT_TOOL_RESULT_CHARS, + MAX_COMPACT_RETRY_MEMORY_CHARS, + NO_PROGRESS_MESSAGE, + PRIVATE_WARNING, + TaskExecutionContext, +) +from villani_code.state import Runner +from villani_code.tools import execute_tool + + +class SequenceClient: + def __init__(self, responses: list[dict]) -> None: + self.responses = responses + self.payloads: list[dict] = [] + + def create_message(self, payload, stream=False): + self.payloads.append(payload) + return self.responses.pop(0) + + +def _tool_response(command: str, *, timeout_sec: int = 10, tool_id: str = "tool-1") -> dict: + return { + "id": tool_id, + "role": "assistant", + "content": [ + { + "type": "tool_use", + "id": tool_id, + "name": "Bash", + "input": {"command": command, "cwd": ".", "timeout_sec": timeout_sec}, + } + ], + } + + +def _done_response() -> dict: + return { + "id": "done", + "role": "assistant", + "content": [{"type": "text", "text": "finished"}], + } + + +def _run_dir(debug_root: Path) -> Path: + directories = [path for path in debug_root.iterdir() if path.is_dir()] + assert len(directories) == 1 + return directories[0] + + +def _command_rows(run_dir: Path) -> list[dict]: + return [ + json.loads(line) + for line in (run_dir / "commands.jsonl").read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + +def test_compact_model_command_result_and_full_output_in_artifact(tmp_path: Path) -> None: + debug_root = tmp_path / "debug" + command = "printf '%07000d' 0; printf '%05000d' 0 >&2" + client = SequenceClient([_tool_response(command), _done_response()]) + runner = Runner( + client=client, + repo=tmp_path, + model="test-model", + stream=False, + debug_config=build_debug_config("trace", debug_root), + ) + + result = runner.run("exercise command output") + observation_text = result["transcript"]["tool_results"][0]["content"] + observation = json.loads(observation_text) + + assert len(observation_text) <= MAX_AGENT_TOOL_RESULT_CHARS + assert len(observation["stdout"]) < 7000 + assert len(observation["stderr"]) < 5000 + assert "execution_context" not in observation + + debug_record = _command_rows(_run_dir(debug_root))[0]["full_debug_record"] + assert len(debug_record["stdout"]) == 7000 + assert len(debug_record["stderr"]) == 5000 + assert debug_record["execution_context"]["before"]["environment_hash"] + + +def test_full_fingerprint_is_artifact_only(tmp_path: Path) -> None: + debug_root = tmp_path / "debug" + client = SequenceClient([_tool_response("printf ok"), _done_response()]) + runner = Runner( + client=client, + repo=tmp_path, + model="test-model", + stream=False, + debug_config=build_debug_config("trace", debug_root), + ) + + result = runner.run("run one command") + observation = json.loads(result["transcript"]["tool_results"][0]["content"]) + debug_record = _command_rows(_run_dir(debug_root))[0]["full_debug_record"] + + assert "execution_context" not in observation + assert "environment_hash" not in observation + assert debug_record["execution_context"]["resolved_executables"] + assert debug_record["execution_context"]["before"]["environment_names"] + + +def test_private_paths_are_not_recursively_snapshotted(tmp_path: Path, monkeypatch) -> None: + workspace = tmp_path / "workspace" + private = tmp_path / "runner-private" + workspace.mkdir() + private.mkdir() + for index in range(200): + (private / f"private-{index}").write_text("private", encoding="utf-8") + private_reads: list[Path] = [] + original_read_bytes = Path.read_bytes + + def tracked_read_bytes(path: Path) -> bytes: + if private in path.parents: + private_reads.append(path) + return original_read_bytes(path) + + monkeypatch.setattr(Path, "read_bytes", tracked_read_bytes) + context = TaskExecutionContext(workspace, private_paths=[private]) + proc, record = context.run(f"printf changed > {private / 'state'}", workspace, 10) + context.record_validation(record, kind="command") + + assert proc.returncode == 0 + assert private_reads == [] + assert context.boundaries.classify(private / "state") == "private-runtime" + assert PRIVATE_WARNING in record.warnings + assert record.external_or_private_state_may_have_changed is True + + +def test_debug_root_is_excluded_from_workspace_snapshot(tmp_path: Path, monkeypatch) -> None: + workspace = tmp_path / "workspace" + debug_root = workspace / "debug-artifacts" + workspace.mkdir() + debug_root.mkdir() + for index in range(200): + (debug_root / f"artifact-{index}").write_text("debug", encoding="utf-8") + debug_reads: list[Path] = [] + original_read_bytes = Path.read_bytes + + def tracked_read_bytes(path: Path) -> bytes: + if debug_root in path.parents: + debug_reads.append(path) + return original_read_bytes(path) + + monkeypatch.setattr(Path, "read_bytes", tracked_read_bytes) + context = TaskExecutionContext(workspace, snapshot_excluded_paths=[debug_root]) + context.run("printf ok", workspace, 10) + + assert debug_reads == [] + + +def test_timeout_is_structured_and_attempt_continues(tmp_path: Path) -> None: + client = SequenceClient( + [ + _tool_response("sleep 2", timeout_sec=1, tool_id="timeout"), + _tool_response("printf recovered", tool_id="recovery"), + _done_response(), + ] + ) + runner = Runner(client=client, repo=tmp_path, model="test-model", stream=False) + + result = runner.run( + "recover after timeout", + execution_budget=ExecutionBudget( + max_turns=5, + max_tool_calls=5, + max_seconds=10, + max_no_edit_turns=5, + max_reconsecutive_recon_turns=5, + ), + ) + timeout_observation = json.loads(result["transcript"]["tool_results"][0]["content"]) + + assert timeout_observation["exit_code"] == 124 + assert timeout_observation["timed_out"] is True + assert timeout_observation["message"] == "Command timed out before completion." + assert len(result["execution"]["attempt_state"]["commands"]) == 2 + assert result["execution"]["attempt_state"]["commands"][1]["exit_code"] == 0 + + +def test_absolute_read_outside_workspace_is_rejected_exactly(tmp_path: Path) -> None: + requested = Path(tmp_path.anchor) / "outside-workspace-file" + result = execute_tool("Read", {"file_path": str(requested)}, tmp_path) + + assert result["is_error"] is True + assert result["content"] == ( + "Read is workspace-only for this path. Use a shell command such as cat, sed, or head " + "if you need to inspect system files." + ) + assert not (tmp_path / str(requested).lstrip("/")).exists() + + +def test_repeated_commands_warn_then_force_no_progress(tmp_path: Path) -> None: + context = TaskExecutionContext(tmp_path) + observations: list[dict] = [] + results: list[dict] = [] + for _index in range(4): + result = execute_tool( + "Bash", + {"command": ":", "cwd": ".", "timeout_sec": 5}, + tmp_path, + unsafe=True, + execution_context=context, + ) + results.append(result) + observations.append(json.loads(result["content"])) + + assert observations[2]["next_action"] == NO_PROGRESS_MESSAGE + assert results[2]["force_finalization"] is False + assert results[3]["force_finalization"] is True + + +def test_retry_memory_is_compact_but_full_memory_remains_in_artifact(tmp_path: Path) -> None: + debug_root = tmp_path / "debug" + private = tmp_path / "private" + private.mkdir() + client = SequenceClient([_done_response()]) + runner = Runner( + client=client, + repo=tmp_path, + model="test-model", + stream=False, + private_runtime_paths=[private], + debug_config=build_debug_config("trace", debug_root), + ) + runner._ensure_mission("first attempt") + proc, record = runner._task_execution_context.run(f"printf changed > {private / 'state'}", tmp_path, 5) + assert proc.returncode == 0 + runner._task_execution_context.record_validation(record, kind="smoke") + memory = runner.record_final_validation( + succeeded=False, + summary="final validation failed " + ("detail " * 1000), + believed_succeeded="weak check passed " + ("claim " * 1000), + ) + assert memory is not None + + runner.run("retry") + retry_text = next( + block["text"] + for message in client.payloads[0]["messages"] + for block in message.get("content", []) + if isinstance(block, dict) and "Previous attempt failure summary" in block.get("text", "") + ) + artifact = json.loads((_run_dir(debug_root) / "attempt_state.json").read_text(encoding="utf-8")) + + assert len(retry_text) <= MAX_COMPACT_RETRY_MEMORY_CHARS + assert "files_and_side_effects" not in retry_text + assert artifact["failure_memory"]["files_and_side_effects"] + assert len(artifact["failure_memory"]["believed_succeeded"]) > len(retry_text) + + +def test_isolation_preserved_and_resolved_executable_recorded(tmp_path: Path, monkeypatch) -> None: + workspace = tmp_path / "workspace" + private = tmp_path / "private" + workspace.mkdir() + private.mkdir() + executable = private / "private-command" + executable.write_text("#!/bin/sh\nprintf private", encoding="utf-8") + executable.chmod(0o755) + monkeypatch.setenv("PATH", os.pathsep.join([str(private), os.environ.get("PATH", "")])) + monkeypatch.setenv("VIRTUAL_ENV", str(private)) + context = TaskExecutionContext(workspace, private_paths=[private]) + + proc, record = context.run("printf '%s' \"$PATH\"", workspace, 5) + + assert str(private) not in proc.stdout.split(os.pathsep) + assert str(private) not in record.before.path.split(os.pathsep) + assert "VIRTUAL_ENV" not in record.before.environment_names + assert record.resolved_executables + + +def test_runner_forces_stop_after_second_no_progress_event(tmp_path: Path) -> None: + client = SequenceClient( + [ + _tool_response(":", tool_id="repeat-1"), + _tool_response(":", tool_id="repeat-2"), + _tool_response(":", tool_id="repeat-3"), + _tool_response(":", tool_id="repeat-4"), + ] + ) + runner = Runner(client=client, repo=tmp_path, model="test-model", stream=False) + + result = runner.run( + "avoid repeated work", + execution_budget=ExecutionBudget( + max_turns=8, + max_tool_calls=8, + max_seconds=15, + max_no_edit_turns=8, + max_reconsecutive_recon_turns=8, + ), + ) + + assert result["execution"]["terminated_reason"] == "no_progress" + assert result["execution"]["attempt_state"]["no_progress_events"] == 2 + assert len(client.payloads) == 4 + + +def test_workspace_snapshot_cap_is_recorded(tmp_path: Path, monkeypatch) -> None: + import villani_code.execution_context as execution_context + + for index in range(6): + (tmp_path / f"file-{index}").write_text(str(index), encoding="utf-8") + monkeypatch.setattr(execution_context, "MAX_SNAPSHOT_FILES", 3) + context = TaskExecutionContext(tmp_path) + + _proc, record = context.run("printf ok", tmp_path, 5) + + assert record.snapshot_truncated is True + assert record.to_dict()["snapshot_truncated"] is True + + +def test_runner_policy_does_not_remap_absolute_read_path(tmp_path: Path) -> None: + runner = Runner(client=SequenceClient([]), repo=tmp_path, model="test-model", stream=False) + requested = Path(tmp_path.anchor) / "outside-workspace-file" + + result = runner._execute_tool_with_policy( + "Read", {"file_path": str(requested)}, "read-outside", 0 + ) + + assert result["content"] == ( + "Read is workspace-only for this path. Use a shell command such as cat, sed, or head " + "if you need to inspect system files." + ) + + +def test_repeated_reads_participate_in_no_progress_guard(tmp_path: Path) -> None: + target = tmp_path / "note" + target.write_text("content", encoding="utf-8") + runner = Runner(client=SequenceClient([]), repo=tmp_path, model="test-model", stream=False) + results = [ + runner._execute_tool_with_policy("Read", {"file_path": "note"}, f"read-{index}", 0) + for index in range(4) + ] + + assert NO_PROGRESS_MESSAGE in results[2]["content"] + assert results[3]["force_finalization"] is True + + +def _run_repeated_failures( + tmp_path: Path, monkeypatch, errors: list[str], *, command: str = "run-validation" +) -> tuple[TaskExecutionContext, list[dict]]: + context = TaskExecutionContext(tmp_path) + remaining = iter(errors) + + def fake_run_process(_command, _cwd, _env, _timeout): + return 1, "", next(remaining), False + + monkeypatch.setattr(context, "_run_process", fake_run_process) + results = [ + execute_tool( + "Bash", + {"command": command, "cwd": ".", "timeout_sec": 5}, + tmp_path, + unsafe=True, + execution_context=context, + ) + for _ in errors + ] + return context, results + + +def test_same_failed_command_with_different_traceback_is_progress(tmp_path: Path, monkeypatch) -> None: + errors = [ + f'Traceback (most recent call last):\n File "/tmp/run-{index}/check.py", line {index + 10}, in \n{error}' + for index, error in enumerate( + [ + "ModuleNotFoundError: No module named 'widget_core'", + "AttributeError: module 'widget_core' has no attribute 'build'", + "RuntimeError: backend initialization failed", + "AssertionError: expected 4 results, got 3", + ] + ) + ] + + context, results = _run_repeated_failures(tmp_path, monkeypatch, errors) + + assert all(not result.get("no_progress_warning", False) for result in results) + assert all(not result.get("force_finalization", False) for result in results) + assert len({item.failure_fingerprint for item in context.attempt.validation_evidence}) == 4 + assert context.attempt.no_progress_events == 0 + + +def test_same_failed_command_and_traceback_still_triggers_no_progress( + tmp_path: Path, monkeypatch +) -> None: + error = ( + 'Traceback (most recent call last):\n' + ' File "/tmp/session-a/check.py", line 42, in \n' + "RuntimeError: validation remained broken" + ) + + context, results = _run_repeated_failures(tmp_path, monkeypatch, [error] * 4) + + assert results[2]["no_progress_warning"] is True + assert results[2]["force_finalization"] is False + assert results[3]["force_finalization"] is True + assert context.attempt.no_progress_events == 2 + + +def test_file_mutation_resets_matching_failure_pressure(tmp_path: Path, monkeypatch) -> None: + context = TaskExecutionContext(tmp_path) + error = "RuntimeError: validation remained broken" + real_run_process = context._run_process + + def fake_run_process(command, cwd, env, timeout): + if command == "run-validation": + return 1, "", error, False + return real_run_process(command, cwd, env, timeout) + + monkeypatch.setattr(context, "_run_process", fake_run_process) + first_results = [ + execute_tool( + "Bash", + {"command": "run-validation", "cwd": ".", "timeout_sec": 5}, + tmp_path, + unsafe=True, + execution_context=context, + ) + for _ in range(2) + ] + write_result = execute_tool( + "Bash", + {"command": "printf patched > implementation.py", "cwd": ".", "timeout_sec": 5}, + tmp_path, + unsafe=True, + execution_context=context, + ) + after_write = execute_tool( + "Bash", + {"command": "run-validation", "cwd": ".", "timeout_sec": 5}, + tmp_path, + unsafe=True, + execution_context=context, + ) + + assert all(not result.get("no_progress_warning", False) for result in first_results) + assert write_result["is_error"] is False + assert (tmp_path / "implementation.py").read_text(encoding="utf-8") == "patched" + assert after_write.get("no_progress_warning", False) is False + assert after_write.get("force_finalization", False) is False + assert context.attempt.no_progress_events == 0 + + +def test_failure_fingerprint_normalizes_volatile_traceback_details(tmp_path: Path, monkeypatch) -> None: + errors = [ + 'Traceback (most recent call last):\n File "/tmp/run-a/check.py", line 12, in \nRuntimeError: request 123e4567-e89b-12d3-a456-426614174000 failed at 0x7ffeeabc', + 'Traceback (most recent call last):\n File "/tmp/run-b/check.py", line 987, in \nRuntimeError: request 123e4567-e89b-12d3-a456-426614174111 failed at 0x1234abcd', + 'Traceback (most recent call last):\n File "/tmp/run-c/check.py", line 3, in \nRuntimeError: request 123e4567-e89b-12d3-a456-426614174222 failed at 0x9999aaaa', + ] + + context, results = _run_repeated_failures(tmp_path, monkeypatch, errors) + + fingerprints = [item.failure_fingerprint for item in context.attempt.validation_evidence] + assert len(set(fingerprints)) == 1 + assert results[2]["no_progress_warning"] is True diff --git a/tests/test_loop.py b/tests/test_loop.py index e50a686d..1dd29512 100644 --- a/tests/test_loop.py +++ b/tests/test_loop.py @@ -490,7 +490,7 @@ def _execute_and_set_pending(tool_name, tool_input, tool_use_id, message_count): assert "order-check" in next_user_message["content"][-1]["content"] -def test_loop_does_not_append_duplicate_validation_summary_when_dedup_returns_empty(tmp_path: Path): +def test_loop_does_not_append_routine_validation_summaries_after_commands(tmp_path: Path): client = FakeClientTwoBashThenDone() runner = Runner(client=client, repo=tmp_path, model="m", stream=False) summaries = iter( @@ -518,7 +518,7 @@ def test_loop_does_not_append_duplicate_validation_summary_when_dedup_returns_em if m["role"] == "user" and m["content"] and m["content"][0].get("type") == "tool_result" ) - assert "" in second_tool_result["content"][-1]["content"] + assert "" not in second_tool_result["content"][-1]["content"] assert "" not in third_tool_result["content"][-1]["content"] def test_loop_retries_twice_then_succeeds(tmp_path: Path): diff --git a/tests/test_permissions.py b/tests/test_permissions.py index 369e87d9..ab0938e0 100644 --- a/tests/test_permissions.py +++ b/tests/test_permissions.py @@ -72,3 +72,18 @@ def test_public_target_for_exposes_normalized_target(tmp_path: Path): def test_bash_matches_malformed_input_fails_closed(): assert bash_matches("*", "echo \"unterminated") is False + + +def test_permission_engine_allows_execution_plan_without_weakening_mutations(tmp_path: Path) -> None: + engine = PermissionEngine(PermissionConfig.from_strings(deny=[], ask=["ExecutionPlan(*)", "Write(*)", "Patch(*)"], allow=["BashSafe(*)"]), tmp_path) + + assert engine.evaluate("ExecutionPlan", {"summary": "plan", "risk": "high"}) == Decision.ALLOW + assert engine.evaluate("Write", {"file_path": "a.txt"}) == Decision.ASK + assert engine.evaluate("Patch", {"file_path": "a.txt"}) == Decision.ASK + assert engine.evaluate("Bash", {"command": "pip install x"}) == Decision.ASK + + +def test_execution_plan_still_respects_explicit_deny(tmp_path: Path) -> None: + engine = PermissionEngine(PermissionConfig.from_strings(deny=["ExecutionPlan(*)"], ask=[], allow=[]), tmp_path) + + assert engine.evaluate("ExecutionPlan", {"summary": "plan"}) == Decision.DENY diff --git a/tests/test_regular_trace_artifacts.py b/tests/test_regular_trace_artifacts.py new file mode 100644 index 00000000..b382dbf3 --- /dev/null +++ b/tests/test_regular_trace_artifacts.py @@ -0,0 +1,370 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from types import SimpleNamespace + +from villani_code.benchmark.runtime_config import BenchmarkRuntimeConfig +from villani_code.debug_mode import build_debug_config +from villani_code.state import Runner + + +class _SequenceClient: + def __init__(self, responses: list[dict]): + self._responses = responses + self._idx = 0 + + def create_message(self, payload, stream): + _ = payload, stream + if self._idx >= len(self._responses): + return self._responses[-1] + response = self._responses[self._idx] + self._idx += 1 + return response + + +class _FailingClient: + def create_message(self, payload, stream): + _ = payload, stream + raise RuntimeError("model unavailable") + + +def _seed_repo(repo: Path) -> None: + (repo / "pyproject.toml").write_text("[project]\nname='x'\n", encoding="utf-8") + + +def _run_dir(debug_root: Path) -> Path: + run_dirs = sorted(path for path in debug_root.iterdir() if path.is_dir()) + assert len(run_dirs) == 1 + return run_dirs[0] + + +def _artifact_paths(debug_root: Path) -> tuple[Path, Path]: + run_dir = _run_dir(debug_root) + return run_dir / "transcript.full.json", run_dir / "trajectory.json" + + +def _completed_client(text: str = "done", usage: dict | None = None) -> _SequenceClient: + response = {"id": "final", "role": "assistant", "content": [{"type": "text", "text": text}]} + if usage is not None: + response["usage"] = usage + return _SequenceClient([response]) + + +def _run_regular(tmp_path: Path, debug_root: Path, debug: str = "trace", **kwargs): + _seed_repo(tmp_path) + runner = Runner( + client=kwargs.pop("client", _completed_client()), + repo=tmp_path, + model="model-a", + provider="provider-a", + stream=False, + print_stream=False, + debug_config=build_debug_config(debug, debug_root), + **kwargs, + ) + return runner.run("please answer") + + +def test_regular_trace_debug_writes_full_transcript_and_atif(tmp_path: Path) -> None: + debug_root = tmp_path / "debug" + result = _run_regular(tmp_path, debug_root) + + transcript_path, trajectory_path = _artifact_paths(debug_root) + assert transcript_path.exists() + assert trajectory_path.exists() + full = json.loads(transcript_path.read_text(encoding="utf-8")) + atif = json.loads(trajectory_path.read_text(encoding="utf-8")) + assert full["schema_version"] == "villani-debug-transcript-v1" + assert full["runtime_mode"] == "execution" + assert full["messages"] == result["messages"] + assert full["requests"] == result["transcript"]["requests"] + assert full["responses"] == result["transcript"]["responses"] + assert atif["schema_version"] == "ATIF-v1.7" + + +def test_normal_debug_writes_generic_trajectory_only(tmp_path: Path) -> None: + debug_root = tmp_path / "debug" + _run_regular(tmp_path, debug_root, debug="normal") + + transcript_path, trajectory_path = _artifact_paths(debug_root) + assert not transcript_path.exists() + assert trajectory_path.exists() + atif = json.loads(trajectory_path.read_text(encoding="utf-8")) + assert atif["schema_version"] == "ATIF-v1.7" + assert atif["extra"]["status"] == "completed" + + +def test_openai_compatible_metadata_uses_configured_inference_provider(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("VILLANI_INFERENCE_PROVIDER", "lmstudio") + debug_root = tmp_path / "debug" + _seed_repo(tmp_path) + runner = Runner( + client=_completed_client(), + repo=tmp_path, + model="local-model", + provider="openai", + stream=False, + print_stream=False, + debug_config=build_debug_config("trace", debug_root), + ) + runner.run("please answer") + + run_dir = _run_dir(debug_root) + session_meta = json.loads((run_dir / "session_meta.json").read_text(encoding="utf-8")) + summary = json.loads((run_dir / "final_summary.json").read_text(encoding="utf-8")) + transcript = json.loads((run_dir / "transcript.full.json").read_text(encoding="utf-8")) + trajectory = json.loads((run_dir / "trajectory.json").read_text(encoding="utf-8")) + + expected_model_metadata = { + "identifier": "local-model", + "inference_provider": "lmstudio", + "api_compatibility": "openai", + } + assert session_meta["agent"]["name"] == "villani-code" + assert session_meta["agent"]["version"] + assert session_meta["model_metadata"] == expected_model_metadata + assert summary["agent"] == session_meta["agent"] + assert summary["model_metadata"] == expected_model_metadata + assert transcript["model_metadata"] == expected_model_metadata + assert trajectory["agent"]["extra"]["inference_provider"] == "lmstudio" + assert trajectory["agent"]["extra"]["api_compatibility"] == "openai" + + +def test_regular_trajectory_exists_after_model_request_failure(tmp_path: Path) -> None: + debug_root = tmp_path / "debug" + _seed_repo(tmp_path) + runner = Runner( + client=_FailingClient(), + repo=tmp_path, + model="local-model", + provider="openai", + stream=False, + print_stream=False, + debug_config=build_debug_config("normal", debug_root), + ) + + with pytest.raises(RuntimeError, match="model unavailable"): + runner.run("please answer") + + transcript_path, trajectory_path = _artifact_paths(debug_root) + assert not transcript_path.exists() + assert trajectory_path.exists() + trajectory = json.loads(trajectory_path.read_text(encoding="utf-8")) + assert trajectory["schema_version"] == "ATIF-v1.7" + assert trajectory["extra"]["status"] == "failed" + assert trajectory["extra"]["termination_reason"] == "model_request_failed" + assert [step["source"] for step in trajectory["steps"]] == ["system", "user"] + + +def test_debug_off_writes_neither_new_artifact(tmp_path: Path) -> None: + debug_root = tmp_path / "debug" + _run_regular(tmp_path, debug_root, debug="off") + + assert not debug_root.exists() + + +def test_trace_debug_small_model_writes_neither_new_artifact(tmp_path: Path) -> None: + debug_root = tmp_path / "debug" + _run_regular(tmp_path, debug_root, small_model=True) + + transcript_path, trajectory_path = _artifact_paths(debug_root) + assert not transcript_path.exists() + assert not trajectory_path.exists() + + +def test_trace_debug_villani_mode_writes_neither_new_artifact(tmp_path: Path) -> None: + debug_root = tmp_path / "debug" + _run_regular(tmp_path, debug_root, villani_mode=True) + + transcript_path, trajectory_path = _artifact_paths(debug_root) + assert not transcript_path.exists() + assert not trajectory_path.exists() + + +def test_trace_debug_benchmark_enabled_writes_neither_new_artifact(tmp_path: Path) -> None: + debug_root = tmp_path / "debug" + _run_regular(tmp_path, debug_root, benchmark_config=BenchmarkRuntimeConfig(enabled=True, task_id="t1")) + + transcript_path, trajectory_path = _artifact_paths(debug_root) + assert not transcript_path.exists() + assert not trajectory_path.exists() + + +def test_trace_debug_planning_only_writes_neither_new_artifact(tmp_path: Path) -> None: + debug_root = tmp_path / "debug" + _seed_repo(tmp_path) + runner = Runner( + client=_completed_client(), + repo=tmp_path, + model="model-a", + stream=False, + print_stream=False, + debug_config=build_debug_config("trace", debug_root), + ) + runner._planning_read_only = True + runner._runtime_mode = "planning" + runner.run("make a plan") + + transcript_path, trajectory_path = _artifact_paths(debug_root) + assert not transcript_path.exists() + assert not trajectory_path.exists() + + +def test_atif_root_fields_ordered_steps_and_token_metrics(tmp_path: Path) -> None: + debug_root = tmp_path / "debug" + client = _SequenceClient( + [ + { + "id": "r1", + "role": "assistant", + "content": [{"type": "text", "text": "done"}], + "usage": {"input_tokens": 11, "output_tokens": 7, "total_tokens": 18}, + } + ] + ) + _run_regular(tmp_path, debug_root, client=client) + + atif = json.loads(_artifact_paths(debug_root)[1].read_text(encoding="utf-8")) + assert atif["schema_version"] == "ATIF-v1.7" + assert atif["session_id"] == atif["trajectory_id"] + assert atif["agent"]["name"] == "villani-code" + assert atif["agent"]["model_name"] == "model-a" + assert atif["agent"]["extra"] == { + "provider": "provider-a", + "inference_provider": "provider-a", + "api_compatibility": None, + "mode": "regular", + } + assert "steps" in atif + assert [step["step_id"] for step in atif["steps"]] == list(range(1, len(atif["steps"]) + 1)) + assert [step["source"] for step in atif["steps"]] == ["system", "user", "agent"] + for step in atif["steps"]: + assert step["source"] in {"system", "user", "agent"} + assert step["message"] is not None + assert "type" not in step + assert "role" not in step + agent_step = atif["steps"][-1] + assert agent_step["llm_call_count"] == 1 + assert agent_step["metrics"]["prompt_tokens"] == 11 + assert agent_step["metrics"]["completion_tokens"] == 7 + assert "input_tokens" not in agent_step["metrics"] + assert "output_tokens" not in agent_step["metrics"] + assert "total_tokens" not in agent_step["metrics"] + assert atif["final_metrics"]["total_prompt_tokens"] == 11 + assert atif["final_metrics"]["total_completion_tokens"] == 7 + assert atif["final_metrics"]["total_steps"] == len(atif["steps"]) + assert "input_tokens" not in atif["final_metrics"] + assert "output_tokens" not in atif["final_metrics"] + assert "prompt_tokens" not in atif["final_metrics"] + assert "completion_tokens" not in atif["final_metrics"] + assert "total_tokens" not in atif["final_metrics"] + assert atif["extra"]["status"] == "completed" + assert atif["extra"]["transcript_artifact"] == "transcript.full.json" + + full = json.loads(_artifact_paths(debug_root)[0].read_text(encoding="utf-8")) + assert full["model"] == "model-a" + assert full["provider"] == "provider-a" + assert full["model_metadata"] == {"identifier": "model-a", "inference_provider": "provider-a"} + assert full["responses"][0]["usage"]["input_tokens"] == 11 + assert full["responses"][0]["usage"]["output_tokens"] == 7 + assert full["responses"][0]["usage"]["total_tokens"] == 18 + + +def test_tool_calls_and_results_are_linked_by_call_id_without_duplicate_cumulative_steps(tmp_path: Path) -> None: + debug_root = tmp_path / "debug" + client = _SequenceClient( + [ + { + "id": "r1", + "role": "assistant", + "content": [ + {"type": "text", "text": "checking"}, + {"type": "tool_use", "id": "tool-1", "name": "Read", "input": {"file_path": "pyproject.toml"}}, + ], + }, + {"id": "r2", "role": "assistant", "content": [{"type": "text", "text": "done"}]}, + ] + ) + _run_regular(tmp_path, debug_root, client=client) + + atif = json.loads(_artifact_paths(debug_root)[1].read_text(encoding="utf-8")) + assert [step["step_id"] for step in atif["steps"]] == list(range(1, len(atif["steps"]) + 1)) + assert all("type" not in step and "role" not in step for step in atif["steps"]) + agent_steps = [step for step in atif["steps"] if step["source"] == "agent"] + assert len(agent_steps) == 2 + assert len([step for step in atif["steps"] if step["source"] == "user"]) == 1 + assert all(step["llm_call_count"] == 1 for step in agent_steps) + assert agent_steps[0]["tool_calls"] == [ + {"tool_call_id": "tool-1", "function_name": "Read", "arguments": {"file_path": "pyproject.toml"}} + ] + assert "id" not in agent_steps[0]["tool_calls"][0] + assert "name" not in agent_steps[0]["tool_calls"][0] + results = agent_steps[0]["observation"]["results"] + assert results[0]["source_call_id"] == "tool-1" + assert "[project]" in results[0]["content"] + assert "result" not in results[0] + assert "observations" not in agent_steps[0] + + +def test_redaction_applies_to_both_new_artifacts(tmp_path: Path) -> None: + debug_root = tmp_path / "debug" + client = _SequenceClient( + [ + { + "id": "r1", + "role": "assistant", + "content": [{"type": "tool_use", "id": "tool-1", "name": "Read", "input": {"file_path": "pyproject.toml"}}], + }, + {"id": "r2", "role": "assistant", "content": [{"type": "text", "text": "done"}]}, + ] + ) + _run_regular(tmp_path, debug_root, client=client, redact=True) + + full_text = _artifact_paths(debug_root)[0].read_text(encoding="utf-8") + atif_text = _artifact_paths(debug_root)[1].read_text(encoding="utf-8") + atif = json.loads(atif_text) + first_agent_step = next(step for step in atif["steps"] if step["source"] == "agent") + assert first_agent_step["message"] == "" + assert "[REDACTED_TOOL_RESULT_CONTENT]" in full_text + assert "[project]" not in full_text + assert "[REDACTED_TOOL_RESULT_CONTENT]" in atif_text + assert "[project]" not in atif_text + + +def test_export_errors_are_non_fatal(monkeypatch, tmp_path: Path) -> None: + debug_root = tmp_path / "debug" + + def boom(**_kwargs): + raise RuntimeError("export failed") + + monkeypatch.setattr("villani_code.debug_recorder.build_full_transcript_artifact", boom) + result = _run_regular(tmp_path, debug_root) + + assert result["response"]["content"][0]["text"] == "done" + transcript_path, trajectory_path = _artifact_paths(debug_root) + assert not transcript_path.exists() + assert trajectory_path.exists() + assert "export failed" in (_run_dir(debug_root) / "stderr.log").read_text(encoding="utf-8") + + +def test_execution_result_unchanged_except_new_files(tmp_path: Path) -> None: + trace_root = tmp_path / "trace-debug" + normal_root = tmp_path / "normal-debug" + trace_repo = tmp_path / "trace-repo" + normal_repo = tmp_path / "normal-repo" + trace_repo.mkdir() + normal_repo.mkdir() + + trace_result = _run_regular(trace_repo, trace_root, debug="trace", client=_completed_client("same")) + normal_result = _run_regular(normal_repo, normal_root, debug="normal", client=_completed_client("same")) + + assert trace_result["response"] == normal_result["response"] + assert trace_result["transcript"]["responses"] == normal_result["transcript"]["responses"] + trace_messages = json.loads(json.dumps(trace_result["messages"]).replace(str(trace_repo), "")) + normal_messages = json.loads(json.dumps(normal_result["messages"]).replace(str(normal_repo), "")) + assert trace_messages == normal_messages + assert _artifact_paths(trace_root)[0].exists() + assert not _artifact_paths(normal_root)[0].exists() diff --git a/tests/test_runner_approvals.py b/tests/test_runner_approvals.py index 6e5649b4..e446a348 100644 --- a/tests/test_runner_approvals.py +++ b/tests/test_runner_approvals.py @@ -49,3 +49,40 @@ def test_runner_approved_ask_runs_tool(tmp_path: Path) -> None: tool_result = next(m for m in result["messages"] if m["role"] == "user" and m["content"][0].get("type") == "tool_result") assert tool_result["content"][0]["is_error"] is False assert "a.txt" in tool_result["content"][0]["content"] + + +def _protected_runner(tmp_path: Path, approved: bool = False) -> tuple[Runner, list[dict]]: + events: list[dict] = [] + runner = Runner(client=AskToolClient(), repo=tmp_path, model="m", stream=False) + runner.event_callback = events.append + runner.approval_callback = lambda _tool, _payload: approved + return runner, events + + +def test_write_still_emits_approval_required(tmp_path: Path) -> None: + runner, events = _protected_runner(tmp_path) + + result = runner._execute_tool_with_policy("Write", {"file_path": "a.txt", "content": "x"}, "toolu_1", 0) + + assert result["is_error"] is True + assert any(event.get("type") == "approval_required" and event.get("name") == "Write" for event in events) + + +def test_patch_still_emits_approval_required(tmp_path: Path) -> None: + (tmp_path / "a.txt").write_text("old\n", encoding="utf-8") + runner, events = _protected_runner(tmp_path) + diff = "--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n" + + result = runner._execute_tool_with_policy("Patch", {"file_path": "a.txt", "diff": diff}, "toolu_1", 0) + + assert result["is_error"] is True + assert any(event.get("type") == "approval_required" and event.get("name") == "Patch" for event in events) + + +def test_unsafe_bash_still_emits_approval_required(tmp_path: Path) -> None: + runner, events = _protected_runner(tmp_path) + + result = runner._execute_tool_with_policy("Bash", {"command": "pip install package-name"}, "toolu_1", 0) + + assert result["is_error"] is True + assert any(event.get("type") == "approval_required" and event.get("name") == "Bash" for event in events) diff --git a/tests/test_state_runtime.py b/tests/test_state_runtime.py index 417f52b1..6ae6ab56 100644 --- a/tests/test_state_runtime.py +++ b/tests/test_state_runtime.py @@ -39,19 +39,25 @@ def deny_if_called(_name: str, _payload: dict) -> bool: assert "plan_approval_required" not in event_types -def test_non_villani_high_risk_plan_rejection_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: +def test_execution_plan_allowed_without_approval_callback(tmp_path: Path) -> None: _seed_repo(tmp_path) runner = Runner(client=DummyClient(), repo=tmp_path, model="m", stream=False, villani_mode=False) - asked = {"count": 0} + events: list[dict] = [] + runner.event_callback = events.append + + def fail_if_called(_name: str, _payload: dict) -> bool: + raise AssertionError("ExecutionPlan must be allowed by policy without prompting") + + runner.approval_callback = fail_if_called - def reject(_name: str, _payload: dict) -> bool: - asked["count"] += 1 - return False + state_runtime.ensure_project_memory_and_plan(runner, "delete files and rewrite history") - runner.approval_callback = reject - with pytest.raises(RuntimeError, match="Execution plan rejected"): - state_runtime.ensure_project_memory_and_plan(runner, "delete files and rewrite history") - assert asked["count"] == 1 + event_types = [e.get("type") for e in events] + assert "plan_auto_approved" in event_types + assert "plan_approval_required" not in event_types + policy_events = [e for e in events if e.get("type") == "policy_decision" and e.get("name") == "ExecutionPlan"] + assert policy_events + assert policy_events[-1]["decision"] == "allow" class _RetrieverHit: diff --git a/tests/test_validation_evidence_hygiene.py b/tests/test_validation_evidence_hygiene.py new file mode 100644 index 00000000..bc8bf9e0 --- /dev/null +++ b/tests/test_validation_evidence_hygiene.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +from villani_code.execution_context import ( + UNRESOLVED_VALIDATION_MESSAGE, + VALIDATION_DRIFT_MESSAGE, + TaskExecutionContext, + is_weakened_validation_command, +) + + +def _run_validation(context: TaskExecutionContext, workspace: Path, command: str): + _completed, record = context.run(command, workspace, 10) + evidence = context.record_validation(record, kind="command") + return record, evidence + + +def test_shell_executor_uses_pipefail_when_bash_is_available(tmp_path: Path) -> None: + if shutil.which("bash") is None: + pytest.skip("bash is not available") + context = TaskExecutionContext(tmp_path) + context.begin_attempt() + + completed, _record = context.run("false | tail", tmp_path, 10) + + assert completed.returncode != 0 + + +@pytest.mark.parametrize( + "command", + ["cmd | head", "cmd | tail", "cmd | grep value", "cmd || true"], +) +def test_filtered_commands_are_marked_as_weak_validation(command: str) -> None: + assert is_weakened_validation_command(command) + + +def test_failed_validation_is_retained_at_finalization(tmp_path: Path) -> None: + context = TaskExecutionContext(tmp_path) + context.begin_attempt() + + record, _evidence = _run_validation(context, tmp_path, "test -e missing-file") + context.finish_attempt() + warnings = context.attempt.finalization_warnings() + + assert record.exit_code != 0 + assert len(context.attempt.unresolved_validation_failures) == 1 + assert UNRESOLVED_VALIDATION_MESSAGE in warnings[0] + assert "test -e missing-file" in warnings[0] + + +def test_successful_validation_after_file_change_clears_failure(tmp_path: Path) -> None: + context = TaskExecutionContext(tmp_path) + context.begin_attempt() + _run_validation(context, tmp_path, "test -e marker") + + (tmp_path / "marker").write_text("ready", encoding="utf-8") + record, _evidence = _run_validation(context, tmp_path, "test -e marker") + + assert record.exit_code == 0 + assert context.attempt.unresolved_validation_failures == [] + + +def test_file_change_after_successful_validation_warns_at_finalization(tmp_path: Path) -> None: + marker = tmp_path / "marker" + marker.write_text("ready", encoding="utf-8") + context = TaskExecutionContext(tmp_path) + context.begin_attempt() + record, _evidence = _run_validation(context, tmp_path, "test -e marker") + assert record.exit_code == 0 + + marker.write_text("changed", encoding="utf-8") + context.finish_attempt() + + assert VALIDATION_DRIFT_MESSAGE in context.attempt.finalization_warnings() diff --git a/villani_code/atif_export.py b/villani_code/atif_export.py new file mode 100644 index 00000000..8e8309bf --- /dev/null +++ b/villani_code/atif_export.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +import copy +import json +from typing import Any +import os + +from villani_code.trace_summary import normalize_token_usage +from villani_code.transcripts import maybe_redact_payload + +_REDACTED_TOOL_RESULT_CONTENT = "[REDACTED_TOOL_RESULT_CONTENT]" + + +def _deepcopy_jsonable(value: Any) -> Any: + return copy.deepcopy(value) + + +def _redact_tool_result_value(value: Any) -> Any: + if isinstance(value, dict): + redacted = {k: _redact_tool_result_value(v) for k, v in value.items()} + if "content" in redacted: + redacted["content"] = _REDACTED_TOOL_RESULT_CONTENT + if "result" in redacted: + redacted["result"] = _REDACTED_TOOL_RESULT_CONTENT + return redacted + if isinstance(value, list): + return [_redact_tool_result_value(v) for v in value] + return value + + +def _redact_messages(messages: list[dict[str, Any]], redact: bool) -> list[dict[str, Any]]: + payload = maybe_redact_payload({"messages": _deepcopy_jsonable(messages)}, redact) + redacted_messages = payload.get("messages", []) + return redacted_messages if isinstance(redacted_messages, list) else [] + + +def _redact_requests(requests: list[dict[str, Any]], redact: bool) -> list[dict[str, Any]]: + if not redact: + return _deepcopy_jsonable(requests) + return [maybe_redact_payload(_deepcopy_jsonable(request), True) for request in requests] + + +def _redact_tool_results(results: list[Any], redact: bool) -> list[Any]: + copied = _deepcopy_jsonable(results) + if not redact: + return copied + return [_redact_tool_result_value(result) for result in copied] + + +def _content_text(content: Any) -> str | None: + if isinstance(content, str): + return content + if not isinstance(content, list): + return None + parts = [str(block.get("text", "")) for block in content if isinstance(block, dict) and block.get("type") == "text"] + text = "\n".join(part for part in parts if part) + return text if text else None + + +def _tool_calls_from_content(content: Any) -> list[dict[str, Any]]: + if not isinstance(content, list): + return [] + calls: list[dict[str, Any]] = [] + for block in content: + if not isinstance(block, dict) or block.get("type") != "tool_use": + continue + arguments = block.get("input", {}) + calls.append( + { + "tool_call_id": block.get("id"), + "function_name": block.get("name"), + "arguments": _deepcopy_jsonable(arguments) if isinstance(arguments, dict) else {}, + } + ) + return calls + + +def _tool_result_call_id(result: Any) -> str | None: + if isinstance(result, dict): + for key in ("tool_use_id", "tool_call_id", "id"): + value = result.get(key) + if value is not None: + return str(value) + return None + + +def _tool_results_with_call_ids(transcript: dict[str, Any]) -> list[Any]: + raw_results = transcript.get("tool_results", []) if isinstance(transcript.get("tool_results"), list) else [] + invocations = transcript.get("tool_invocations", []) if isinstance(transcript.get("tool_invocations"), list) else [] + enriched: list[Any] = [] + for idx, result in enumerate(raw_results): + copied = _deepcopy_jsonable(result) + if isinstance(copied, dict) and _tool_result_call_id(copied) is None and idx < len(invocations) and isinstance(invocations[idx], dict): + call_id = invocations[idx].get("id") or invocations[idx].get("tool_call_id") or invocations[idx].get("tool_use_id") + if call_id is not None: + copied["tool_call_id"] = str(call_id) + enriched.append(copied) + return enriched + + +def _atif_content(value: Any) -> str: + if isinstance(value, str): + return value + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + + +def _observation_for_tool_calls(tool_call_ids: set[str], tool_results: list[Any], redact: bool) -> dict[str, Any] | None: + results: list[dict[str, Any]] = [] + for result in tool_results: + call_id = _tool_result_call_id(result) + if call_id is None or call_id not in tool_call_ids: + continue + payload = _redact_tool_result_value(_deepcopy_jsonable(result)) if redact else _deepcopy_jsonable(result) + content = payload.get("content") if isinstance(payload, dict) and "content" in payload else payload + results.append({"source_call_id": call_id, "content": _atif_content(content)}) + if not results: + return None + return {"results": results} + + +def _is_tool_result_only_message(message: dict[str, Any]) -> bool: + content = message.get("content") + if not isinstance(content, list) or not content: + return False + return all(isinstance(block, dict) and block.get("type") == "tool_result" for block in content) + + +def _new_messages_since(previous: list[dict[str, Any]], current: list[dict[str, Any]]) -> list[dict[str, Any]]: + if len(current) >= len(previous) and current[: len(previous)] == previous: + return current[len(previous) :] + return current + + +def _api_compatibility(provider: str | None) -> str | None: + normalized = (provider or "").strip().lower() + return "openai" if normalized == "openai" else None + + +def _inference_provider(provider: str | None) -> str | None: + configured = os.environ.get("VILLANI_INFERENCE_PROVIDER") + if configured and configured.strip(): + return configured.strip() + return provider + + +def _model_metadata(model: str | None, provider: str | None) -> dict[str, Any]: + metadata: dict[str, Any] = {"identifier": model, "inference_provider": _inference_provider(provider)} + api_compatibility = _api_compatibility(provider) + if api_compatibility is not None: + metadata["api_compatibility"] = api_compatibility + return metadata + + +def build_full_transcript_artifact( + *, + run_id: str, + model: str | None, + provider: str | None, + transcript: dict[str, Any], + messages: list[dict[str, Any]], + status: str, + termination_reason: str | None, + redact: bool, +) -> dict[str, Any]: + requests = transcript.get("requests", []) if isinstance(transcript.get("requests"), list) else [] + responses = transcript.get("responses", []) if isinstance(transcript.get("responses"), list) else [] + tool_invocations = transcript.get("tool_invocations", []) if isinstance(transcript.get("tool_invocations"), list) else [] + tool_results = _tool_results_with_call_ids(transcript) + return { + "schema_version": "villani-debug-transcript-v1", + "run_id": run_id, + "runtime_mode": "execution", + "model": model, + "provider": provider, + "model_metadata": _model_metadata(model, provider), + "status": status, + "termination_reason": termination_reason, + "messages": _redact_messages(messages, redact), + "requests": _redact_requests(requests, redact), + "responses": _deepcopy_jsonable(responses), + "tool_invocations": _deepcopy_jsonable(tool_invocations), + "tool_results": _redact_tool_results(tool_results, redact), + "streamed_events_count": transcript.get("streamed_events_count", 0), + } + + +def build_atif_trajectory( + *, + run_id: str, + agent_version: str, + model: str | None, + provider: str | None, + transcript: dict[str, Any], + messages: list[dict[str, Any]], + status: str, + termination_reason: str | None, + redact: bool, +) -> dict[str, Any]: + del messages # ATIF dialogue steps are derived from captured request/response pairs. + requests = transcript.get("requests", []) if isinstance(transcript.get("requests"), list) else [] + responses = transcript.get("responses", []) if isinstance(transcript.get("responses"), list) else [] + tool_results = _tool_results_with_call_ids(transcript) + steps: list[dict[str, Any]] = [] + + previous_request_messages: list[dict[str, Any]] = [] + system_emitted = False + total_prompt_tokens = 0 + total_completion_tokens = 0 + saw_prompt_tokens = False + saw_completion_tokens = False + + def append_step(step: dict[str, Any]) -> None: + step["step_id"] = len(steps) + 1 + steps.append(step) + + def append_request_steps(request: dict[str, Any]) -> None: + nonlocal previous_request_messages, system_emitted + system_prompt = request.get("system") + if not system_emitted and system_prompt: + append_step({"source": "system", "message": str(system_prompt)}) + system_emitted = True + + current_messages = request.get("messages", []) if isinstance(request.get("messages"), list) else [] + for message in _new_messages_since(previous_request_messages, current_messages): + if not isinstance(message, dict) or _is_tool_result_only_message(message): + continue + role = str(message.get("role", "user")) + if role == "assistant": + continue + source = role if role in {"system", "user"} else "user" + content = _redact_messages([message], redact)[0].get("content") if redact else _deepcopy_jsonable(message.get("content")) + text = _content_text(content) + append_step({"source": source, "message": text if text is not None else _atif_content(content)}) + previous_request_messages = _deepcopy_jsonable(current_messages) + + for idx, response in enumerate(responses): + request = requests[idx] if idx < len(requests) and isinstance(requests[idx], dict) else {} + append_request_steps(request) + + response_content = response.get("content", []) if isinstance(response, dict) else [] + tool_calls = _tool_calls_from_content(response_content) + usage = normalize_token_usage(response if isinstance(response, dict) else {}) + metrics: dict[str, Any] = {} + if usage.get("tokens_input") is not None: + metrics["prompt_tokens"] = usage.get("tokens_input") + total_prompt_tokens += int(usage["tokens_input"] or 0) + saw_prompt_tokens = True + if usage.get("tokens_output") is not None: + metrics["completion_tokens"] = usage.get("tokens_output") + total_completion_tokens += int(usage["tokens_output"] or 0) + saw_completion_tokens = True + + agent_step: dict[str, Any] = { + "source": "agent", + "message": _content_text(response_content) or "", + "llm_call_count": 1, + } + if metrics: + agent_step["metrics"] = metrics + if tool_calls: + agent_step["tool_calls"] = tool_calls + observation = _observation_for_tool_calls( + {str(call.get("tool_call_id")) for call in tool_calls if call.get("tool_call_id") is not None}, tool_results, redact + ) + if observation is not None: + agent_step["observation"] = observation + append_step(agent_step) + + if len(requests) > len(responses): + request = requests[len(responses)] if isinstance(requests[len(responses)], dict) else {} + append_request_steps(request) + + final_metrics: dict[str, Any] = {"total_steps": len(steps)} + if saw_prompt_tokens: + final_metrics["total_prompt_tokens"] = total_prompt_tokens + if saw_completion_tokens: + final_metrics["total_completion_tokens"] = total_completion_tokens + + return { + "schema_version": "ATIF-v1.7", + "session_id": run_id, + "trajectory_id": run_id, + "agent": { + "name": "villani-code", + "version": agent_version, + "model_name": model, + "extra": { + "provider": provider, + "inference_provider": _inference_provider(provider), + "api_compatibility": _api_compatibility(provider), + "mode": "regular", + }, + }, + "steps": steps, + "final_metrics": final_metrics, + "extra": { + "status": status, + "termination_reason": termination_reason, + "transcript_artifact": "transcript.full.json", + }, + } diff --git a/villani_code/autonomous.py b/villani_code/autonomous.py index 706f2e48..affadc73 100644 --- a/villani_code/autonomous.py +++ b/villani_code/autonomous.py @@ -327,6 +327,13 @@ def run(self) -> dict[str, Any]: ) task.status, task.outcome = self._adjudicate_task(task, verification) task.status = self._update_lifecycle_after_attempt(task, op) + record_final_validation = getattr(self.runner, "record_final_validation", None) + if callable(record_final_validation): + record_final_validation( + succeeded=task.status == TaskLifecycle.PASSED.value, + summary=verification.summary, + believed_succeeded=task.outcome, + ) self._update_category_attempt_state(task) if task.status == TaskLifecycle.PASSED.value: retired += 1 diff --git a/villani_code/cli.py b/villani_code/cli.py index b5634b3e..4257eca3 100644 --- a/villani_code/cli.py +++ b/villani_code/cli.py @@ -328,6 +328,17 @@ def context_cmd( console.print(f"- {item.source_id} excluded={item.excluded_reason.value if item.excluded_reason else '-'} why={item.why}") +@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("checkpoint") def checkpoint_cmd( task_summary: str = typer.Argument("manual checkpoint"), diff --git a/villani_code/debug_recorder.py b/villani_code/debug_recorder.py index 0628962f..4654ddb6 100644 --- a/villani_code/debug_recorder.py +++ b/villani_code/debug_recorder.py @@ -6,7 +6,10 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any +import os +from villani_code import __version__ +from villani_code.atif_export import build_atif_trajectory, build_full_transcript_artifact from villani_code.debug_artifacts import DEBUG_JSONL_FILES, append_jsonl, append_text, create_debug_run_artifacts, write_json from villani_code.debug_mode import DebugConfig from villani_code.trace_summary import ( @@ -20,6 +23,30 @@ _RESULT_PREVIEW_LIMIT = 240 +def _api_compatibility(provider: str | None) -> str | None: + normalized = (provider or "").strip().lower() + return "openai" if normalized == "openai" else None + + +def _inference_provider(provider: str | None) -> str | None: + configured = os.environ.get("VILLANI_INFERENCE_PROVIDER") + if configured and configured.strip(): + return configured.strip() + return provider + + +def _model_metadata(model: str | None, provider: str | None) -> dict[str, Any]: + metadata: dict[str, Any] = {"identifier": model, "inference_provider": _inference_provider(provider)} + api_compatibility = _api_compatibility(provider) + if api_compatibility is not None: + metadata["api_compatibility"] = api_compatibility + return metadata + + +def _agent_metadata() -> dict[str, str]: + return {"name": "villani-code", "version": __version__} + + class DebugRecorder: def __init__(self, config: DebugConfig, run_id: str, objective: str, repo: Path, mode: str, model: str, provider: str | None = None): self.config = config @@ -50,10 +77,22 @@ def __init__(self, config: DebugConfig, run_id: str, objective: str, repo: Path, "runtime_mode": mode, "model": model, "provider": provider, + "agent": _agent_metadata(), + "model_metadata": _model_metadata(model, provider), "created_at": self._ts(), }, ) - self._emit("run_started", {"objective": objective, "runtime_mode": mode, "model": model, "provider": provider}) + self._emit( + "run_started", + { + "objective": objective, + "runtime_mode": mode, + "model": model, + "provider": provider, + "agent": _agent_metadata(), + "model_metadata": _model_metadata(model, provider), + }, + ) def _normalize_changed_path(self, file_path: str) -> str: return normalize_repo_path(file_path, Path(self._repo)) @@ -259,6 +298,7 @@ def record_command_finish( truncated: bool = False, tool_call_id: str = "", turn_index: int | None = None, + full_debug_record: dict[str, Any] | None = None, ) -> None: payload = { "ts": self._ts(), @@ -270,6 +310,12 @@ def record_command_finish( "truncated": truncated, "tool_call_id": tool_call_id, } + if isinstance(full_debug_record, dict): + debug_record = copy.deepcopy(full_debug_record) + if not self.config.capture_command_output: + debug_record["stdout"] = str(debug_record.get("stdout", ""))[:240] + debug_record["stderr"] = str(debug_record.get("stderr", ""))[:240] + payload["full_debug_record"] = debug_record self._safe_append_jsonl("commands", payload) self.record_event("command_finished", f"Command finished: {command}", payload, turn_index=turn_index) if exit_code != 0: @@ -381,6 +427,79 @@ def write_prompt_rendered(self, text: str) -> None: def write_working_context(self, text: str) -> None: self._safe(append_text, self.artifacts.path("working_context.txt"), text) + def write_regular_trajectory_artifact( + self, + *, + transcript: dict[str, Any], + messages: list[dict[str, Any]], + status: str, + termination_reason: str | None, + redact: bool, + ) -> None: + def _write() -> None: + trajectory = build_atif_trajectory( + run_id=self.run_id, + agent_version=__version__, + model=self._model, + provider=self._provider, + transcript=transcript, + messages=messages, + status=status, + termination_reason=termination_reason, + redact=redact, + ) + write_json(self.artifacts.path("trajectory.json"), trajectory) + + self._safe(_write) + + def write_regular_trace_artifacts( + self, + *, + transcript: dict[str, Any], + messages: list[dict[str, Any]], + status: str, + termination_reason: str | None, + redact: bool, + ) -> None: + def _write() -> None: + transcript_artifact = build_full_transcript_artifact( + run_id=self.run_id, + model=self._model, + provider=self._provider, + transcript=transcript, + messages=messages, + status=status, + termination_reason=termination_reason, + redact=redact, + ) + trajectory = build_atif_trajectory( + run_id=self.run_id, + agent_version=__version__, + model=self._model, + provider=self._provider, + transcript=transcript, + messages=messages, + status=status, + termination_reason=termination_reason, + redact=redact, + ) + write_json(self.artifacts.path("transcript.full.json"), transcript_artifact) + write_json(self.artifacts.path("trajectory.json"), trajectory) + + self._safe(_write) + + def write_attempt_state( + self, + attempt_state: dict[str, Any], + failure_memory: dict[str, Any] | None = None, + ) -> None: + payload = {"attempt_state": attempt_state, "failure_memory": failure_memory} + self._safe_write_json(self.artifacts.path("attempt_state.json"), payload) + for command in attempt_state.get("commands", []): + self._safe_append_jsonl("commands", {"ts": self._ts(), "execution_context": command}) + for warning in attempt_state.get("warnings", []): + self.record_event("private_runtime_contamination", str(warning), {"warning": warning}) + def write_final_summary(self, *, status: str, termination_reason: str, total_turns: int, mission_id: str = "") -> Path: if status == "completed": self._emit("run_completed", {"termination_reason": termination_reason, "mission_id": mission_id, "total_turns": total_turns}) diff --git a/villani_code/execution.py b/villani_code/execution.py index e16eaadf..ecdda31e 100644 --- a/villani_code/execution.py +++ b/villani_code/execution.py @@ -29,6 +29,8 @@ class ExecutionResult: runner_failures: list[str] terminated_reason: str completed: bool + attempt_state: dict[str, object] + failure_memory: dict[str, object] | None def to_dict(self) -> dict[str, object]: return asdict(self) diff --git a/villani_code/execution_context.py b/villani_code/execution_context.py new file mode 100644 index 00000000..01a65b75 --- /dev/null +++ b/villani_code/execution_context.py @@ -0,0 +1,1159 @@ +from __future__ import annotations + +import getpass +import hashlib +import json +import os +import re +import shlex +import shutil +import signal +import stat +import subprocess +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Iterable, Mapping + +PRIVATE_WARNING = ( + "Warning: this command modified or depended on runner-private state rather than only " + "task/workspace state. Success evidence from this context may not reflect final validation." +) +TIMEOUT_MESSAGE = "Command timed out before completion." +WEAK_VALIDATION_MESSAGE = ( + "Validation evidence is weak because the command output or exit status may be filtered/masked." +) +UNRESOLVED_VALIDATION_MESSAGE = ( + "Unresolved failed validation remains. A previous check failed and has not been cleared by a " + "later successful equivalent or broader check." +) +VALIDATION_DRIFT_MESSAGE = ( + "Files changed after the last successful validation. Re-run validation or justify finalizing " + "with weaker evidence." +) + +_VALIDATION_INTENT_RE = re.compile( + r"(?i)(?:^|[^a-z0-9_])(test|pytest|make|check|build|eval|verify|validate|compare|diff|compile|run)(?:$|[^a-z0-9_])" +) +_WEAK_VALIDATION_RE = re.compile( + r"(?i)(?:\|\s*(?:head|tail|grep|awk|sed)\b|\bgrep\s+-v\b|\|\|\s*(?:true\b|echo\b)|;\s*echo\b)" +) +NO_PROGRESS_MESSAGE = ( + "No meaningful progress has been detected. Make a materially different change, run a " + "materially different validation, or submit/stop." +) +TRUNCATION_NOTICE = "[truncated; full details written to debug artifacts]" + +MAX_AGENT_STDOUT_CHARS = 6000 +MAX_AGENT_STDERR_CHARS = 4000 +MAX_AGENT_WARNING_CHARS = 2000 +MAX_AGENT_MUTATION_SUMMARY_CHARS = 2000 +MAX_AGENT_TOOL_RESULT_CHARS = 12000 +MAX_AGENT_WARNING_COUNT = 5 +MAX_AGENT_MUTATION_ENTRIES = 10 +MAX_AGENT_ATTEMPT_STATE_SUMMARY_CHARS = 1500 +MAX_COMPACT_RETRY_MEMORY_CHARS = 2500 +MAX_SNAPSHOT_FILES = 5000 +MAX_SNAPSHOT_FILE_BYTES = 2_000_000 +NO_PROGRESS_MAX_STEPS = 8 +NO_PROGRESS_MAX_REPEATED_COMMANDS = 3 +NO_PROGRESS_MAX_REPEATED_WARNINGS = 2 + +DEFAULT_SNAPSHOT_SKIP_DIRS = frozenset( + { + ".git", "node_modules", "__pycache__", ".pytest_cache", ".mypy_cache", + ".ruff_cache", ".venv", "venv", "env", "dist", "build", "target", ".cache", + } +) + + +def _digest(value: str) -> str: + return hashlib.sha256(value.encode("utf-8", errors="replace")).hexdigest()[:16] + + +def _digest_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest()[:16] + + +def is_validation_like(command: str) -> bool: + return bool(_VALIDATION_INTENT_RE.search(command or "")) + + +def is_weakened_validation_command(command: str) -> bool: + return bool(_WEAK_VALIDATION_RE.search(command or "")) + + +def _validation_intents(command: str) -> frozenset[str]: + return frozenset(match.lower() for match in _VALIDATION_INTENT_RE.findall(command or "")) + + +def _changed_files_fingerprint(mutations: "MutationSummary") -> str: + rows = [ + *(f"created:{item.path}:{item.content_hash or ''}" for item in mutations.created), + *(f"modified:{item.path}:{item.content_hash or ''}" for item in mutations.modified), + *(f"deleted:{item.path}:{item.content_hash or ''}" for item in mutations.deleted), + ] + return _digest("\n".join(sorted(rows))) + + +def _is_within(path: Path, root: Path) -> bool: + try: + path.resolve(strict=False).relative_to(root.resolve(strict=False)) + return True + except (OSError, ValueError): + return False + + +def _safe_resolve(value: str | Path) -> Path: + return Path(value).expanduser().resolve(strict=False) + + +def _decode_partial(value: str | bytes | None) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return str(value) + + +def _cap_text(value: str, limit: int) -> str: + if len(value) <= limit: + return value + room = max(0, limit - len(TRUNCATION_NOTICE) - 1) + return value[:room] + "\n" + TRUNCATION_NOTICE + + +_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]") +_UUID_RE = re.compile( + r"\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b", + re.IGNORECASE, +) +_TIMESTAMP_RE = re.compile( + r"\b\d{4}-\d{2}-\d{2}[T ][0-2]\d:[0-5]\d:[0-5]\d(?:[.,]\d+)?(?:Z|[+-]\d{2}:?\d{2})?\b" +) +_ABSOLUTE_PATH_RE = re.compile(r"(? str: + value = _ANSI_ESCAPE_RE.sub("", value).strip() + value = _UUID_RE.sub("", value) + value = _TIMESTAMP_RE.sub("", value) + value = re.sub(r"\b0x[0-9a-f]+\b", "
", value, flags=re.IGNORECASE) + value = re.sub(r"(?i)(?:/tmp|/var/tmp|/private/tmp)[^\s:\"'(),]*", "", value) + value = _ABSOLUTE_PATH_RE.sub("", value) + value = re.sub(r"(?i)\bline\s+\d+\b", "line ", value) + value = re.sub(r"(?<=\w):\d+(?::\d+)?\b", ":", value) + value = re.sub(r"\bpid[=: ]+\d+\b", "pid=", value, flags=re.IGNORECASE) + return " ".join(value.split()) + + +def failure_fingerprint(stdout: str, stderr: str, exit_code: int) -> str: + """Return a stable fingerprint for the most useful failure detail in command output.""" + if exit_code == 0: + return "success" + combined = "\n".join(part for part in (stderr, stdout) if part)[-12000:] + lines = [_normalize_failure_text(line) for line in combined.splitlines()] + meaningful = [line for line in lines if line and not line.startswith(("Traceback (", "During handling"))] + for line in reversed(meaningful): + if any(pattern.search(line) for pattern in _STRUCTURED_FAILURE_PATTERNS): + return "structured:" + _digest(line.lower()) + compact_tail = " | ".join(meaningful[-8:])[-2000:] + if compact_tail: + return "tail:" + _digest(compact_tail.lower()) + return f"exit:{exit_code}" + + +@dataclass(frozen=True, slots=True) +class PathBoundaries: + workspace: Path + private_paths: tuple[Path, ...] = () + + @classmethod + def discover( + cls, + workspace: Path, + configured: Iterable[str | Path] = (), + runtime_paths: Iterable[str | Path] = (), + ) -> "PathBoundaries": + workspace = workspace.resolve() + normalized: list[Path] = [] + for candidate in [*configured, *runtime_paths]: + if not str(candidate).strip(): + continue + path = _safe_resolve(candidate) + if _is_within(path, workspace) or _is_within(workspace, path): + continue + if path not in normalized: + normalized.append(path) + return cls(workspace=workspace, private_paths=tuple(normalized)) + + def classify(self, path: str | Path) -> str: + resolved = _safe_resolve(path) + if _is_within(resolved, self.workspace): + return "workspace" + if any(_is_within(resolved, private) for private in self.private_paths): + return "private-runtime" + return "external/system" + + def contains_private(self, value: str) -> bool: + if not value: + return False + return any( + piece and self.classify(piece) == "private-runtime" + for piece in value.split(os.pathsep) + ) + + +@dataclass(slots=True) +class FileRecord: + path: str + path_class: str + kind: str + mode: int + size: int + mtime_ns: int + link_target: str | None = None + content_hash: str | None = None + + +@dataclass(slots=True) +class Snapshot: + records: dict[str, FileRecord] = field(default_factory=dict) + truncated: bool = False + inspected_files: int = 0 + + +@dataclass(slots=True) +class MutationSummary: + created: list[FileRecord] = field(default_factory=list) + modified: list[FileRecord] = field(default_factory=list) + deleted: list[FileRecord] = field(default_factory=list) + permissions_changed: list[str] = field(default_factory=list) + symlinks_created: list[str] = field(default_factory=list) + directories_modified: list[str] = field(default_factory=list) + processes_started: list[int] = field(default_factory=list) + ports_opened: list[str] = field(default_factory=list) + + @property + def path_classes(self) -> set[str]: + return {item.path_class for item in [*self.created, *self.modified, *self.deleted]} + + def has_effects(self) -> bool: + return any(asdict(self).values()) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + def compact(self) -> list[str]: + entries: list[str] = [] + groups = ( + ("created", self.created), ("modified", self.modified), ("deleted", self.deleted) + ) + for action, records in groups: + for record in records: + entries.append(f"{action}: {record.path}") + entries.extend(f"permissions changed: {path}" for path in self.permissions_changed) + entries.extend(f"symlink created: {path}" for path in self.symlinks_created) + if self.processes_started: + entries.append(f"processes started: {len(self.processes_started)}") + if self.ports_opened: + entries.append(f"network listeners changed: {len(self.ports_opened)}") + if len(entries) > MAX_AGENT_MUTATION_ENTRIES: + entries = entries[:MAX_AGENT_MUTATION_ENTRIES] + [TRUNCATION_NOTICE] + return entries + + +@dataclass(slots=True) +class ExecutionFingerprint: + cwd: str + user: str + shell: str + path: str + environment_names: list[str] + environment_hash: str + environment_value_hashes: dict[str, str] + processes: list[int] + open_ports: list[str] + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(slots=True) +class CommandRecord: + command: str + cwd: str + environment_hash: str + resolved_executables: list[str] + exit_code: int + timed_out: bool + duration_seconds: float + before: ExecutionFingerprint + after: ExecutionFingerprint + mutations: MutationSummary + path_classes: list[str] + depended_on_private_runtime: bool + used_clean_task_context: bool + snapshot_truncated: bool = False + external_or_private_state_may_have_changed: bool = False + warnings: list[str] = field(default_factory=list) + no_progress_warning: bool = False + force_finalization: bool = False + failure_fingerprint: str = "" + weakened_validation: bool = False + changed_files_fingerprint: str = "" + output_excerpt: str = "" + step: int = 0 + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(slots=True) +class ValidationEvidence: + command: str + label: str + strength: int + context_hash: str + clean_task_context: bool + depended_on_private_runtime: bool + produced_artifacts: bool + scope: str + exit_code: int + failure_fingerprint: str = "" + suspicious: bool = False + weakened: bool = False + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(slots=True) +class UnresolvedValidationFailure: + command: str + exit_code: int + excerpt: str + step: int + changed_files_fingerprint: str + intents: frozenset[str] = field(default_factory=frozenset, repr=False) + + def to_dict(self) -> dict[str, Any]: + result = asdict(self) + result["intents"] = sorted(self.intents) + return result + + +@dataclass(slots=True) +class SuccessfulValidationState: + command: str + step: int + changed_files_fingerprint: str + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +@dataclass(slots=True) +class FailureMemory: + believed_succeeded: str + final_validation: str + contradiction: str + context_differences: list[str] + files_and_side_effects: dict[str, Any] + contamination_warnings: list[str] + strongest_failure_evidence: str + weakest_success_evidence: str + timeout_observed: bool = False + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + def render_compact(self) -> str: + rows = [ + "Previous attempt failure summary:", + f"Final status: {self.final_validation or 'failed'}", + f"Strongest failing evidence: {self.strongest_failure_evidence or 'final validation failure'}", + ] + if self.contradiction: + rows.append(f"Contradiction: {self.contradiction}") + if self.weakest_success_evidence: + rows.append(f"Suspicious weak success evidence: {self.weakest_success_evidence}") + if self.contamination_warnings: + rows.append(f"Private-runtime warning: {self.contamination_warnings[0]}") + if self.timeout_observed: + rows.append(f"Timeout warning: {TIMEOUT_MESSAGE}") + rows.append( + "Recommendation: use the clean task context, address the strongest failure, and run a " + "materially different validation before claiming completion." + ) + return _cap_text("\n".join(rows), MAX_COMPACT_RETRY_MEMORY_CHARS) + + # Compatibility for callers that previously rendered the full structure. + def render(self) -> str: + return self.render_compact() + + +@dataclass(slots=True) +class AttemptState: + before: ExecutionFingerprint + after: ExecutionFingerprint | None = None + commands: list[CommandRecord] = field(default_factory=list) + files_created: set[str] = field(default_factory=set) + files_modified: set[str] = field(default_factory=set) + files_deleted: set[str] = field(default_factory=set) + side_effects: list[dict[str, Any]] = field(default_factory=list) + validation_evidence: list[ValidationEvidence] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + unresolved_failures: list[str] = field(default_factory=list) + unresolved_validation_failures: list[UnresolvedValidationFailure] = field(default_factory=list) + best_successful_validation: SuccessfulValidationState | None = None + final_changed_files_fingerprint: str = "" + timeouts: int = 0 + snapshot_truncated: bool = False + no_progress_steps: int = 0 + no_progress_events: int = 0 + repeated_commands: dict[str, int] = field(default_factory=dict) + repeated_warnings: dict[str, int] = field(default_factory=dict) + tool_steps: list[dict[str, Any]] = field(default_factory=list) + tool_failures: list[str] = field(default_factory=list) + _last_evidence_signatures: set[str] = field(default_factory=set, repr=False) + _evidence_repetitions: dict[str, tuple[int, int]] = field(default_factory=dict, repr=False) + _progress_epoch: int = field(default=0, repr=False) + _last_command_exit: dict[str, int] = field(default_factory=dict, repr=False) + _previous_command_had_warning: bool = field(default=False, repr=False) + + def add_command(self, record: CommandRecord) -> None: + self.commands.append(record) + for item in record.mutations.created: + self.files_created.add(item.path) + for item in record.mutations.modified: + self.files_modified.add(item.path) + for item in record.mutations.deleted: + self.files_deleted.add(item.path) + if record.mutations.has_effects(): + self.side_effects.append({"command": record.command, **record.mutations.to_dict()}) + if "workspace" in record.mutations.path_classes: + self._progress_epoch += 1 + if record.timed_out: + self.timeouts += 1 + self.snapshot_truncated = self.snapshot_truncated or record.snapshot_truncated + for warning in record.warnings: + if warning not in self.warnings: + self.warnings.append(warning) + self.repeated_warnings[warning] = self.repeated_warnings.get(warning, 0) + 1 + + def register_progress(self, record: CommandRecord, evidence: ValidationEvidence) -> tuple[bool, bool]: + command_signature = " ".join(record.command.split()) + command_count = self.repeated_commands.get(command_signature, 0) + 1 + self.repeated_commands[command_signature] = command_count + evidence_signature = "|".join( + (command_signature, str(evidence.exit_code), evidence.label, evidence.failure_fingerprint) + ) + previous_count, previous_epoch = self._evidence_repetitions.get( + evidence_signature, (0, -1) + ) + evidence_count = previous_count + 1 if previous_epoch == self._progress_epoch else 1 + new_evidence = evidence_count == 1 + self._last_evidence_signatures.add(evidence_signature) + previous_exit = self._last_command_exit.get(command_signature) + outcome_changed = previous_exit is not None and previous_exit != evidence.exit_code + self._last_command_exit[command_signature] = evidence.exit_code + warning_resolved = self._previous_command_had_warning and not record.warnings + self._previous_command_had_warning = bool(record.warnings) + workspace_effect = "workspace" in record.mutations.path_classes and record.mutations.has_effects() + process_effect = bool(record.mutations.processes_started or record.mutations.ports_opened) + changed_strategy = command_count == 1 + progress = ( + workspace_effect or process_effect or new_evidence or changed_strategy + or outcome_changed or warning_resolved + ) + if progress: + # Invalidate stale repetition pressure. The current evidence starts a fresh epoch so + # the immediately following identical result can still be counted as a repeat. + self._progress_epoch += 1 + evidence_count = 1 + self._evidence_repetitions[evidence_signature] = (evidence_count, self._progress_epoch) + repeated_warning = any( + value >= NO_PROGRESS_MAX_REPEATED_WARNINGS for value in self.repeated_warnings.values() + ) + repeated_command = evidence_count >= NO_PROGRESS_MAX_REPEATED_COMMANDS + if progress and not repeated_warning: + self.no_progress_steps = 0 + return False, False + self.no_progress_steps += 1 + threshold = ( + self.no_progress_steps >= NO_PROGRESS_MAX_STEPS or repeated_command or repeated_warning + ) + if not threshold: + return False, False + self.no_progress_events += 1 + self.no_progress_steps = 0 + record.no_progress_warning = True + record.force_finalization = self.no_progress_events >= 2 + return True, record.force_finalization + + def track_validation(self, record: CommandRecord, evidence: ValidationEvidence) -> None: + if not is_validation_like(record.command): + return + intents = _validation_intents(record.command) + if record.exit_code != 0: + self.unresolved_validation_failures.append( + UnresolvedValidationFailure( + command=record.command, + exit_code=record.exit_code, + excerpt=record.output_excerpt, + step=record.step, + changed_files_fingerprint=record.changed_files_fingerprint, + intents=intents, + ) + ) + return + if evidence.weakened: + return + self.unresolved_validation_failures = [ + failure + for failure in self.unresolved_validation_failures + if not ( + failure.command == record.command + or bool(failure.intents & intents) + or failure.changed_files_fingerprint != record.changed_files_fingerprint + ) + ] + self.best_successful_validation = SuccessfulValidationState( + command=record.command, + step=record.step, + changed_files_fingerprint=record.changed_files_fingerprint, + ) + + def finalization_warnings(self) -> list[str]: + warnings: list[str] = [] + if self.unresolved_validation_failures: + summaries = [] + for failure in self.unresolved_validation_failures[-3:]: + excerpt = f": {failure.excerpt}" if failure.excerpt else "" + summaries.append( + f"- {failure.command} (exit {failure.exit_code}, step {failure.step}){excerpt}" + ) + warnings.append(UNRESOLVED_VALIDATION_MESSAGE + "\n" + "\n".join(summaries)) + if ( + self.best_successful_validation is not None + and self.final_changed_files_fingerprint + and self.best_successful_validation.changed_files_fingerprint + != self.final_changed_files_fingerprint + ): + warnings.append(VALIDATION_DRIFT_MESSAGE) + return warnings + + def add_evidence(self, evidence: ValidationEvidence) -> None: + self.validation_evidence.append(evidence) + successful = [item for item in self.validation_evidence if item.exit_code == 0] + failures = [item for item in self.validation_evidence if item.exit_code != 0] + if successful and failures: + strongest_success = max(successful, key=lambda item: item.strength) + strongest_failure = max(failures, key=lambda item: item.strength) + if strongest_failure.strength > strongest_success.strength: + strongest_success.suspicious = True + message = ( + "Contradictory validation evidence: a weaker check passed in a different or " + "polluted context while a stronger clean-context check failed." + ) + if message not in self.unresolved_failures: + self.unresolved_failures.append(message) + + def compact_summary(self) -> str: + summary = { + "files_created": len(self.files_created), + "files_modified": len(self.files_modified), + "files_deleted": len(self.files_deleted), + "commands": len(self.commands), + "failed_commands": sum(item.exit_code != 0 for item in self.commands), + "timeouts": self.timeouts, + "evidence": [item.label for item in self.validation_evidence[-3:]], + "warnings": self.warnings[:2], + "unresolved": self.unresolved_failures[:2], + } + return _cap_text(json.dumps(summary, sort_keys=True), MAX_AGENT_ATTEMPT_STATE_SUMMARY_CHARS) + + def to_dict(self) -> dict[str, Any]: + return { + "before": self.before.to_dict(), + "after": self.after.to_dict() if self.after else None, + "commands": [item.to_dict() for item in self.commands], + "files_created": sorted(self.files_created), + "files_modified": sorted(self.files_modified), + "files_deleted": sorted(self.files_deleted), + "side_effects": self.side_effects, + "validation_evidence": [item.to_dict() for item in self.validation_evidence], + "warnings": self.warnings, + "unresolved_failures": self.unresolved_failures, + "unresolved_validation_failures": [item.to_dict() for item in self.unresolved_validation_failures], + "best_successful_validation": self.best_successful_validation.to_dict() if self.best_successful_validation else None, + "final_changed_files_fingerprint": self.final_changed_files_fingerprint, + "timeouts": self.timeouts, + "snapshot_truncated": self.snapshot_truncated, + "no_progress_steps": self.no_progress_steps, + "no_progress_events": self.no_progress_events, + "tool_steps": self.tool_steps, + "tool_failures": self.tool_failures, + } + + def build_failure_memory(self, believed_succeeded: str, final_validation: str) -> FailureMemory: + successful = [item for item in self.validation_evidence if item.exit_code == 0] + failures = [item for item in self.validation_evidence if item.exit_code != 0] + weakest_success = min(successful, key=lambda item: item.strength).label if successful else "agent assertion" + strongest_failure = max(failures, key=lambda item: item.strength).label if failures else final_validation + contexts = sorted( + { + "private-runtime dependency" if item.depended_on_private_runtime else "clean task context" + for item in self.validation_evidence + } + ) + contradiction = ( + "Something passed in one context but failed in another, so the execution contexts or assumptions may differ." + if successful and (failures or final_validation) + else "Final validation did not confirm the attempted solution." + ) + return FailureMemory( + believed_succeeded=believed_succeeded, + final_validation=final_validation, + contradiction=contradiction, + context_differences=contexts, + files_and_side_effects={ + "created": sorted(self.files_created), + "modified": sorted(self.files_modified), + "deleted": sorted(self.files_deleted), + "side_effects": self.side_effects, + }, + contamination_warnings=list(self.warnings), + strongest_failure_evidence=strongest_failure, + weakest_success_evidence=weakest_success, + timeout_observed=self.timeouts > 0, + ) + + +class TaskExecutionContext: + """Build a clean task environment and retain full command telemetry outside model context.""" + + _PRESERVED_NAMES = { + "HOME", "USER", "LOGNAME", "SHELL", "TERM", "COLORTERM", "LANG", "TZ", + "TMPDIR", "TMP", "TEMP", "XDG_RUNTIME_DIR", "DISPLAY", "WAYLAND_DISPLAY", + "SSH_AUTH_SOCK", "SYSTEMROOT", "COMSPEC", "PATHEXT", + } + _PRESERVED_PREFIXES = ("LC_",) + + def __init__( + self, + workspace: Path, + *, + private_paths: Iterable[str | Path] = (), + task_environment: Mapping[str, str] | None = None, + allowed_private_paths: Iterable[str | Path] = (), + snapshot_excluded_paths: Iterable[str | Path] = (), + ) -> None: + runtime_paths: list[str | Path] = [] + for name in ("VIRTUAL_ENV", "CONDA_PREFIX", "CONDA_ENV_PATH"): + value = os.environ.get(name) + if value: + runtime_paths.append(value) + self.boundaries = PathBoundaries.discover(workspace, private_paths, runtime_paths) + self.allowed_private_paths = tuple(_safe_resolve(item) for item in allowed_private_paths) + self.snapshot_excluded_paths = tuple(_safe_resolve(item) for item in snapshot_excluded_paths) + self.task_environment = dict(task_environment or {}) + self.environment = self._build_environment(os.environ) + self._attempt_files_before = Snapshot() + self.attempt = AttemptState(before=self.fingerprint(workspace, self.environment)) + + def _allowed_private(self, path: str | Path) -> bool: + resolved = _safe_resolve(path) + return any(_is_within(resolved, allowed) for allowed in self.allowed_private_paths) + + def _build_environment(self, source: Mapping[str, str]) -> dict[str, str]: + env: dict[str, str] = {} + for name, value in source.items(): + if name == "PATH" or name.startswith(("VILLANI_", "CODEX_")): + continue + if name in self._PRESERVED_NAMES or name.startswith(self._PRESERVED_PREFIXES): + if not self.boundaries.contains_private(value): + env[name] = value + path_entries: list[str] = [] + for entry in source.get("PATH", os.defpath).split(os.pathsep): + if not entry: + continue + if self.boundaries.classify(entry) == "private-runtime" and not self._allowed_private(entry): + continue + if entry not in path_entries: + path_entries.append(entry) + env["PATH"] = os.pathsep.join(path_entries) or os.defpath + for name, value in self.task_environment.items(): + if self.boundaries.contains_private(value) and not any( + self._allowed_private(piece) for piece in value.split(os.pathsep) if piece + ): + continue + env[str(name)] = str(value) + return env + + def begin_attempt(self) -> AttemptState: + before = self.fingerprint(self.boundaries.workspace, self.environment) + self._attempt_files_before = self.snapshot_workspace() + self.attempt = AttemptState(before=before, snapshot_truncated=self._attempt_files_before.truncated) + return self.attempt + + def existed_at_attempt_start(self, path: str | Path) -> bool: + return str(_safe_resolve(path)) in self._attempt_files_before.records + + def finish_attempt(self) -> AttemptState: + after = self.fingerprint(self.boundaries.workspace, self.environment) + current = self.snapshot_workspace() + self.attempt.after = after + self.attempt.snapshot_truncated = self.attempt.snapshot_truncated or current.truncated + cumulative = self._mutation_diff( + self._attempt_files_before.records, current.records, self.attempt.before, after + ) + for item in cumulative.created: + self.attempt.files_created.add(item.path) + for item in cumulative.modified: + self.attempt.files_modified.add(item.path) + for item in cumulative.deleted: + self.attempt.files_deleted.add(item.path) + self.attempt.final_changed_files_fingerprint = _changed_files_fingerprint(cumulative) + if cumulative.has_effects(): + self.attempt.side_effects.append({"scope": "cumulative-attempt", **cumulative.to_dict()}) + return self.attempt + + def fingerprint(self, cwd: Path, env: Mapping[str, str]) -> ExecutionFingerprint: + value_hashes = {name: _digest(value) for name, value in sorted(env.items())} + return ExecutionFingerprint( + cwd=str(cwd.resolve()), + user=getpass.getuser(), + shell=env.get("SHELL") or os.environ.get("SHELL", ""), + path=env.get("PATH", ""), + environment_names=sorted(env), + environment_hash=_digest(json.dumps(value_hashes, sort_keys=True)), + environment_value_hashes=value_hashes, + processes=self._processes(), + open_ports=self._open_ports(), + ) + + def snapshot_workspace(self) -> Snapshot: + root = self.boundaries.workspace + snapshot = Snapshot() + if not root.exists(): + return snapshot + for current_root, directory_names, file_names in os.walk(root, topdown=True, followlinks=False): + current = Path(current_root) + directory_names[:] = [ + name + for name in directory_names + if name not in DEFAULT_SNAPSHOT_SKIP_DIRS + and not any( + _is_within(current / name, excluded) or _is_within(excluded, current / name) + for excluded in self.snapshot_excluded_paths + ) + ] + candidates = [*(current / name for name in directory_names), *(current / name for name in file_names)] + for path in candidates: + if snapshot.inspected_files >= MAX_SNAPSHOT_FILES: + snapshot.truncated = True + return snapshot + snapshot.inspected_files += 1 + try: + info = path.lstat() + except OSError: + continue + kind = "symlink" if path.is_symlink() else "directory" if path.is_dir() else "file" + content_hash = None + if kind == "file" and info.st_size <= MAX_SNAPSHOT_FILE_BYTES: + try: + content_hash = _digest_bytes(path.read_bytes()) + except OSError: + content_hash = None + resolved = str(path.resolve(strict=False)) + snapshot.records[resolved] = FileRecord( + path=resolved, + path_class="workspace", + kind=kind, + mode=stat.S_IMODE(info.st_mode), + size=info.st_size, + mtime_ns=info.st_mtime_ns, + link_target=os.readlink(path) if path.is_symlink() else None, + content_hash=content_hash, + ) + return snapshot + + # Compatibility alias; deliberately workspace-only. + def snapshot_files(self) -> dict[str, FileRecord]: + return self.snapshot_workspace().records + + @staticmethod + def _processes() -> list[int]: + proc = Path("/proc") + if not proc.exists(): + return [] + try: + return sorted(int(item.name) for item in proc.iterdir() if item.name.isdigit()) + except OSError: + return [] + + @staticmethod + def _open_ports() -> list[str]: + ports: set[str] = set() + for name in ("tcp", "tcp6", "udp", "udp6"): + path = Path("/proc/net") / name + try: + rows = path.read_text(encoding="utf-8", errors="replace").splitlines()[1:] + except OSError: + continue + for row in rows: + columns = row.split() + if len(columns) > 3: + ports.add(f"{name}:{columns[1]}:{columns[3]}") + return sorted(ports) + + def resolved_executables(self, command: str, env: Mapping[str, str]) -> list[str]: + resolved: list[str] = [] + try: + tokens = shlex.split(command) + except ValueError: + tokens = [] + expect_command = True + for token in tokens: + if token in {";", "&&", "||", "|"}: + expect_command = True + continue + if expect_command and "=" not in token: + candidate = shutil.which(token, path=env.get("PATH")) + if candidate and candidate not in resolved: + resolved.append(str(_safe_resolve(candidate))) + expect_command = False + shell = shutil.which(env.get("SHELL", ""), path=env.get("PATH")) if env.get("SHELL") else None + if shell and shell not in resolved: + resolved.insert(0, str(_safe_resolve(shell))) + return resolved + + def _mutation_diff( + self, + before: dict[str, FileRecord], + after: dict[str, FileRecord], + before_fp: ExecutionFingerprint, + after_fp: ExecutionFingerprint, + ) -> MutationSummary: + created_keys = after.keys() - before.keys() + deleted_keys = before.keys() - after.keys() + common = before.keys() & after.keys() + modified_keys = { + key for key in common + if before[key].kind != "directory" + and ( + before[key].size, before[key].kind, before[key].link_target, before[key].content_hash + ) != ( + after[key].size, after[key].kind, after[key].link_target, after[key].content_hash + ) + } + permissions = sorted(key for key in common if before[key].mode != after[key].mode) + return MutationSummary( + created=[after[key] for key in sorted(created_keys)], + modified=[after[key] for key in sorted(modified_keys)], + deleted=[before[key] for key in sorted(deleted_keys)], + permissions_changed=permissions, + symlinks_created=sorted(key for key in created_keys if after[key].kind == "symlink"), + directories_modified=[], + processes_started=sorted(set(after_fp.processes) - set(before_fp.processes)), + ports_opened=sorted(set(after_fp.open_ports) - set(before_fp.open_ports)), + ) + + @staticmethod + def _kill_process_tree(proc: subprocess.Popen[str]) -> None: + if proc.poll() is not None: + return + if os.name == "posix": + try: + os.killpg(proc.pid, signal.SIGKILL) + return + except (OSError, ProcessLookupError): + pass + try: + proc.kill() + except OSError: + pass + + def _run_process( + self, command: str, cwd: Path, env: Mapping[str, str], timeout: int + ) -> tuple[int, str, str, bool]: + bash = shutil.which("bash", path=env.get("PATH")) + process_command: str | list[str] = [bash, "-o", "pipefail", "-c", command] if bash else command + popen_kwargs: dict[str, Any] = { + "shell": not bool(bash), + "cwd": str(cwd), + "env": dict(env), + "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, + "text": True, + } + if os.name == "posix": + popen_kwargs["start_new_session"] = True + proc = subprocess.Popen(process_command, **popen_kwargs) + try: + stdout, stderr = proc.communicate(timeout=timeout) + return int(proc.returncode or 0), stdout or "", stderr or "", False + except subprocess.TimeoutExpired as exc: + partial_stdout = _decode_partial(exc.stdout) + partial_stderr = _decode_partial(exc.stderr) + self._kill_process_tree(proc) + try: + remaining_stdout, remaining_stderr = proc.communicate(timeout=1) + except subprocess.TimeoutExpired: + self._kill_process_tree(proc) + remaining_stdout, remaining_stderr = "", "" + stdout = partial_stdout + _decode_partial(remaining_stdout) + stderr = partial_stderr + _decode_partial(remaining_stderr) + return 124, stdout, stderr, True + + def run( + self, command: str, cwd: Path, timeout: int + ) -> tuple[subprocess.CompletedProcess[str], CommandRecord]: + env = dict(self.environment) + before_fp = self.fingerprint(cwd, env) + before_snapshot = self.snapshot_workspace() + executables = self.resolved_executables(command, env) + started = time.monotonic() + exit_code, stdout, stderr, timed_out = self._run_process(command, cwd, env, timeout) + duration = time.monotonic() - started + after_fp = self.fingerprint(cwd, env) + after_snapshot = self.snapshot_workspace() + mutations = self._mutation_diff( + before_snapshot.records, after_snapshot.records, before_fp, after_fp + ) + try: + command_tokens = shlex.split(command, posix=os.name != "nt") + except ValueError: + command_tokens = [] + explicit_paths = [token for token in command_tokens if token.startswith(("/", "~"))] + private_dependency = ( + self.boundaries.classify(cwd) == "private-runtime" + or any( + self.boundaries.classify(path) == "private-runtime" and not self._allowed_private(path) + for path in executables + ) + or any( + self.boundaries.classify(path) == "private-runtime" and not self._allowed_private(path) + for path in explicit_paths + ) + or any(self.boundaries.contains_private(value) for value in env.values()) + ) + warnings = [PRIVATE_WARNING] if private_dependency else [] + if timed_out: + warnings.append(TIMEOUT_MESSAGE) + classes = set(mutations.path_classes) + classes.update(self.boundaries.classify(path) for path in explicit_paths) + if mutations.processes_started or mutations.ports_opened: + classes.add("external/system") + record = CommandRecord( + command=command, + cwd=str(cwd.resolve()), + environment_hash=before_fp.environment_hash, + resolved_executables=executables, + exit_code=exit_code, + timed_out=timed_out, + duration_seconds=round(duration, 6), + before=before_fp, + after=after_fp, + mutations=mutations, + path_classes=sorted(classes), + depended_on_private_runtime=private_dependency, + used_clean_task_context=not private_dependency, + snapshot_truncated=before_snapshot.truncated or after_snapshot.truncated, + external_or_private_state_may_have_changed=True, + warnings=warnings, + failure_fingerprint=failure_fingerprint(stdout, stderr, exit_code), + weakened_validation=is_weakened_validation_command(command), + changed_files_fingerprint=_changed_files_fingerprint( + self._mutation_diff( + self._attempt_files_before.records, after_snapshot.records, self.attempt.before, after_fp + ) + ), + output_excerpt=_cap_text((stderr or stdout).strip(), 500), + step=len(self.attempt.commands) + 1, + ) + self.attempt.add_command(record) + completed = subprocess.CompletedProcess(command, exit_code, stdout, stderr) + return completed, record + + def record_tool_step( + self, + tool_name: str, + tool_input: Mapping[str, Any], + *, + is_error: bool, + ) -> tuple[bool, bool]: + signature = f"tool:{tool_name}:{json.dumps(dict(tool_input), sort_keys=True, default=str)}" + count = self.attempt.repeated_commands.get(signature, 0) + 1 + self.attempt.repeated_commands[signature] = count + self.attempt.tool_steps.append( + {"tool": tool_name, "input_hash": _digest(signature), "is_error": is_error} + ) + if is_error: + failure_signature = f"{tool_name}:{_digest(signature)}" + if failure_signature not in self.attempt.tool_failures: + self.attempt.tool_failures.append(failure_signature) + if count == 1: + self.attempt._progress_epoch += 1 + self.attempt.no_progress_steps = 0 + return False, False + if not is_error and tool_name in {"Write", "Patch"}: + self.attempt._progress_epoch += 1 + self.attempt.no_progress_steps = 0 + return False, False + self.attempt.no_progress_steps += 1 + if ( + count < NO_PROGRESS_MAX_REPEATED_COMMANDS + and self.attempt.no_progress_steps < NO_PROGRESS_MAX_STEPS + ): + return False, False + self.attempt.no_progress_events += 1 + self.attempt.no_progress_steps = 0 + return True, self.attempt.no_progress_events >= 2 + + def record_final_result(self, summary: str, succeeded: bool) -> ValidationEvidence: + evidence = ValidationEvidence( + command=summary or "final validation", + label="official/verifier result", + strength=5, + context_hash=( + self.attempt.after.environment_hash if self.attempt.after else self.attempt.before.environment_hash + ), + clean_task_context=True, + depended_on_private_runtime=False, + produced_artifacts=False, + scope="final expected behaviour", + exit_code=0 if succeeded else 1, + ) + self.attempt.add_evidence(evidence) + return evidence + + def record_validation( + self, + record: CommandRecord, + *, + kind: str = "smoke", + final_behavior: bool = False, + official: bool = False, + ) -> ValidationEvidence: + produced_artifacts = bool(record.mutations.created or record.mutations.modified) + if official: + label, strength = "official/verifier result", 5 + elif kind == "project" and record.used_clean_task_context: + label, strength = "project/task tests in clean task context", 4 + elif kind == "smoke" and record.used_clean_task_context: + label, strength = "independent smoke test in clean task context", 3 + elif record.depended_on_private_runtime: + label, strength = "smoke test in polluted/private context", 2 + else: + label, strength = "command exit code only", 1 + evidence = ValidationEvidence( + command=record.command, + label=label, + strength=strength, + context_hash=record.environment_hash, + clean_task_context=record.used_clean_task_context, + depended_on_private_runtime=record.depended_on_private_runtime, + produced_artifacts=produced_artifacts, + scope="final expected behaviour" if final_behavior else "partial behaviour", + exit_code=record.exit_code, + failure_fingerprint=record.failure_fingerprint, + weakened=record.weakened_validation, + ) + if record.weakened_validation: + evidence.label = "weak " + evidence.label + evidence.strength = min(evidence.strength, 1) + self.attempt.add_evidence(evidence) + self.attempt.track_validation(record, evidence) + warning, force = self.attempt.register_progress(record, evidence) + record.warnings = [ + item + for item in record.warnings + if item == TIMEOUT_MESSAGE or self.attempt.repeated_warnings.get(item, 0) <= NO_PROGRESS_MAX_REPEATED_WARNINGS + ] + if warning: + record.warnings.append(NO_PROGRESS_MESSAGE) + if NO_PROGRESS_MESSAGE not in self.attempt.warnings: + self.attempt.warnings.append(NO_PROGRESS_MESSAGE) + record.force_finalization = force + return evidence + + +def compact_command_observation( + *, + command: str, + record: CommandRecord, + stdout: str, + stderr: str, + evidence: ValidationEvidence | None, +) -> dict[str, Any]: + warnings = list(dict.fromkeys(record.warnings))[:MAX_AGENT_WARNING_COUNT] + mutation_entries = record.mutations.compact() + observation: dict[str, Any] = { + "command": _cap_text(command, 2000), + "exit_code": record.exit_code, + "timed_out": record.timed_out, + "stdout": _cap_text(stdout, MAX_AGENT_STDOUT_CHARS), + "stderr": _cap_text(stderr, MAX_AGENT_STDERR_CHARS), + } + if warnings: + observation["warnings"] = _cap_text("\n".join(warnings), MAX_AGENT_WARNING_CHARS).splitlines() + if mutation_entries: + observation["mutation_summary"] = _cap_text( + "\n".join(mutation_entries), MAX_AGENT_MUTATION_SUMMARY_CHARS + ).splitlines() + if evidence is not None: + observation["evidence"] = evidence.label + if evidence.weakened: + observation["validation_warning"] = WEAK_VALIDATION_MESSAGE + if record.timed_out: + observation["message"] = TIMEOUT_MESSAGE + if warnings or record.exit_code != 0 or record.no_progress_warning: + if record.no_progress_warning: + observation["next_action"] = NO_PROGRESS_MESSAGE + elif record.timed_out: + observation["next_action"] = "Use a bounded command or inspect partial output before retrying." + elif record.depended_on_private_runtime: + observation["next_action"] = "Repeat the check in the clean task context." + elif record.exit_code != 0: + observation["next_action"] = "Use the failure output to make a materially different next step." + return _fit_agent_observation(observation) + + +def _fit_agent_observation(observation: dict[str, Any]) -> dict[str, Any]: + def encoded() -> str: + return json.dumps(observation, ensure_ascii=False) + + if len(encoded()) <= MAX_AGENT_TOOL_RESULT_CHARS: + return observation + if "mutation_summary" in observation: + observation["mutation_summary"] = [TRUNCATION_NOTICE] + if len(encoded()) <= MAX_AGENT_TOOL_RESULT_CHARS: + return observation + warnings = list(observation.get("warnings", [])) + if len(warnings) > 2: + observation["warnings"] = warnings[:2] + [TRUNCATION_NOTICE] + if len(encoded()) <= MAX_AGENT_TOOL_RESULT_CHARS: + return observation + for output_field in ("stderr", "stdout"): + value = str(observation.get(output_field, "")) + overflow = len(encoded()) - MAX_AGENT_TOOL_RESULT_CHARS + if overflow > 0 and value: + observation[output_field] = _cap_text(value, max(80, len(value) - overflow - 64)) + if len(encoded()) <= MAX_AGENT_TOOL_RESULT_CHARS: + return observation + # Preserve mandatory status fields even under unusual serialization overhead. + for output_field in ("stderr", "stdout"): + if len(encoded()) > MAX_AGENT_TOOL_RESULT_CHARS: + observation[output_field] = TRUNCATION_NOTICE + return observation diff --git a/villani_code/integrations/__init__.py b/villani_code/integrations/__init__.py new file mode 100644 index 00000000..512503fc --- /dev/null +++ b/villani_code/integrations/__init__.py @@ -0,0 +1 @@ +"""Integration adapters for Villani Code.""" diff --git a/villani_code/integrations/pi_bridge.py b/villani_code/integrations/pi_bridge.py new file mode 100644 index 00000000..ec9def19 --- /dev/null +++ b/villani_code/integrations/pi_bridge.py @@ -0,0 +1,621 @@ +from __future__ import annotations + +import io +import os +import queue +import subprocess +import sys +import threading +import traceback +import hashlib +import inspect +import uuid +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 ( + RunCommand, + parse_approval_response_command, + parse_json_line, + parse_run_command, + ready_event, + to_json_line, +) + +RunnerFactory = Callable[..., Any] + + +@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) + + +class PiBridge: + def __init__( + self, + *, + stdin: TextIO | None = None, + stdout: TextIO | None = None, + stderr: TextIO | None = None, + runner_factory: RunnerFactory | 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() + + def emit(self, event: dict[str, Any]) -> None: + try: + self.stdout.write(to_json_line(event)) + self.stdout.flush() + except Exception as exc: # noqa: BLE001 + self.stderr.write(f"PiBridge response-write failure: {exc}\n") + self.stderr.flush() + raise + + def _debug(self, message: str, run_id: str | None = None) -> None: + event: dict[str, Any] = {"type": "bridge_diagnostic", "message": cap_text(redact_text(message), 500)} + if run_id: + event["id"] = run_id + self._events.put(event) + + def run_stdio(self) -> None: + self.emit(ready_event()) + commands: queue.Queue[str | None] = queue.Queue() + + def read_stdin() -> None: + try: + try: + fd = self.stdin.fileno() + except (AttributeError, io.UnsupportedOperation): + for raw_line in self.stdin: + commands.put(raw_line) + return + + buffer = b"" + while True: + chunk = os.read(fd, 4096) + if not chunk: + break + buffer += chunk + while b"\n" in buffer: + raw_line, buffer = buffer.split(b"\n", 1) + commands.put(raw_line.decode("utf-8", errors="replace") + "\n") + if buffer.strip(): + commands.put(buffer.decode("utf-8", errors="replace")) + finally: + commands.put(None) + + reader = threading.Thread(target=read_stdin, daemon=True) + reader.start() + stdin_closed = False + while True: + self._drain_events() + try: + raw_line = commands.get(timeout=0.05) + except queue.Empty: + if stdin_closed and not self._active: + break + continue + if raw_line is None: + stdin_closed = True + if not self._active: + break + continue + line = raw_line.strip() + if not line: + continue + try: + command = parse_json_line(line) + self._handle_command(command) + except Exception as exc: # noqa: BLE001 + self.emit({"type": "error", "error": str(exc)}) + self._drain_events() + + def _drain_events(self) -> None: + while True: + try: + event = self._events.get_nowait() + except queue.Empty: + return + if event is not None: + self.emit(event) + + def _handle_command(self, command: dict[str, Any]) -> None: + command_type = str(command.get("type") or "") + if command_type == "ping": + self.emit({"type": "pong", "id": command.get("id")}) + return + if command_type == "run": + self._start_run(parse_run_command(command)) + return + if command_type == "abort": + run_id = str(command.get("id") or "") + self._abort_run(run_id) + return + if command_type == "approval_response": + self._handle_approval_response(parse_approval_response_command(command)) + self._drain_events() + return + raise ValueError(f"Unknown bridge command type: {command_type or ''}") + + def _start_run(self, command: RunCommand) -> None: + with self._lock: + if command.id in self._active: + raise ValueError(f"Run already active: {command.id}") + active = ActiveRun(command=command) + self._active[command.id] = active + thread = threading.Thread(target=self._run_worker, args=(active,), daemon=True) + active.thread = thread + thread.start() + + def _abort_run(self, run_id: str) -> None: + with self._lock: + active = self._active.get(run_id) + if active is None: + self.emit({"type": "error", "id": run_id, "error": "No active run with that id"}) + return + active.abort_requested.set() + self._deny_pending_approvals(active) + # Best-effort only: the existing Runner has no general cooperative cancellation hook yet. + self.emit({"type": "abort_requested", "id": run_id}) + + def _handle_approval_response(self, response: Any) -> None: + with self._lock: + pending = self._pending_approvals.get(response.request_id) + active = self._active.get(response.id) + if pending is None or active is None or pending.run_id != response.id: + self.emit({"type": "error", "id": response.id, "error": f"Unknown approval request: {response.request_id}"}) + return + # Remove first so duplicate responses cannot change the decision. + self._pending_approvals.pop(response.request_id, None) + active.pending_approvals.pop(response.request_id, None) + pending.approved = bool(response.approved) + pending.ready.set() + self._events.put( + { + "type": "approval_resolved", + "id": response.id, + "request_id": response.request_id, + "tool": pending.tool, + "approved": bool(response.approved), + } + ) + + def _deny_pending_approvals(self, active: ActiveRun) -> None: + with self._lock: + pending_items = list(active.pending_approvals.values()) + for pending in pending_items: + self._pending_approvals.pop(pending.request_id, None) + active.pending_approvals.pop(pending.request_id, None) + pending.approved = False + pending.ready.set() + + def _run_worker(self, active: ActiveRun) -> None: + command = active.command + repo = str(Path(command.repo).resolve()) + repo_path = Path(repo) + before_dirty = git_changed_files(repo_path) + before_dirty_hashes = hash_files(repo_path, before_dirty) + touched_files: set[str] = set() + transcript_path: str | None = None + verification_passed: bool | None = None + latest_summary = "" + try: + self._events.put( + { + "type": "run_started", + "id": command.id, + "run_id": command.id, + "task": command.task, + "repo": repo, + "mode": command.mode, + } + ) + self._debug(f"run worker started mode={command.mode} repo={repo} task_chars={len(command.task)}", command.id) + cfg = command.config + provider = cfg.provider or os.environ.get("VILLANI_PROVIDER") or "anthropic" + model = cfg.model or os.environ.get("VILLANI_MODEL") or "" + base_url = cfg.base_url or os.environ.get("VILLANI_BASE_URL") or "" + source = "pi-proxy" if cfg.pi_model_proxy else "direct-config" + self._debug(f"model configuration source={source} provider={provider} model={model} base_url={redact_url(base_url)}", command.id) + + def on_runner_event(event: dict[str, Any]) -> None: + nonlocal verification_passed, latest_summary + for mapped in map_runner_event(command.id, event): + if mapped.get("type") == "verification_finished": + verification_passed = bool(mapped.get("passed")) + if mapped.get("type") == "workspace_changed": + for changed_path in mapped.get("files", []): + touched_files.add(str(changed_path).replace("\\", "/")) + self._events.put(mapped) + if event.get("type") == "validation_completed": + verification_passed = bool(event.get("passed")) + if event.get("type") in {"tool_result", "tool_finished"}: + latest_summary = summarize_tool_event(event) or latest_summary + + approval_callback = self._approval_callback(active) + self._debug("creating runner", command.id) + runner = self._create_runner(command, on_runner_event, approval_callback) + self._debug("runner created; entering execution", command.id) + if active.abort_requested.is_set(): + self._events.put({"type": "run_aborted", "id": command.id, "success": False, "summary": "Aborted by caller"}) + return + result = run_existing_runner(runner, command) + self._debug("runner returned result", command.id) + transcript_path = normalize_transcript_path(result) + changed_files, preexisting_dirty_files = attributed_changed_files(repo_path, before_dirty, before_dirty_hashes, touched_files) + if active.abort_requested.is_set(): + self._events.put( + { + "type": "run_aborted", + "id": command.id, + "success": False, + "summary": "Aborted by caller after runner stopped", + "changed_files": changed_files, + "preexisting_dirty_files": preexisting_dirty_files, + "transcript_path": transcript_path, + } + ) + return + summary = extract_summary(result) or latest_summary or "Villani run completed." + self._events.put( + { + "type": "run_completed", + "id": command.id, + "success": True, + "changed_files": changed_files, + "preexisting_dirty_files": preexisting_dirty_files, + "verification_passed": verification_passed, + "summary": summary, + "transcript_path": transcript_path, + } + ) + except Exception as exc: # noqa: BLE001 + self._debug(f"exception: {exc}", command.id) + self._events.put( + { + "type": "run_failed", + "id": command.id, + "success": False, + "error": str(exc), + "summary": "Villani bridge run failed.", + "changed_files": attributed_changed_files(Path(repo), before_dirty, before_dirty_hashes, touched_files)[0], + "preexisting_dirty_files": before_dirty, + "transcript_path": transcript_path, + } + ) + self.stderr.write(traceback.format_exc()) + self.stderr.flush() + finally: + self._deny_pending_approvals(active) + with self._lock: + self._active.pop(command.id, None) + + def _create_runner( + self, + command: RunCommand, + event_callback: Callable[[dict[str, Any]], None], + approval_callback: Callable[[str, dict[str, Any]], bool], + ) -> Any: + try: + signature = inspect.signature(self.runner_factory) + parameters = list(signature.parameters.values()) + accepts_varargs = any(parameter.kind == inspect.Parameter.VAR_POSITIONAL for parameter in parameters) + positional = [ + parameter + for parameter in parameters + if parameter.kind in {inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD} + ] + if accepts_varargs or len(positional) >= 3: + return self.runner_factory(command, event_callback, approval_callback) + except (TypeError, ValueError): + # Some callables do not expose an inspectable signature; fall back to the legacy two-argument shape. + pass + runner = self.runner_factory(command, event_callback) + runner.approval_callback = approval_callback + return runner + + def _approval_callback(self, active: ActiveRun) -> Callable[[str, dict[str, Any]], bool]: + def approve(tool_name: str, tool_input: dict[str, Any]) -> bool: + if active.abort_requested.is_set(): + return False + request_id = uuid.uuid4().hex + summary, safe_input = summarize_approval_request(tool_name, tool_input) + pending = PendingApproval(run_id=active.command.id, request_id=request_id, tool=str(tool_name)) + with self._lock: + if active.abort_requested.is_set() or active.command.id not in self._active: + return False + active.pending_approvals[request_id] = pending + self._pending_approvals[request_id] = pending + self._debug(f"approval_required emitted request_id={request_id} tool={tool_name} summary={summary}", active.command.id) + self._events.put( + { + "type": "approval_required", + "id": active.command.id, + "request_id": request_id, + "tool": str(tool_name), + "summary": summary, + "input": safe_input, + } + ) + while not pending.ready.wait(timeout=0.05): + if active.abort_requested.is_set(): + with self._lock: + self._pending_approvals.pop(request_id, None) + active.pending_approvals.pop(request_id, None) + return False + return pending.approved is True and not active.abort_requested.is_set() + + return approve + + +def redact_text(value: str) -> str: + api_key = os.environ.get("VILLANI_API_KEY") + if api_key: + value = value.replace(api_key, "[redacted]") + return value + + +def redact_url(value: str) -> str: + return redact_text(value.split("?", 1)[0]) + + +def summarize_approval_request(tool_name: str, tool_input: dict[str, Any]) -> tuple[str, dict[str, Any]]: + tool = str(tool_name or "tool") + safe_input: dict[str, Any] = {} + path = tool_input.get("path") or tool_input.get("file_path") + command = tool_input.get("command") + if tool in {"Write", "Patch"}: + if path is not None: + safe_input["path"] = cap_text(str(path).replace("\\", "/"), 500) + content = tool_input.get("content") + if isinstance(content, str): + safe_input["content_chars"] = len(content) + action = "Write file" if tool == "Write" else "Apply patch to" + target = safe_input.get("path") or "unknown path" + return f"{action}: {target}", safe_input + if tool == "Bash": + safe_input["command"] = cap_text(str(command or ""), 2000) + return f"Run command: {safe_input['command']}", safe_input + for key in ("path", "file_path", "command"): + if key in tool_input and tool_input[key] is not None: + safe_input[key if key != "file_path" else "path"] = cap_text(str(tool_input[key]), 1000) + return f"Approve {tool} operation", safe_input + + +def cap_text(value: str, limit: int) -> str: + if len(value) <= limit: + return value + return value[: limit - 1] + "…" + + +def build_default_runner(command: RunCommand, event_callback: Callable[[dict[str, Any]], None], approval_callback: Callable[[str, dict[str, Any]], bool]) -> Any: + from villani_code.cli import _build_runner + + provider = command.config.provider or os.environ.get("VILLANI_PROVIDER") or "anthropic" + if provider not in {"anthropic", "openai"}: + raise ValueError("provider must be 'anthropic' or 'openai'") + model = command.config.model or os.environ.get("VILLANI_MODEL") + base_url = command.config.base_url or os.environ.get("VILLANI_BASE_URL") + 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), + max_tokens=4096, + stream=True, + thinking=None, + unsafe=False, + verbose=False, + extra_json=None, + redact=False, + dangerously_skip_permissions=False, + auto_accept_edits=False, + auto_approve=False, + plan_mode="auto", + max_repair_attempts=2, + small_model=False, + provider=provider, # type: ignore[arg-type] + 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, + ) + # The bridge owns stdout as a JSONL protocol channel. Never print streamed + # model text directly to stdout, or it corrupts the event stream. + runner.print_stream = False + runner.event_callback = event_callback + runner.approval_callback = approval_callback + # Villani mode normally auto-approves ASK decisions. The Pi bridge must preserve the + # permission boundary and ask Pi instead of silently approving. + runner.force_interactive_approvals = True + return runner + + +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, + ) + if command.mode == "villani": + result = runner.run_villani_mode() + else: + result = 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 hash_files(repo: Path, files: list[str]) -> dict[str, str | None]: + return {path: hash_file(repo / path) for path in files} + + +def hash_file(path: Path) -> str | None: + try: + if not path.exists() or not path.is_file(): + return None + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + except OSError: + return None + + +def attributed_changed_files( + repo: Path, + before_dirty: list[str], + before_dirty_hashes: dict[str, str | None], + touched_files: set[str], +) -> tuple[list[str], list[str]]: + if not is_git_repo(repo): + return sorted(path for path in touched_files if (repo / path).exists()), sorted(before_dirty) + after_dirty = set(git_changed_files(repo)) + before_dirty_set = set(before_dirty) + changed = set(after_dirty - before_dirty_set) + for path in before_dirty_set & after_dirty: + if hash_file(repo / path) != before_dirty_hashes.get(path): + changed.add(path) + for path in touched_files: + normalized = path.replace("\\", "/").lstrip("./") + if normalized in after_dirty: + previous_hash = before_dirty_hashes.get(normalized) + if normalized not in before_dirty_set or hash_file(repo / normalized) != previous_hash: + changed.add(normalized) + return sorted(changed), sorted(before_dirty_set) + + +def is_git_repo(repo: Path) -> bool: + try: + proc = subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], cwd=repo, text=True, capture_output=True, stdin=subprocess.DEVNULL, timeout=10, check=False) + except Exception: + return False + return proc.returncode == 0 and proc.stdout.strip() == "true" + + +def git_changed_files(repo: Path) -> list[str]: + try: + proc = subprocess.run( + ["git", "status", "--porcelain=v1", "--untracked-files=all"], + cwd=repo, + text=True, + capture_output=True, + stdin=subprocess.DEVNULL, + timeout=10, + check=False, + ) + except Exception: + return [] + if proc.returncode != 0: + return [] + paths: list[str] = [] + for line in proc.stdout.splitlines(): + if len(line) < 4: + continue + path = line[3:] + if " -> " in path: + path = path.split(" -> ", 1)[1] + paths.append(path.replace("\\", "/")) + return sorted(set(paths)) + + +def map_runner_event(run_id: str, event: dict[str, Any]) -> list[dict[str, Any]]: + etype = str(event.get("type") or "") + if etype in {"diagnosis_attempted", "diagnosis_generated", "planning_started", "repair_attempt_started"}: + return [{"type": "phase", "id": run_id, "phase": etype, "message": humanize_event_type(etype)}] + if etype == "model_request_started": + return [ + {"type": "phase", "id": run_id, "phase": etype, "message": humanize_event_type(etype)}, + {"type": "bridge_diagnostic", "id": run_id, "message": f"model request begins model={event.get('model') or ''}"}, + ] + if etype == "model_request_completed": + return [{"type": "bridge_diagnostic", "id": run_id, "message": f"model response received model={event.get('model') or ''} stop_reason={event.get('stop_reason') or ''}"}] + if etype == "model_request_failed": + return [{"type": "bridge_diagnostic", "id": run_id, "message": f"model request failed model={event.get('model') or ''} error={cap_text(redact_text(str(event.get('error') or '')), 200)}"}] + if etype == "approval_required": + return [{"type": "bridge_diagnostic", "id": run_id, "message": f"runner requested approval tool={event.get('name') or ''}"}] + if etype == "tool_started": + tool = str(event.get("name") or "tool") + tool_input = event.get("input") if isinstance(event.get("input"), dict) else {} + return [ + {"type": "bridge_diagnostic", "id": run_id, "message": f"tool call requested tool={tool} path={tool_input.get('path') or tool_input.get('file_path') or ''} command_chars={len(str(tool_input.get('command') or ''))}"}, + {"type": "tool_started", "id": run_id, "tool": tool, "path": tool_input.get("path"), "command": tool_input.get("command")}, + ] + if etype == "tool_finished": + tool = str(event.get("name") or "tool") + ok = not bool(event.get("is_error")) + events = [{"type": "tool_finished", "id": run_id, "tool": tool, "ok": ok, "summary": summarize_tool_event(event)}] + if tool in {"Write", "Patch"}: + tool_input = event.get("input") if isinstance(event.get("input"), dict) else {} + path = tool_input.get("path") or tool_input.get("file_path") + if path: + events.append({"type": "workspace_changed", "id": run_id, "files": [str(path).replace("\\", "/")]}) + return events + if etype == "validation_step_started": + return [{"type": "verification_started", "id": run_id, "command": event.get("command") or event.get("name") or ""}] + if etype == "validation_step_finished": + passed = int(event.get("exit_code") or 0) == 0 + return [{"type": "verification_finished", "id": run_id, "command": event.get("command") or event.get("name") or "", "passed": passed, "summary": "passed" if passed else "failed"}] + if etype == "validation_completed": + passed = bool(event.get("passed")) + return [{"type": "verification_finished", "id": run_id, "command": "validation", "passed": passed, "summary": "passed" if passed else "failed"}] + if etype in {"command_wandering_detected", "progress_governor_redirected", "governor_redirect"}: + return [{"type": "governor_redirect", "id": run_id, "message": str(event.get("message") or humanize_event_type(etype))}] + return [] + + +def humanize_event_type(etype: str) -> str: + return etype.replace("_", " ").capitalize() + + +def summarize_tool_event(event: dict[str, Any]) -> str: + tool = str(event.get("name") or "tool") + tool_input = event.get("input") if isinstance(event.get("input"), dict) else {} + if tool == "Bash" and tool_input.get("command"): + return f"Ran {tool_input['command']}" + path = tool_input.get("path") or tool_input.get("file_path") + if path: + return f"{tool} {path}" + return f"{tool} finished" + + +def normalize_transcript_path(result: dict[str, Any]) -> str | None: + path = result.get("transcript_path") + return str(path) if path else None + + +def extract_summary(result: dict[str, Any]) -> str: + execution = result.get("execution") if isinstance(result.get("execution"), dict) else {} + if execution.get("final_text"): + return str(execution["final_text"]) + response = result.get("response") if isinstance(result.get("response"), dict) else {} + content = response.get("content") if isinstance(response.get("content"), list) else [] + text = "\n".join(str(block.get("text", "")) for block in content if isinstance(block, dict) and block.get("type") == "text").strip() + return text[:2000] + + +def main_stdio() -> None: + PiBridge().run_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..9382fa8a --- /dev/null +++ b/villani_code/integrations/pi_bridge_protocol.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field +from typing import Any, Literal + +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 PingCommand: + id: str + + +@dataclass(slots=True) +class AbortCommand: + id: str + + +@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, ensure_ascii=True, separators=(",", ":")) + "\n" + + +def parse_json_line(line: str) -> dict[str, Any]: + payload = json.loads(line) + if not isinstance(payload, dict): + raise ValueError("Bridge command must be a JSON object") + return payload + + +def _optional_dict(value: Any) -> dict[str, Any]: + return value if isinstance(value, dict) else {} + + +def parse_approval_response_command(payload: dict[str, Any]) -> ApprovalResponseCommand: + run_id = str(payload.get("id") or "").strip() + request_id = str(payload.get("request_id") or "").strip() + if not run_id: + raise ValueError("approval_response command requires id") + if not request_id: + raise ValueError("approval_response command requires request_id") + if not isinstance(payload.get("approved"), bool): + raise ValueError("approval_response command requires boolean approved") + return ApprovalResponseCommand(id=run_id, request_id=request_id, approved=bool(payload["approved"])) + + +def parse_run_command(payload: dict[str, Any]) -> RunCommand: + run_id = str(payload.get("id") or "").strip() + task = str(payload.get("task") or "").strip() + repo = str(payload.get("repo") or "").strip() + mode = str(payload.get("mode") or "runner").strip().lower() + if not run_id: + raise ValueError("run command requires id") + if not task: + raise ValueError("run command requires task") + if not repo: + raise ValueError("run command requires repo") + if mode not in {"runner", "villani"}: + raise ValueError("run mode must be 'runner' or 'villani'") + config_payload = _optional_dict(payload.get("config")) + limits_payload = _optional_dict(payload.get("limits")) + max_turns = limits_payload.get("max_turns") + return RunCommand( + id=run_id, + task=task, + repo=repo, + mode=mode, # type: ignore[arg-type] + config=BridgeConfig( + provider=str(config_payload["provider"]) if config_payload.get("provider") else None, + model=str(config_payload["model"]) if config_payload.get("model") else None, + base_url=str(config_payload["base_url"]) if config_payload.get("base_url") else None, + api_key=str(config_payload["api_key"]) if config_payload.get("api_key") else None, + pi_model_proxy=bool(config_payload.get("pi_model_proxy")), + ), + limits=BridgeLimits(max_turns=int(max_turns) if max_turns is not None else None), + ) + + +def ready_event() -> dict[str, Any]: + return {"type": "ready", "protocol_version": PROTOCOL_VERSION} + + +def dataclass_to_dict(value: Any) -> dict[str, Any]: + return asdict(value) diff --git a/villani_code/permissions.py b/villani_code/permissions.py index a849dbd2..931a2197 100644 --- a/villani_code/permissions.py +++ b/villani_code/permissions.py @@ -61,6 +61,8 @@ def evaluate_with_reason(self, tool: str, payload: dict, bypass: bool = False, a for rule in self.config.deny: if self._matches(rule, tool, target): return PolicyDecision(Decision.DENY, f"Matched deny rule: {rule.tool}({rule.pattern})") + if tool == "ExecutionPlan": + return PolicyDecision(Decision.ALLOW, "ExecutionPlan is an internal read-only planning operation") for rule in self.config.ask: if self._matches(rule, tool, target): return PolicyDecision(Decision.ASK, f"Matched ask rule: {rule.tool}({rule.pattern})") diff --git a/villani_code/state.py b/villani_code/state.py index 43faa570..99b238b4 100644 --- a/villani_code/state.py +++ b/villani_code/state.py @@ -4,6 +4,7 @@ import ast import json import re +import sys import time from pathlib import Path from typing import Any, Callable @@ -20,6 +21,11 @@ from villani_code.context_governance import ContextGovernanceManager from villani_code.edits import ProposalStore from villani_code.execution import ExecutionBudget, ExecutionResult +from villani_code.execution_context import ( + MAX_AGENT_ATTEMPT_STATE_SUMMARY_CHARS, + FailureMemory, + TaskExecutionContext, +) from villani_code.hooks import HookRunner from villani_code.mcp import load_mcp_config from villani_code.permissions import Decision, PermissionConfig, PermissionEngine @@ -41,6 +47,7 @@ from villani_code.transcripts import save_transcript from villani_code.context_projection import build_model_context_packet, render_model_context_packet from villani_code.event_recorder import RuntimeEventRecorder +from villani_code.debug_artifacts import resolve_debug_root from villani_code.debug_mode import DebugConfig, DebugMode from villani_code.debug_recorder import DebugRecorder from villani_code.mission_state import MissionState, create_mission_state, get_mission_dir, save_mission_state @@ -488,6 +495,9 @@ def __init__( benchmark_config: BenchmarkRuntimeConfig | None = None, debug_config: DebugConfig | None = None, provider: str | None = None, + private_runtime_paths: list[Path] | None = None, + task_environment: dict[str, str] | None = None, + allowed_private_paths: list[Path] | None = None, ): self.client = client self.repo = repo @@ -517,6 +527,19 @@ def __init__( self.benchmark_config = benchmark_config or BenchmarkRuntimeConfig() self._debug_config = debug_config or DebugConfig(mode=DebugMode.OFF) self.provider = provider + discovered_private_paths: list[Path] = list(private_runtime_paths or []) + discovered_private_paths.append(Path(__file__).resolve().parent) + discovered_private_paths.append(resolve_debug_root(self._debug_config.debug_root)) + if sys.prefix != getattr(sys, "base_prefix", sys.prefix): + discovered_private_paths.append(Path(sys.prefix)) + self._task_execution_context = TaskExecutionContext( + self.repo, + private_paths=discovered_private_paths, + task_environment=task_environment, + allowed_private_paths=allowed_private_paths or [], + snapshot_excluded_paths=[resolve_debug_root(self._debug_config.debug_root)], + ) + self._failure_memory: FailureMemory | None = None self._debug_recorder: DebugRecorder | None = None self._benchmark_noop_completion_attempts = 0 self.console = Console() @@ -538,6 +561,7 @@ def __init__( "GitCheckout(*)", "GitCommit(*)", "SubmitPlan(*)", + "ExecutionPlan(*)", ], ), repo=self.repo, @@ -600,6 +624,24 @@ def __init__( if self.small_model: self._init_small_model_support() + def record_final_validation( + self, + *, + succeeded: bool, + summary: str, + believed_succeeded: str = "", + ) -> FailureMemory | None: + """Record external/final validation and retain contradictions for the next attempt.""" + attempt = self._task_execution_context.attempt + self._task_execution_context.record_final_result(summary, succeeded) + if succeeded: + self._failure_memory = None + return None + self._failure_memory = attempt.build_failure_memory(believed_succeeded, summary) + if self._debug_recorder is not None: + self._debug_recorder.write_attempt_state(attempt.to_dict(), self._failure_memory.to_dict()) + return self._failure_memory + @property def event_callback(self) -> Callable[[dict[str, Any]], None]: return self._dispatch_event @@ -660,6 +702,7 @@ def _debug_tool_callback(self, event_type: str, payload: dict[str, Any]) -> None truncated=bool(payload.get("truncated", False)), tool_call_id=str(payload.get("tool_call_id", "") or ""), turn_index=turn_index, + full_debug_record=dict(payload), ) @@ -773,7 +816,12 @@ def run( if approved_plan is not None and not approved_plan.ready_to_execute: raise RuntimeError("Approved plan is not ready to execute; unresolved clarifications remain.") self._ensure_mission(instruction) + self._task_execution_context.begin_attempt() messages = messages or build_initial_messages(self.repo, instruction) + if self._failure_memory is not None: + messages.append( + {"role": "user", "content": [{"type": "text", "text": self._failure_memory.render_compact()}]} + ) if approved_plan is not None: if self._mission_dir is not None: (self._mission_dir / "plan_artifact.json").write_text(json.dumps(approved_plan.to_dict(), indent=2), encoding="utf-8") @@ -841,6 +889,12 @@ def run( "tool_results": [], "streamed_events_count": 0, } + self._write_regular_trajectory_artifact( + transcript=transcript, + messages=messages, + status="started", + termination_reason=None, + ) self.event_callback( { "type": "diagnosis_target_forced_read", @@ -1062,6 +1116,17 @@ def _finish_bounded( for block in response.get("content", []) if block.get("type") == "text" ) + attempt_state = self._task_execution_context.finish_attempt() + for command_record in attempt_state.commands: + if not any(item.command == command_record.command for item in attempt_state.validation_evidence): + self._task_execution_context.record_validation(command_record, kind="command") + validation_warnings = attempt_state.finalization_warnings() + if validation_warnings: + warning_text = "\n\n".join(validation_warnings) + response.setdefault("content", []).append({"type": "text", "text": warning_text}) + final_text = f"{final_text}\n\n{warning_text}" if final_text else warning_text + if not completed: + self._failure_memory = attempt_state.build_failure_memory(final_text, reason) execution = ExecutionResult( final_text=final_text, turns_used=turns_used, @@ -1078,6 +1143,8 @@ def _finish_bounded( runner_failures=collect_runner_failures(transcript), terminated_reason=reason, completed=completed, + attempt_state=attempt_state.to_dict(), + failure_memory=self._failure_memory.to_dict() if self._failure_memory else None, ) transcript["execution"] = execution.to_dict() transcript["final_assistant_content"] = response.get("content", []) @@ -1093,12 +1160,22 @@ def _finish_bounded( if self._event_recorder is not None: self._event_recorder.write_digest() if self._debug_recorder is not None: + self._debug_recorder.write_attempt_state( + attempt_state.to_dict(), + self._failure_memory.to_dict() if self._failure_memory else None, + ) self._debug_recorder.write_final_summary( status=mission_status, termination_reason=reason, total_turns=turns_used, mission_id=self._mission_id, ) + self._write_regular_trace_artifacts( + transcript=transcript, + messages=messages, + status=mission_status, + termination_reason=reason, + ) return { "response": response, "messages": messages, @@ -1171,8 +1248,16 @@ def _budget_reason( if self._debug_recorder is not None: self._debug_recorder.record_model_request_failed(str(exc)) self._debug_recorder.record_turn_finish(turns_used + 1, "model_request_failed") + self._write_regular_trajectory_artifact( + transcript=transcript, + messages=messages, + status="failed", + termination_reason="model_request_failed", + ) + self.event_callback({"type": "model_request_failed", "model": self.model, "error": str(exc)}) raise + self.event_callback({"type": "model_request_completed", "model": self.model, "stop_reason": response.get("stop_reason")}) response["content"] = normalize_content_blocks(response.get("content")) transcript["responses"].append(response) if self._debug_recorder is not None: @@ -1203,11 +1288,6 @@ def _budget_reason( reason = _budget_reason() if reason: return _finish_bounded(response, reason, reason == "completed") - if tool_name in {"Write", "Patch"} and not result.get("is_error"): - targets = self._extract_tool_targets(tool_name, tool_input) - if any(not _is_generated_or_runtime_artifact(target) for target in targets): - meaningful_repo_edit_made = True - prose_edit_intent_recovery_attempts = 0 continue if tool_uses or not empty: empty_turn_retries = 0 @@ -1288,6 +1368,12 @@ def _budget_reason( total_turns=turns_used, mission_id=self._mission_id, ) + self._write_regular_trace_artifacts( + transcript=transcript, + messages=messages, + status="completed", + termination_reason="completed", + ) return { "response": response, "messages": messages, @@ -1398,7 +1484,10 @@ def _budget_reason( self.event_callback({"type": "benchmark_scope_reminder_injected", "task_id": self.benchmark_config.task_id, "reason": "no_meaningful_edit"}) messages.append({"role": "user", "content": [{"type": "text", "text": reminder}]}) continue - if self._patch_effect_check_pending and self._patch_effect_check_attempts < self._patch_effect_check_cap: + if ( + self._patch_effect_check_pending + and self._patch_effect_check_attempts < self._patch_effect_check_cap + ): self._patch_effect_check_attempts += 1 check_observation = self._run_patch_effect_check(response, _attributed_changed_files(), instruction) if check_observation: @@ -1430,32 +1519,11 @@ def _budget_reason( } ) continue - reason = _budget_reason(completed=True) - if reason: - return _finish_bounded(response, reason, reason == "completed") - transcript["final_assistant_content"] = response.get("content", []) - transcript_path = None - if not self._planning_read_only: - transcript_path = self._save_transcript_and_link(transcript) - self._save_session_snapshot(messages) - self._update_mission_state(status="completed", compact_summary=summarize_mission_state(self._mission_state) if self._mission_state else "") - if self._event_recorder is not None: - self._event_recorder.write_digest() - if self._debug_recorder is not None: - self._debug_recorder.write_final_summary( - status="completed", - termination_reason="completed", - total_turns=turns_used, - mission_id=self._mission_id, - ) - return { - "response": response, - "messages": messages, - "transcript_path": str(transcript_path) if transcript_path is not None else "", - "transcript": transcript, - } + reason = _budget_reason(completed=True) or "completed" + return _finish_bounded(response, reason, reason == "completed") tool_results: list[dict[str, Any]] = [] + force_no_progress_finalization = False for block in tool_uses: tool_name = block.get("name", "") tool_input = dict(block.get("input", {})) @@ -1493,21 +1561,17 @@ def _budget_reason( tool_name, tool_input, tool_use_id, len(messages) ) tool_calls_used += 1 + force_no_progress_finalization = ( + force_no_progress_finalization or bool(result.get("force_finalization", False)) + ) if self.small_model: result = self._truncate_tool_result(tool_name, result) if tool_name == "Read" and not result.get("is_error"): self._files_read.add(str(tool_input.get("file_path", ""))) if tool_name in {"Write", "Patch"} and not result.get("is_error"): - self._pending_verification = self._run_post_edit_verification( - trigger=f"{tool_name} execution" - ) self._patch_effect_check_pending = True self._patch_effect_check_attempts = 0 - elif tool_name == "Bash": - self._pending_verification = self._run_verification( - trigger=f"{tool_name} execution" - ) if result.get("is_error"): result_text = str(result.get("content", "")) @@ -1634,6 +1698,8 @@ def _budget_reason( ) self._pending_verification = "" messages.append({"role": "user", "content": next_user_content}) + if force_no_progress_finalization: + return _finish_bounded(response, "no_progress", False) reason = _budget_reason() if reason: @@ -1675,67 +1741,107 @@ def _run_patch_effect_check(self, response: dict[str, Any], changed_files: list[ for block in response.get("content", []) if isinstance(block, dict) and block.get("type") == "text" ).strip() - candidates = self._canonical_modified_paths(list(changed_files) + sorted(self._current_verification_targets) + sorted(self._intended_targets)) - target = next( - ( - path - for path in candidates - if path - and not path.startswith(("tmp", "temp", "debug", "scratch", ".villani", ".villani_code", "__pycache__")) - and (path.endswith(".py") or path.startswith(("src/", "app/", "lib/", "config/"))) - ), - "", + candidates = self._canonical_modified_paths( + list(changed_files) + + sorted(self._current_verification_targets) + + sorted(self._intended_targets) ) - if not target: + attempt = self._task_execution_context.attempt + candidate_summary = summarize_changes(candidates) + has_external_effect = any( + command.mutations.processes_started + or command.mutations.ports_opened + or "private-runtime" in command.path_classes + or "external/system" in command.path_classes + for command in attempt.commands + ) + if not candidate_summary.intentional and not has_external_effect: self._patch_effect_check_pending = False return "" - target_path = (self.repo / target).resolve() - if not target_path.exists() or not target_path.is_file(): + only_new_files = bool(candidate_summary.intentional) and all( + not self._task_execution_context.existed_at_attempt_start(self.repo / path) + for path in candidate_summary.intentional + ) + if only_new_files and not _is_code_change_oriented_request(objective) and not has_external_effect: self._patch_effect_check_pending = False return "" - patched_text = target_path.read_text(encoding="utf-8", errors="replace") - patched_excerpt = patched_text[:2400] - intended_effect = self._derive_intended_effect(text, target, objective) - syntax_result = "not_applicable" - if target.endswith(".py"): + + file_effects: list[dict[str, Any]] = [] + remaining_excerpt_chars = 600 + for relative_path in candidates[:10]: + path = (self.repo / relative_path).resolve() + excerpt = "" + state = "deleted-or-missing" + if path.exists() and path.is_file(): + state = "present" + if remaining_excerpt_chars > 0: + try: + excerpt = path.read_text(encoding="utf-8", errors="replace")[:remaining_excerpt_chars] + except OSError: + excerpt = "" + remaining_excerpt_chars -= len(excerpt) + file_effects.append({"path": relative_path, "state": state, "excerpt": excerpt}) + + cumulative_effect = { + "attempt": json.loads(attempt.compact_summary()), + "files": file_effects, + } + cumulative_text = json.dumps(cumulative_effect, indent=2) + if len(cumulative_text) > MAX_AGENT_ATTEMPT_STATE_SUMMARY_CHARS: + cumulative_text = cumulative_text[: MAX_AGENT_ATTEMPT_STATE_SUMMARY_CHARS - 16] + "\n[truncated]" + intended_effect = self._derive_intended_effect(text, ", ".join(candidates), objective) + legacy_syntax_result = "not_applicable" + syntax_target = next((item for item in file_effects if item["path"].endswith(".py")), None) + if syntax_target is not None: try: - ast.parse(patched_text) - syntax_result = "ok" + ast.parse(str(syntax_target["excerpt"])) + legacy_syntax_result = "ok" except SyntaxError as exc: - line = exc.lineno or "?" - syntax_result = f"syntax_error: {exc.msg} at line {line}" + legacy_syntax_result = f"syntax_error: {exc.msg} at line {exc.lineno or '?'}" prompt = ( - "You are checking whether a code edit actually matches its intended effect.\n\n" + "Check whether the cumulative effects of this entire attempt match its intended effect. " + "Do not focus on whichever file happened to be edited last. Consider every changed or " + "deleted file, command, observable side effect, validation result, and unresolved warning.\n\n" f"Task objective:\n{objective}\n\n" f"Intended effect:\n{intended_effect}\n\n" - f"Fresh patched code:\n```\n{patched_excerpt}\n```\n\n" - f"Syntax check:\n{syntax_result}\n\n" - "Does the patched code actually implement the intended effect?\n" + f"Cumulative attempt state:\n{cumulative_text}\n\n" "Answer exactly one of:\nYES\nNO: " ) self.event_callback( { "type": "patch_effect_critic_started", "canonical_modified_paths": candidates, - "target_file": target, - "syntax_check": syntax_result, + "attempt_state_summary": attempt.compact_summary(), } ) - critic_payload = {"model": self.model, "messages": [{"role": "user", "content": [{"type": "text", "text": prompt}]}], "system": "", "max_tokens": 120, "stream": False} + critic_payload = { + "model": self.model, + "messages": [{"role": "user", "content": [{"type": "text", "text": prompt}]}], + "system": "", + "max_tokens": 120, + "stream": False, + } critic_raw = self.client.create_message(critic_payload, stream=False) - critic_text = "\n".join(str(b.get("text", "")) for b in critic_raw.get("content", []) if isinstance(b, dict) and b.get("type") == "text").strip() + critic_text = "\n".join( + str(block.get("text", "")) + for block in critic_raw.get("content", []) + if isinstance(block, dict) and block.get("type") == "text" + ).strip() stripped = critic_text.lstrip() - if syntax_result.startswith("syntax_error"): - return f"Patch-effect check failed: {syntax_result}. Fix syntax before completion." + if legacy_syntax_result.startswith("syntax_error"): + self._patch_effect_check_pending = True + self._pending_verification = legacy_syntax_result + return f"Patch-effect check failed: {legacy_syntax_result}. Fix structure before completion." if stripped.startswith("YES"): self._patch_effect_check_pending = False return "" if stripped.startswith("NO:"): - return f"Patch-effect check mismatch: {critic_text[:400]}" + return f"Patch-effect check mismatch (cumulative attempt): {critic_text[:400]}" self._patch_effect_check_pending = False return ( - "Patch-effect check was inconclusive. No syntax error or concrete mismatch was found. " - "Do not create new verification/proof files. If the edited source already satisfies the task objective, provide the final answer." + "Patch-effect check was inconclusive for the cumulative attempt. Review all changed files, " + "commands, side effects, validation evidence, and warnings before claiming completion. " + "Do not create new verification/proof files solely to manufacture success evidence." ) def _derive_intended_effect(self, rationale: str, target: str, objective: str) -> str: @@ -1839,6 +1945,64 @@ def _inject_projected_context(self, messages: list[dict[str, Any]]) -> None: state_runtime.prepend_text_to_latest_safe_user_message(messages, rendered) + def _should_write_regular_trajectory_artifact(self) -> bool: + return ( + self._debug_config.enabled + and self._runtime_mode == "execution" + and not self._planning_read_only + and not self.small_model + and not self.villani_mode + and not self.benchmark_config.enabled + and self._debug_recorder is not None + ) + + def _should_write_regular_trace_artifacts(self) -> bool: + return self._debug_config.mode == DebugMode.TRACE and self._should_write_regular_trajectory_artifact() + + def _write_regular_trajectory_artifact( + self, + *, + transcript: dict[str, Any], + messages: list[dict[str, Any]], + status: str, + termination_reason: str | None, + ) -> None: + if not self._should_write_regular_trajectory_artifact() or self._debug_recorder is None: + return + self._debug_recorder.write_regular_trajectory_artifact( + transcript=transcript, + messages=messages, + status=status, + termination_reason=termination_reason, + redact=self.redact, + ) + + def _write_regular_trace_artifacts( + self, + *, + transcript: dict[str, Any], + messages: list[dict[str, Any]], + status: str, + termination_reason: str | None, + ) -> None: + if self._debug_recorder is None: + return + if not self._should_write_regular_trace_artifacts(): + self._write_regular_trajectory_artifact( + transcript=transcript, + messages=messages, + status=status, + termination_reason=termination_reason, + ) + return + self._debug_recorder.write_regular_trace_artifacts( + transcript=transcript, + messages=messages, + status=status, + termination_reason=termination_reason, + redact=self.redact, + ) + def _save_transcript_and_link(self, transcript: dict[str, Any]) -> Path: path = save_transcript(self.repo, transcript, redact=self.redact) self._update_mission_state(last_transcript_path=str(path)) diff --git a/villani_code/state_runtime.py b/villani_code/state_runtime.py index e4d9cc6e..9d079f3f 100644 --- a/villani_code/state_runtime.py +++ b/villani_code/state_runtime.py @@ -14,6 +14,7 @@ from villani_code.autonomy import VerificationStatus from villani_code.indexing import DEFAULT_IGNORE, RepoIndex from villani_code.live_display import apply_live_display_delta +from villani_code.permissions import Decision from villani_code.planning import TaskMode, generate_execution_plan from villani_code.project_memory import SessionState, ensure_project_memory, load_repo_map, update_session_state from villani_code.context_governance import ContextCompactor, ContextInclusionReason, ContextExclusionReason @@ -607,7 +608,7 @@ 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) + proc = subprocess.run(["git", "status", "--short"], cwd=repo, capture_output=True, text=True, stdin=subprocess.DEVNULL) return [line[3:].strip() for line in proc.stdout.splitlines() if line.strip()] @@ -710,7 +711,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) stdout = proc.stdout.strip() stderr = proc.stderr.strip() if proc.returncode != 0: @@ -769,7 +770,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) collect_stdout = collect_proc.stdout.strip() collect_stderr = collect_proc.stderr.strip() telemetry["collection_sanity_ran"] = True @@ -988,7 +989,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) stderr_lines = "\n".join([ln for ln in proc.stderr.splitlines() if ln][:5]) stdout = proc.stdout[:1500] cmd_results.append( @@ -1311,8 +1312,27 @@ def ensure_project_memory_and_plan(runner: Any, instruction: str) -> None: update_session_state(runner.repo, session) return + approval_payload = {"summary": plan.to_human_text(), "risk": plan.risk_level.value} + policy = runner.permissions.evaluate_with_reason( + "ExecutionPlan", + approval_payload, + bypass=runner.bypass_permissions, + auto_accept_edits=runner.auto_accept_edits, + ) + runner._emit_policy_event("ExecutionPlan", approval_payload, policy.decision, policy.reason) + if policy.decision == Decision.ALLOW: + runner.event_callback({"type": "plan_auto_approved", "risk": plan.risk_level.value}) + update_session_state(runner.repo, session) + return + if policy.decision == Decision.DENY: + runner.event_callback({"type": "plan_rejected"}) + session.outcome_status = "rejected" + session.next_step_hints = ["Revise plan scope or lower risk before retrying"] + update_session_state(runner.repo, session) + raise RuntimeError("Execution plan denied by permission policy.") + runner.event_callback({"type": "plan_approval_required", "risk": plan.risk_level.value}) - approved = runner.approval_callback("ExecutionPlan", {"summary": plan.to_human_text(), "risk": plan.risk_level.value}) + approved = runner.approval_callback("ExecutionPlan", approval_payload) if not approved: runner.event_callback({"type": "plan_rejected"}) session.outcome_status = "rejected" diff --git a/villani_code/state_tooling.py b/villani_code/state_tooling.py index c84c64ca..2aa58d6d 100644 --- a/villani_code/state_tooling.py +++ b/villani_code/state_tooling.py @@ -7,6 +7,11 @@ from pathlib import Path from typing import Any +from villani_code.execution_context import ( + MAX_AGENT_TOOL_RESULT_CHARS, + NO_PROGRESS_MESSAGE, + TRUNCATION_NOTICE, +) from villani_code.patch_apply import PatchApplyError, extract_unified_diff_targets, parse_unified_diff from villani_code.permissions import Decision from villani_code.repo_rules import classify_repo_path, is_ignored_repo_path @@ -174,13 +179,7 @@ def _analyze_patch_mutation( def _sanitize_tool_input_file_path(tool_input: dict[str, Any], repo: Path) -> None: - """Normalize and sanitize a `file_path` value in tool_input in-place. - - - Strip surrounding quotes - - Normalize backslashes to forward slashes - - If an absolute path points inside `repo`, convert to a repo-relative path - - Strip leading './' or leading '/' - """ + """Normalize a file path without remapping external absolute paths into the workspace.""" try: raw = tool_input.get("file_path") if not isinstance(raw, str): @@ -188,18 +187,16 @@ def _sanitize_tool_input_file_path(tool_input: dict[str, Any], repo: Path) -> No fp = raw.strip() if (fp.startswith('"') and fp.endswith('"')) or (fp.startswith("'") and fp.endswith("'")): fp = fp[1:-1].strip() - fp = fp.strip('"').strip("'") - fp = fp.replace("\\", "/") - p = Path(fp) - if p.is_absolute(): + fp = fp.strip('"').strip("'").replace("\\", "/") + path = Path(fp) + if path.is_absolute(): try: - rel = p.resolve().relative_to(repo.resolve()) - fp = str(rel).replace("\\", "/") - except Exception: - pass - fp = fp.lstrip("./") - if fp.startswith("/"): - fp = fp.lstrip("/") + fp = str(path.resolve().relative_to(repo.resolve())).replace("\\", "/") + except (OSError, ValueError): + tool_input["file_path"] = fp + return + while fp.startswith("./"): + fp = fp[2:] tool_input["file_path"] = fp except Exception: return @@ -515,7 +512,7 @@ def execute_tool_with_policy( "decision_source": "auto_approve_flag", } ) - elif runner.villani_mode: + elif runner.villani_mode and not getattr(runner, "force_interactive_approvals", False): runner.event_callback( { "type": "approval_auto_resolved", @@ -665,7 +662,23 @@ def _debug_callback_with_turn(event_type: str, payload: dict[str, Any]) -> None: unsafe=runner.unsafe, debug_callback=_debug_callback_with_turn, tool_call_id=stable_tool_use_id, + execution_context=getattr(runner, "_task_execution_context", None), ) + execution_context = getattr(runner, "_task_execution_context", None) + if not result.get("progress_recorded", False) and execution_context is not None: + warning, force = execution_context.record_tool_step( + tool_name, tool_input, is_error=bool(result.get("is_error", False)) + ) + if warning: + content = str(result.get("content", "")) + suffix = ("\n" if content else "") + NO_PROGRESS_MESSAGE + combined = content + suffix + if len(combined) > MAX_AGENT_TOOL_RESULT_CHARS: + room = MAX_AGENT_TOOL_RESULT_CHARS - len(TRUNCATION_NOTICE) - 1 + combined = combined[:room] + "\n" + TRUNCATION_NOTICE + result["content"] = combined + result["no_progress_warning"] = True + result["force_finalization"] = force runner.event_callback( { "type": "tool_result", diff --git a/villani_code/tools.py b/villani_code/tools.py index 423807ed..cc2b46f6 100644 --- a/villani_code/tools.py +++ b/villani_code/tools.py @@ -3,7 +3,7 @@ import glob import json import shutil -import subprocess +import shlex from pathlib import Path from typing import Any from urllib.parse import urlparse @@ -11,6 +11,12 @@ import httpx from pydantic import BaseModel, ConfigDict, Field +from villani_code.execution_context import ( + MAX_AGENT_TOOL_RESULT_CHARS, + TRUNCATION_NOTICE, + TaskExecutionContext, + compact_command_observation, +) from villani_code.patch_apply import ( PatchApplyError, apply_unified_diff_with_diagnostics, @@ -56,6 +62,8 @@ class BashInput(BaseModel): command: str cwd: str = "." timeout_sec: int = 30 + validation_kind: str = "command" + checks_final_behavior: bool = False class WriteInput(BaseModel): @@ -116,11 +124,18 @@ class SubmitPlanInput(BaseModel): def _error(message: str) -> dict[str, Any]: - return {"content": message, "is_error": True} + return {"content": _cap_tool_content(message), "is_error": True} + + +def _cap_tool_content(content: str) -> str: + if len(content) <= MAX_AGENT_TOOL_RESULT_CHARS: + return content + room = max(0, MAX_AGENT_TOOL_RESULT_CHARS - len(TRUNCATION_NOTICE) - 1) + return content[:room] + "\n" + TRUNCATION_NOTICE -def _ok(content: str) -> dict[str, Any]: - return {"content": content, "is_error": False} +def _ok(content: str, **metadata: Any) -> dict[str, Any]: + return {"content": _cap_tool_content(content), "is_error": False, **metadata} def tool_specs() -> list[dict[str, Any]]: @@ -143,6 +158,7 @@ def execute_tool( unsafe: bool = False, debug_callback: Any | None = None, tool_call_id: str = "", + execution_context: TaskExecutionContext | None = None, ) -> dict[str, Any]: model = TOOL_MODELS.get(name) if not model: @@ -158,13 +174,21 @@ def execute_tool( if name == "Read": return _ok(_run_read(parsed, repo, debug_callback=debug_callback, tool_call_id=tool_call_id)) if name == "Grep": - return _ok(_run_grep(parsed, repo)) + return _ok(_run_grep(parsed, repo, execution_context=execution_context)) if name == "Glob": return _ok(_run_glob(parsed, repo)) if name == "Search": - return _ok(_run_search(parsed, repo)) + return _ok(_run_search(parsed, repo, execution_context=execution_context)) if name == "Bash": - return _ok(_run_bash(parsed, repo, unsafe=unsafe, debug_callback=debug_callback, tool_call_id=tool_call_id)) + content, metadata = _run_bash( + parsed, + repo, + unsafe=unsafe, + debug_callback=debug_callback, + tool_call_id=tool_call_id, + execution_context=execution_context, + ) + return _ok(content, **metadata) if name == "Write": return _ok(_run_write(parsed, repo, debug_callback=debug_callback, tool_call_id=tool_call_id)) if name == "Patch": @@ -172,7 +196,7 @@ def execute_tool( if name == "WebFetch": return _ok(_run_webfetch(parsed)) if name.startswith("Git"): - return _ok(_run_git(name, parsed, repo)) + return _ok(_run_git(name, parsed, repo, execution_context=execution_context)) if name == "SubmitPlan": return _ok("Plan artifact submitted") except Exception as exc: @@ -180,6 +204,14 @@ def execute_tool( return _error("Unhandled tool") +def _is_within_workspace(path: Path, repo: Path) -> bool: + try: + path.resolve(strict=False).relative_to(repo.resolve(strict=False)) + return True + except (OSError, ValueError): + return False + + def _safe_path(repo: Path, raw: str) -> Path: path = (repo / raw).resolve() repo_resolved = repo.resolve() @@ -201,6 +233,11 @@ def _run_ls(data: LsInput, repo: Path) -> str: def _run_read(data: ReadInput, repo: Path, debug_callback: Any | None = None, tool_call_id: str = "") -> str: + requested = Path(data.file_path).expanduser() + if requested.is_absolute() and not _is_within_workspace(requested, repo): + raise ValueError( + "Read is workspace-only for this path. Use a shell command such as cat, sed, or head if you need to inspect system files." + ) path = _safe_path(repo, data.file_path) raw = path.read_bytes()[: data.max_bytes] if callable(debug_callback): @@ -208,15 +245,23 @@ def _run_read(data: ReadInput, repo: Path, debug_callback: Any | None = None, to return raw.decode("utf-8", errors="replace") -def _run_grep(data: GrepInput, repo: Path) -> str: +def _run_grep( + data: GrepInput, + repo: Path, + execution_context: TaskExecutionContext | None = None, +) -> str: base = _safe_path(repo, data.path) - rg_bin = shutil.which("rg") + context = execution_context or TaskExecutionContext(repo) + rg_bin = shutil.which("rg", path=context.environment.get("PATH")) if rg_bin: cmd = [rg_bin, "-n", data.pattern, str(base)] if data.include_hidden: cmd.append("--hidden") - proc = subprocess.run(cmd, capture_output=True, text=True) - return "\n".join(proc.stdout.splitlines()[: data.max_results]) + proc, record = context.run(shlex.join(cmd), repo, 30) + output = "\n".join(proc.stdout.splitlines()[: data.max_results]) + if record.warnings: + output = output + ("\n" if output else "") + "\n".join(record.warnings) + return output return "" @@ -225,40 +270,74 @@ def _run_glob(data: GlobInput, repo: Path) -> str: return "\n".join(sorted(hits)) -def _run_search(data: SearchInput, repo: Path) -> str: - rg_bin = shutil.which("rg") +def _run_search( + data: SearchInput, + repo: Path, + execution_context: TaskExecutionContext | None = None, +) -> str: + context = execution_context or TaskExecutionContext(repo) + rg_bin = shutil.which("rg", path=context.environment.get("PATH")) if not rg_bin: - return _run_grep(GrepInput(pattern=data.query, path=data.path), repo) + return _run_grep(GrepInput(pattern=data.query, path=data.path), repo, execution_context=context) base = _safe_path(repo, data.path) cmd = [rg_bin, "-n", "-C", str(data.context_lines), data.query, str(base)] - proc = subprocess.run(cmd, capture_output=True, text=True) - return proc.stdout + proc, record = context.run(shlex.join(cmd), repo, 30) + suffix = "\n" + "\n".join(record.warnings) if record.warnings else "" + return proc.stdout + suffix -def _run_bash(data: BashInput, repo: Path, unsafe: bool, debug_callback: Any | None = None, tool_call_id: str = "") -> str: +def _run_bash( + data: BashInput, + repo: Path, + unsafe: bool, + debug_callback: Any | None = None, + tool_call_id: str = "", + execution_context: TaskExecutionContext | None = None, +) -> tuple[str, dict[str, Any]]: lowered = data.command.lower() if not unsafe: for bad in DENYLIST: if bad in lowered: raise ValueError(f"Refusing command: {bad.strip()}") cwd = _safe_path(repo, data.cwd) - if callable(debug_callback): - debug_callback("command_started", {"command": data.command, "cwd": data.cwd, "tool_call_id": tool_call_id}) - proc = subprocess.run(data.command, shell=True, cwd=str(cwd), capture_output=True, text=True, timeout=data.timeout_sec) + context = execution_context or TaskExecutionContext(repo) if callable(debug_callback): debug_callback( - "command_finished", - { - "command": data.command, - "cwd": data.cwd, - "exit_code": proc.returncode, - "stdout": proc.stdout, - "stderr": proc.stderr, - "truncated": False, - "tool_call_id": tool_call_id, - }, + "command_started", + {"command": data.command, "cwd": data.cwd, "tool_call_id": tool_call_id}, ) - return json.dumps({"command": data.command, "exit_code": proc.returncode, "stdout": proc.stdout, "stderr": proc.stderr}, indent=2) + proc, record = context.run(data.command, cwd, data.timeout_sec) + evidence = context.record_validation( + record, + kind=data.validation_kind if data.validation_kind in {"project", "smoke"} else "command", + final_behavior=data.checks_final_behavior, + ) + compact = compact_command_observation( + command=data.command, + record=record, + stdout=proc.stdout, + stderr=proc.stderr, + evidence=evidence, + ) + full_debug_record = { + "command": data.command, + "cwd": data.cwd, + "exit_code": proc.returncode, + "timed_out": record.timed_out, + "stdout": proc.stdout, + "stderr": proc.stderr, + "execution_context": record.to_dict(), + "validation_evidence": evidence.to_dict(), + "tool_call_id": tool_call_id, + } + if callable(debug_callback): + debug_callback("command_finished", full_debug_record) + return json.dumps(compact, ensure_ascii=False), { + "timed_out": record.timed_out, + "force_finalization": record.force_finalization, + "no_progress_warning": record.no_progress_warning, + "progress_recorded": True, + } def _run_write(data: WriteInput, repo: Path, debug_callback: Any | None = None, tool_call_id: str = "") -> str: @@ -338,7 +417,12 @@ def _run_webfetch(data: WebFetchInput) -> str: return r.text[:10000] -def _run_git(name: str, data: GitSimpleInput, repo: Path) -> str: +def _run_git( + name: str, + data: GitSimpleInput, + repo: Path, + execution_context: TaskExecutionContext | None = None, +) -> str: mapping = { "GitStatus": ["status", "--short"], "GitDiff": ["diff"], @@ -348,5 +432,9 @@ def _run_git(name: str, data: GitSimpleInput, repo: Path) -> str: "GitCommit": ["commit"], } cmd = ["git", *mapping[name], *data.args] - proc = subprocess.run(cmd, cwd=str(repo), capture_output=True, text=True) - return proc.stdout or proc.stderr + context = execution_context or TaskExecutionContext(repo) + proc, record = context.run(shlex.join(cmd), repo, 30) + output = proc.stdout or proc.stderr + if record.warnings: + output = output + ("\n" if output else "") + "\n".join(record.warnings) + return output diff --git a/villani_code/trace_summary.py b/villani_code/trace_summary.py index c27afc60..165209ed 100644 --- a/villani_code/trace_summary.py +++ b/villani_code/trace_summary.py @@ -155,14 +155,19 @@ def normalize_repo_path(raw_path: Any, repo_root: Path | None) -> str: return normalized.lstrip("./") -def _extract_repo_root(run_dir: Path) -> Path | None: +def _load_session_meta(run_dir: Path) -> dict[str, Any]: session_meta = run_dir / "session_meta.json" if not session_meta.exists(): - return None + return {} try: body = json.loads(session_meta.read_text(encoding="utf-8")) except Exception: - return None + return {} + return body if isinstance(body, dict) else {} + + +def _extract_repo_root(run_dir: Path) -> Path | None: + body = _load_session_meta(run_dir) repo = body.get("repo") if isinstance(repo, str) and repo.strip(): return Path(repo) @@ -195,6 +200,7 @@ def _infer_tool_category(name: str) -> str: def build_tool_call_records_from_events(run_dir: Path) -> tuple[list[dict[str, Any]], list[str], list[str]]: events = load_events(run_dir / "events.jsonl") + session_meta = _load_session_meta(run_dir) repo_root = _extract_repo_root(run_dir) warnings: list[str] = [] validation_errors: list[str] = [] @@ -438,6 +444,7 @@ def write_tool_calls_from_events(run_dir: Path) -> Path: def aggregate_summary_from_events(run_dir: Path, *, status_override: str | None = None) -> dict[str, Any]: events = load_events(run_dir / "events.jsonl") + session_meta = _load_session_meta(run_dir) repo_root = _extract_repo_root(run_dir) warnings: list[str] = [] validation_errors: list[str] = [] @@ -626,6 +633,8 @@ def aggregate_summary_from_events(run_dir: Path, *, status_override: str | None summary: dict[str, Any] = { "run_id": run_id or run_dir.name, "status": status, + "agent": session_meta.get("agent"), + "model_metadata": session_meta.get("model_metadata"), "started_at": started_at_iso, "ended_at": ended_at_iso, "duration_ms": duration_ms,