diff --git a/.github/workflows/action-test.yml b/.github/workflows/action-test.yml new file mode 100644 index 0000000..36585f1 --- /dev/null +++ b/.github/workflows/action-test.yml @@ -0,0 +1,121 @@ +name: Action Self-Test + +# Runs the AppClaw GitHub Action against itself to validate it works end-to-end. +# Triggered when the action definition or its test workflow changes, and on +# manual dispatch. + +on: + push: + branches: [main] + paths: + - 'github-action/**' + - '.github/workflows/action-test.yml' + - 'flows/**' + workflow_dispatch: + inputs: + platform: + description: 'Platform to test' + required: false + default: 'android' + type: choice + options: [android, ios] + flow: + description: 'Flow file to run' + required: false + default: 'flows/youtube.yaml' + +concurrency: + group: action-test-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ── Android — YAML flow ──────────────────────────────────────────────────── + android-flow: + name: Android — YAML flow + runs-on: ubuntu-latest + if: github.event.inputs.platform == '' || github.event.inputs.platform == 'android' + + steps: + - uses: actions/checkout@v4 + + # Use the local action definition (same repo, same commit) + - uses: ./github-action + id: run + with: + flow: ${{ github.event.inputs.flow || 'flows/youtube.yaml' }} + platform: android + provider: gemini + api-key: ${{ secrets.LLM_API_KEY }} + report-name: action-test-android-flow-${{ github.run_id }} + + - name: Print report path + if: always() + run: echo "Report → ${{ steps.run.outputs.report-path }}" + + # ── Android — natural language goal ─────────────────────────────────────── + android-goal: + name: Android — natural language goal + runs-on: ubuntu-latest + if: github.event.inputs.platform == '' || github.event.inputs.platform == 'android' + + steps: + - uses: actions/checkout@v4 + + - uses: ./github-action + id: run + with: + goal: 'Open YouTube app and verify the home feed is visible' + platform: android + provider: gemini + api-key: ${{ secrets.LLM_API_KEY }} + max-steps: 10 + report-name: action-test-android-goal-${{ github.run_id }} + + - name: Print report path + if: always() + run: echo "Report → ${{ steps.run.outputs.report-path }}" + + # ── iOS — YAML flow (macOS runner) ───────────────────────────────────────── + ios-flow: + name: iOS — YAML flow + runs-on: macos-14 + if: github.event.inputs.platform == 'ios' + + steps: + - uses: actions/checkout@v4 + + - uses: ./github-action + id: run + with: + flow: ${{ github.event.inputs.flow || 'flows/youtube.yaml' }} + platform: ios + provider: gemini + api-key: ${{ secrets.LLM_API_KEY }} + report-name: action-test-ios-flow-${{ github.run_id }} + + - name: Print report path + if: always() + run: echo "Report → ${{ steps.run.outputs.report-path }}" + + # ── Validate: action installs AppClaw correctly (no device needed) ───────── + validate-install: + name: Validate — AppClaw installs correctly + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install AppClaw (latest) + run: npm install -g appclaw@latest + + - name: Verify binary is available + run: appclaw --help + + - name: Verify build compiles + run: | + npm ci + npm run build diff --git a/.github/workflows/layer3-branch-test.yml b/.github/workflows/layer3-branch-test.yml new file mode 100644 index 0000000..4be481c --- /dev/null +++ b/.github/workflows/layer3-branch-test.yml @@ -0,0 +1,96 @@ +name: AppClaw Action — Branch Test (Layer 3) + +# Layer 3 test — validates the action as an external caller would use it. +# References the branch by name (not ./github-action) so it exercises the +# full public action resolution path before the branch is tagged and published. +# +# Trigger: Actions tab → "AppClaw Action — Branch Test (Layer 3)" → Run workflow +# Required secret: LLM_API_KEY + +on: + push: + branches: [feat/parallel-testing-video-recording] + paths: ['github-action/**'] + workflow_dispatch: + inputs: + flow: + description: 'Path to YAML flow file in your repo' + required: false + default: 'flows/youtube.yaml' + goal: + description: 'Natural language goal (leave empty to use flow instead)' + required: false + default: '' + platform: + description: 'Platform to test' + required: false + default: 'android' + type: choice + options: [android, ios] + +jobs: + # ── Test 1: YAML flow on Android ──────────────────────────────────────────── + android-flow: + name: Android — YAML flow + runs-on: ubuntu-latest + if: inputs.platform == 'android' && inputs.goal == '' + + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@feat/parallel-testing-video-recording + id: run + with: + flow: ${{ inputs.flow }} + platform: android + provider: gemini + api-key: ${{ secrets.LLM_API_KEY }} + report-name: layer3-android-flow-${{ github.run_id }} + + - name: Show report path + if: always() + run: echo "Report → ${{ steps.run.outputs.report-path }}" + + # ── Test 2: Natural language goal on Android ───────────────────────────────── + android-goal: + name: Android — natural language goal + runs-on: ubuntu-latest + if: inputs.platform == 'android' && inputs.goal != '' + + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@feat/parallel-testing-video-recording + id: run + with: + goal: ${{ inputs.goal }} + platform: android + provider: gemini + api-key: ${{ secrets.LLM_API_KEY }} + report-name: layer3-android-goal-${{ github.run_id }} + + - name: Show report path + if: always() + run: echo "Report → ${{ steps.run.outputs.report-path }}" + + # ── Test 3: YAML flow on iOS ───────────────────────────────────────────────── + ios-flow: + name: iOS — YAML flow + runs-on: macos-14 + if: inputs.platform == 'ios' + + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@feat/parallel-testing-video-recording + id: run + with: + flow: ${{ inputs.flow }} + platform: ios + provider: gemini + api-key: ${{ secrets.LLM_API_KEY }} + report-name: layer3-ios-flow-${{ github.run_id }} + + - name: Show report path + if: always() + run: echo "Report → ${{ steps.run.outputs.report-path }}" diff --git a/flows/youtube.yaml b/flows/youtube.yaml index 52945de..ee4721f 100644 --- a/flows/youtube.yaml +++ b/flows/youtube.yaml @@ -4,7 +4,7 @@ steps: - Open youtube app - wait for search icon to be visible - Click on Search icon - - Enter appium 3.0 + - Type appium 3.0 - Click on the first result from the list - wait 3s - scroll down diff --git a/github-action/README.md b/github-action/README.md new file mode 100644 index 0000000..a87a74c --- /dev/null +++ b/github-action/README.md @@ -0,0 +1,289 @@ +# AppClaw Mobile Tests — GitHub Action + +[![GitHub Marketplace](https://img.shields.io/badge/Marketplace-AppClaw%20Mobile%20Tests-purple?logo=github)](https://github.com/marketplace/actions/appclaw-mobile-tests) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](../LICENSE) + +Run [AppClaw](https://github.com/AppiumTestDistribution/AppClaw) mobile UI automation flows and AI-driven goals directly in GitHub Actions — Android emulator or iOS simulator included, zero boilerplate. + +--- + +## Quick start + +### Android — run a YAML flow + +```yaml +name: Mobile Tests +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest # Android requires Ubuntu (KVM-enabled) + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + flow: flows/login.yaml + platform: android + api-key: ${{ secrets.LLM_API_KEY }} +``` + +### Android — run a natural language goal + +```yaml +- uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + goal: 'Open YouTube, search for Appium 3.0, verify the first result is visible' + platform: android + api-key: ${{ secrets.LLM_API_KEY }} +``` + +### iOS — run a YAML flow + +```yaml +jobs: + test: + runs-on: macos-14 # iOS requires macOS (Apple Silicon) + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + flow: flows/ios-login.yaml + platform: ios + api-key: ${{ secrets.LLM_API_KEY }} +``` + +--- + +## Inputs + +| Input | Required | Default | Description | +| ------------------------ | :------: | -------------------- | ------------------------------------------------------------------------------ | +| `flow` | one of¹ | — | Path to a YAML flow file relative to repo root | +| `goal` | one of¹ | — | Natural language goal executed by the LLM agent | +| `platform` | no | `android` | Target platform: `android` or `ios` | +| `provider` | no | `gemini` | LLM provider: `gemini`, `anthropic`, `openai`, `groq` | +| `api-key` | **yes** | — | LLM API key — stored as `LLM_API_KEY` in the environment | +| `model` | no | _(provider default)_ | LLM model ID to pin (e.g. `gemini-2.0-flash`, `claude-3-5-haiku-20241022`) | +| `agent-mode` | no | `dom` | `dom` (element locators) or `vision` (screenshot AI) | +| `max-steps` | no | `30` | Maximum agent steps before the run fails | +| `step-delay` | no | `500` | Milliseconds between steps | +| `android-api-level` | no | `33` | Android emulator API level (33 = Android 13) | +| `android-profile` | no | `pixel_6` | Android AVD hardware profile | +| `android-target` | no | `default` | Emulator target: `default` or `google_apis` | +| `cloud-provider` | no | _(local)_ | Cloud device provider: `lambdatest`. Leave empty for local emulator/simulator. | +| `lambdatest-username` | no² | — | LambdaTest account username | +| `lambdatest-access-key` | no² | — | LambdaTest access key | +| `lambdatest-device-name` | no² | — | Cloud device name (e.g. `Pixel 7`, `iPhone 14`) | +| `lambdatest-os-version` | no² | — | Cloud OS version (e.g. `13`, `16`) | +| `lambdatest-app` | no | — | LambdaTest app ID (`lt://APP...`) | +| `report` | no | `true` | Upload HTML report as a workflow artifact | +| `report-name` | no | `appclaw-report` | Name of the uploaded artifact | +| `appclaw-version` | no | `latest` | npm package version to pin (e.g. `0.1.7`) | + +¹ Provide either `flow` **or** `goal`, not both. +² Required when `cloud-provider: lambdatest`. + +## Outputs + +| Output | Description | +| ------------- | ----------------------------------------------------- | +| `report-path` | Path to the generated `.appclaw/runs//` directory | + +--- + +## Writing YAML flows + +```yaml +# flows/search.yaml +platform: android +--- +steps: + - open YouTube app + - wait for search icon to be visible + - tap Search + - type Appium 3.0 + - tap the first result + - wait 3 seconds + - scroll down + - verify screen has video uploaded by TestMu AI + - done +``` + +See the [AppClaw YAML flow docs](https://github.com/AppiumTestDistribution/AppClaw#yaml-flows) for the full syntax (phases, variables, parallel, assertions). + +--- + +## Secrets setup + +Go to your repo → **Settings → Secrets and variables → Actions → New repository secret**: + +| Secret name | Description | +| ------------- | ----------------------------------------------------------------------- | +| `LLM_API_KEY` | Your API key — works for any provider (Gemini, Anthropic, OpenAI, Groq) | + +--- + +## Examples + +### Parallel matrix — run multiple flows concurrently + +```yaml +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + flow: + - flows/login.yaml + - flows/search.yaml + - flows/checkout.yaml + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + flow: ${{ matrix.flow }} + platform: android + api-key: ${{ secrets.LLM_API_KEY }} + report-name: report-${{ strategy.job-index }} +``` + +### LambdaTest cloud devices (iOS on Ubuntu — no macOS runner needed) + +```yaml +jobs: + test: + runs-on: ubuntu-latest # LambdaTest handles the device — no macOS runner needed + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + flow: flows/ios-login.yaml + platform: ios + api-key: ${{ secrets.LLM_API_KEY }} + cloud-provider: lambdatest + lambdatest-username: ${{ secrets.LT_USERNAME }} + lambdatest-access-key: ${{ secrets.LT_ACCESS_KEY }} + lambdatest-device-name: 'iPhone 14' + lambdatest-os-version: '16' + lambdatest-app: ${{ secrets.LT_APP_ID }} +``` + +### Pin model for cost control + +```yaml +- uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + flow: flows/smoke.yaml + platform: android + api-key: ${{ secrets.LLM_API_KEY }} + model: 'gemini-2.0-flash' # cheaper/faster than pro +``` + +### Pin AppClaw version + +```yaml +- uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + flow: flows/smoke.yaml + platform: android + api-key: ${{ secrets.LLM_API_KEY }} + appclaw-version: '0.1.7' +``` + +### Use report path in a downstream step + +```yaml +- uses: AppiumTestDistribution/AppClaw/github-action@v1 + id: appclaw + with: + flow: flows/login.yaml + platform: android + api-key: ${{ secrets.LLM_API_KEY }} + +- name: Print report location + run: echo "Report at ${{ steps.appclaw.outputs.report-path }}" +``` + +### Vision mode (screenshot-based AI) + +```yaml +- uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + flow: flows/onboarding.yaml + platform: android + agent-mode: vision + api-key: ${{ secrets.LLM_API_KEY }} +``` + +### Nightly regression on a schedule + +```yaml +on: + schedule: + - cron: '0 2 * * *' # 2 AM UTC every night + +jobs: + nightly: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + flow: flows/full-regression.yaml + platform: android + api-key: ${{ secrets.LLM_API_KEY }} + report-name: nightly-report-${{ github.run_id }} +``` + +--- + +## Reports + +When `report: true` (default), an HTML report is uploaded as a workflow artifact after each run. Download it from the **Actions run summary → Artifacts**. The report includes: + +- Step-by-step screenshots with tap overlays +- Pass/fail status per step +- Execution timeline +- Screen recording (if `video: true` is set in your flow's `appClaw` options) + +To view reports locally after downloading: + +```bash +npx appclaw --report +``` + +--- + +## Runner requirements + +| Platform | Runner | Notes | +| --------- | --------------- | --------------------------------------------------- | +| `android` | `ubuntu-latest` | Free tier. KVM-enabled. Emulator boots in ~4–6 min. | +| `ios` | `macos-14` | Apple Silicon. macOS minutes cost ~10× Linux. | + +> **iOS tip:** For faster iOS CI, use [LambdaTest](https://github.com/AppiumTestDistribution/AppClaw#lambdatest) cloud devices on `ubuntu-latest` instead of a macOS runner. + +--- + +## Full example workflows + +Ready-to-copy workflow files are in the [`examples/`](./examples/) directory: + +| File | Description | +| ------------------------------------------------------- | --------------------------------------------- | +| [`android-flow.yml`](./examples/android-flow.yml) | Android YAML flow | +| [`android-goal.yml`](./examples/android-goal.yml) | Android natural language goal | +| [`ios-flow.yml`](./examples/ios-flow.yml) | iOS simulator YAML flow | +| [`matrix-parallel.yml`](./examples/matrix-parallel.yml) | Parallel matrix across multiple flows | +| [`full-pipeline.yml`](./examples/full-pipeline.yml) | Full CI/CD pipeline with lint + test + report | + +--- + +## License + +MIT © [AppiumTestDistribution](https://github.com/AppiumTestDistribution) diff --git a/github-action/action.yml b/github-action/action.yml new file mode 100644 index 0000000..39d2900 --- /dev/null +++ b/github-action/action.yml @@ -0,0 +1,311 @@ +name: 'AppClaw Mobile Tests' +description: 'Run mobile UI automation flows and AI-driven goals in CI — Android emulator, iOS simulator, or LambdaTest cloud devices.' +author: 'AppiumTestDistribution' + +branding: + icon: 'smartphone' + color: 'purple' + +# ── Inputs ──────────────────────────────────────────────────────────────────── + +inputs: + # ── What to run ───────────────────────────────────────────────────────────── + flow: + description: 'Path to a YAML flow file (mutually exclusive with goal)' + required: false + default: '' + goal: + description: 'Natural language goal for the LLM agent (mutually exclusive with flow)' + required: false + default: '' + + # ── Platform ───────────────────────────────────────────────────────────────── + platform: + description: 'Target platform: android or ios' + required: false + default: 'android' + + # ── LLM ────────────────────────────────────────────────────────────────────── + provider: + description: 'LLM provider: gemini, anthropic, openai, groq' + required: false + default: 'gemini' + api-key: + description: 'LLM API key — passed to AppClaw as LLM_API_KEY' + required: true + model: + description: 'LLM model ID to use (e.g. gemini-2.0-flash, claude-3-5-haiku-20241022). Defaults to the provider built-in.' + required: false + default: '' + + # ── Agent ──────────────────────────────────────────────────────────────────── + agent-mode: + description: 'Interaction strategy: dom (element locators) or vision (screenshot AI)' + required: false + default: 'dom' + max-steps: + description: 'Maximum agent steps before the run is marked failed. Default: 30' + required: false + default: '30' + step-delay: + description: 'Delay in milliseconds between steps. Default: 500' + required: false + default: '500' + + # ── Android emulator ───────────────────────────────────────────────────────── + android-api-level: + description: 'Android emulator API level. Default: 33 (Android 13)' + required: false + default: '33' + android-profile: + description: 'Android AVD hardware profile. Default: pixel_6' + required: false + default: 'pixel_6' + android-target: + description: 'Emulator system image target: default or google_apis' + required: false + default: 'default' + + # ── LambdaTest cloud ───────────────────────────────────────────────────────── + cloud-provider: + description: 'Cloud device provider: lambdatest. Leave empty for local emulator/simulator (default).' + required: false + default: '' + lambdatest-username: + description: 'LambdaTest account username (required when cloud-provider=lambdatest)' + required: false + default: '' + lambdatest-access-key: + description: 'LambdaTest access key (required when cloud-provider=lambdatest)' + required: false + default: '' + lambdatest-device-name: + description: 'Cloud device name, e.g. "Pixel 7" or "iPhone 14" (required when cloud-provider=lambdatest)' + required: false + default: '' + lambdatest-os-version: + description: 'Cloud OS version, e.g. "13" for Android or "16" for iOS (required when cloud-provider=lambdatest)' + required: false + default: '' + lambdatest-app: + description: 'LambdaTest app ID (lt://APP...) — the app to test on the cloud device' + required: false + default: '' + + # ── Report ─────────────────────────────────────────────────────────────────── + report: + description: 'Upload HTML report as a workflow artifact after the run. Default: true' + required: false + default: 'true' + report-name: + description: 'Name of the uploaded artifact. Default: appclaw-report' + required: false + default: 'appclaw-report' + + # ── AppClaw version ─────────────────────────────────────────────────────────── + appclaw-version: + description: 'AppClaw npm package version to install. Default: latest' + required: false + default: 'latest' + +# ── Outputs ─────────────────────────────────────────────────────────────────── + +outputs: + report-path: + description: 'Path to the generated .appclaw/runs// report directory' + value: ${{ steps.report-path.outputs.path }} + +# ── Steps ───────────────────────────────────────────────────────────────────── + +runs: + using: composite + steps: + # ── Validate ────────────────────────────────────────────────────────────── + - name: Validate inputs + shell: bash + run: | + if [ -z "${{ inputs.flow }}" ] && [ -z "${{ inputs.goal }}" ]; then + echo "::error title=Missing input::Provide either 'flow' (path to YAML) or 'goal' (natural language string)" + exit 1 + fi + if [ -n "${{ inputs.flow }}" ] && [ -n "${{ inputs.goal }}" ]; then + echo "::error title=Conflicting inputs::Provide either 'flow' or 'goal', not both" + exit 1 + fi + if [ "${{ inputs.platform }}" != "android" ] && [ "${{ inputs.platform }}" != "ios" ]; then + echo "::error title=Invalid platform::platform must be 'android' or 'ios', got '${{ inputs.platform }}'" + exit 1 + fi + if [ -n "${{ inputs.cloud-provider }}" ] && [ "${{ inputs.cloud-provider }}" != "lambdatest" ]; then + echo "::error title=Invalid cloud-provider::cloud-provider must be 'lambdatest' or empty, got '${{ inputs.cloud-provider }}'" + exit 1 + fi + if [ "${{ inputs.cloud-provider }}" = "lambdatest" ]; then + if [ -z "${{ inputs.lambdatest-username }}" ] || [ -z "${{ inputs.lambdatest-access-key }}" ]; then + echo "::error title=Missing LambdaTest credentials::lambdatest-username and lambdatest-access-key are required when cloud-provider=lambdatest" + exit 1 + fi + if [ -z "${{ inputs.lambdatest-device-name }}" ] || [ -z "${{ inputs.lambdatest-os-version }}" ]; then + echo "::error title=Missing device info::lambdatest-device-name and lambdatest-os-version are required when cloud-provider=lambdatest" + exit 1 + fi + fi + + # ── Node + AppClaw ──────────────────────────────────────────────────────── + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install AppClaw + shell: bash + run: | + echo "::group::Installing appclaw@${{ inputs.appclaw-version }}" + npm install -g appclaw@${{ inputs.appclaw-version }} + echo "::endgroup::" + + # ── LambdaTest — YAML flow ──────────────────────────────────────────────── + - name: Run YAML flow on LambdaTest + if: inputs.cloud-provider == 'lambdatest' && inputs.flow != '' + shell: bash + env: + LLM_PROVIDER: ${{ inputs.provider }} + LLM_API_KEY: ${{ inputs.api-key }} + LLM_MODEL: ${{ inputs.model }} + LLM_THINKING: 'off' + AGENT_MODE: ${{ inputs.agent-mode }} + MAX_STEPS: ${{ inputs.max-steps }} + STEP_DELAY: ${{ inputs.step-delay }} + PLATFORM: ${{ inputs.platform }} + CLOUD_PROVIDER: lambdatest + LAMBDATEST_USERNAME: ${{ inputs.lambdatest-username }} + LAMBDATEST_ACCESS_KEY: ${{ inputs.lambdatest-access-key }} + LAMBDATEST_DEVICE_NAME: ${{ inputs.lambdatest-device-name }} + LAMBDATEST_OS_VERSION: ${{ inputs.lambdatest-os-version }} + LAMBDATEST_APP: ${{ inputs.lambdatest-app }} + run: appclaw --flow "${{ inputs.flow }}" --platform ${{ inputs.platform }} + + # ── LambdaTest — natural language goal ──────────────────────────────────── + - name: Run goal on LambdaTest + if: inputs.cloud-provider == 'lambdatest' && inputs.goal != '' + shell: bash + env: + LLM_PROVIDER: ${{ inputs.provider }} + LLM_API_KEY: ${{ inputs.api-key }} + LLM_MODEL: ${{ inputs.model }} + LLM_THINKING: 'off' + AGENT_MODE: ${{ inputs.agent-mode }} + MAX_STEPS: ${{ inputs.max-steps }} + STEP_DELAY: ${{ inputs.step-delay }} + PLATFORM: ${{ inputs.platform }} + CLOUD_PROVIDER: lambdatest + LAMBDATEST_USERNAME: ${{ inputs.lambdatest-username }} + LAMBDATEST_ACCESS_KEY: ${{ inputs.lambdatest-access-key }} + LAMBDATEST_DEVICE_NAME: ${{ inputs.lambdatest-device-name }} + LAMBDATEST_OS_VERSION: ${{ inputs.lambdatest-os-version }} + LAMBDATEST_APP: ${{ inputs.lambdatest-app }} + run: appclaw "${{ inputs.goal }}" --platform ${{ inputs.platform }} + + # ── Android — enable KVM ────────────────────────────────────────────────── + - name: Enable KVM + if: inputs.platform == 'android' && inputs.cloud-provider == '' + shell: bash + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + # ── Android — YAML flow ─────────────────────────────────────────────────── + - name: Run YAML flow on Android emulator + if: inputs.platform == 'android' && inputs.cloud-provider == '' && inputs.flow != '' + uses: reactivecircus/android-emulator-runner@v2 + env: + LLM_PROVIDER: ${{ inputs.provider }} + LLM_API_KEY: ${{ inputs.api-key }} + LLM_MODEL: ${{ inputs.model }} + LLM_THINKING: 'off' + AGENT_MODE: ${{ inputs.agent-mode }} + MAX_STEPS: ${{ inputs.max-steps }} + STEP_DELAY: ${{ inputs.step-delay }} + PLATFORM: android + with: + api-level: ${{ inputs.android-api-level }} + profile: ${{ inputs.android-profile }} + target: ${{ inputs.android-target }} + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim + disable-animations: true + script: appclaw --flow "${{ inputs.flow }}" --platform android + + # ── Android — natural language goal ─────────────────────────────────────── + - name: Run goal on Android emulator + if: inputs.platform == 'android' && inputs.cloud-provider == '' && inputs.goal != '' + uses: reactivecircus/android-emulator-runner@v2 + env: + LLM_PROVIDER: ${{ inputs.provider }} + LLM_API_KEY: ${{ inputs.api-key }} + LLM_MODEL: ${{ inputs.model }} + LLM_THINKING: 'off' + AGENT_MODE: ${{ inputs.agent-mode }} + MAX_STEPS: ${{ inputs.max-steps }} + STEP_DELAY: ${{ inputs.step-delay }} + PLATFORM: android + with: + api-level: ${{ inputs.android-api-level }} + profile: ${{ inputs.android-profile }} + target: ${{ inputs.android-target }} + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim + disable-animations: true + script: appclaw "${{ inputs.goal }}" --platform android + + # ── iOS — YAML flow ─────────────────────────────────────────────────────── + - name: Run YAML flow on iOS simulator + if: inputs.platform == 'ios' && inputs.cloud-provider == '' && inputs.flow != '' + shell: bash + env: + LLM_PROVIDER: ${{ inputs.provider }} + LLM_API_KEY: ${{ inputs.api-key }} + LLM_MODEL: ${{ inputs.model }} + LLM_THINKING: 'off' + AGENT_MODE: ${{ inputs.agent-mode }} + MAX_STEPS: ${{ inputs.max-steps }} + STEP_DELAY: ${{ inputs.step-delay }} + PLATFORM: ios + DEVICE_TYPE: simulator + run: appclaw --flow "${{ inputs.flow }}" --platform ios + + # ── iOS — natural language goal ─────────────────────────────────────────── + - name: Run goal on iOS simulator + if: inputs.platform == 'ios' && inputs.cloud-provider == '' && inputs.goal != '' + shell: bash + env: + LLM_PROVIDER: ${{ inputs.provider }} + LLM_API_KEY: ${{ inputs.api-key }} + LLM_MODEL: ${{ inputs.model }} + LLM_THINKING: 'off' + AGENT_MODE: ${{ inputs.agent-mode }} + MAX_STEPS: ${{ inputs.max-steps }} + STEP_DELAY: ${{ inputs.step-delay }} + PLATFORM: ios + DEVICE_TYPE: simulator + run: appclaw "${{ inputs.goal }}" --platform ios + + # ── Report ──────────────────────────────────────────────────────────────── + - name: Find report path + id: report-path + if: always() + shell: bash + run: | + DIR=$(ls -td .appclaw/runs/*/ 2>/dev/null | head -1 || echo "") + echo "path=${DIR}" >> $GITHUB_OUTPUT + if [ -n "$DIR" ]; then + echo "::notice title=AppClaw Report::Report written to ${DIR}" + fi + + - name: Upload report artifact + if: ${{ always() && inputs.report == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.report-name }} + path: .appclaw/runs/ + if-no-files-found: warn diff --git a/github-action/examples/android-flow.yml b/github-action/examples/android-flow.yml new file mode 100644 index 0000000..7d7bcd4 --- /dev/null +++ b/github-action/examples/android-flow.yml @@ -0,0 +1,25 @@ +name: AppClaw — Android Flow + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: appclaw-android-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Android YAML Flow + runs-on: ubuntu-latest # Android emulator requires Ubuntu (KVM) + + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + flow: flows/youtube.yaml + platform: android + provider: gemini + api-key: ${{ secrets.LLM_API_KEY }} diff --git a/github-action/examples/android-goal.yml b/github-action/examples/android-goal.yml new file mode 100644 index 0000000..524ac84 --- /dev/null +++ b/github-action/examples/android-goal.yml @@ -0,0 +1,26 @@ +name: AppClaw — Android Goal (LLM Agent) + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: appclaw-goal-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Android Natural Language Goal + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + goal: 'Open YouTube, search for Appium 3.0, tap the first result, scroll down, verify a video by TestMu AI is visible' + platform: android + provider: gemini + api-key: ${{ secrets.LLM_API_KEY }} + max-steps: 20 diff --git a/github-action/examples/full-pipeline.yml b/github-action/examples/full-pipeline.yml new file mode 100644 index 0000000..2b5ab4c --- /dev/null +++ b/github-action/examples/full-pipeline.yml @@ -0,0 +1,85 @@ +name: AppClaw — Full CI/CD Pipeline + +on: + push: + branches: [main] + pull_request: + schedule: + - cron: '0 2 * * *' # nightly at 2 AM UTC + +concurrency: + group: appclaw-pipeline-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ── 1. Lint & build the flow files ────────────────────────────────────────── + validate: + name: Validate flows + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm ci + - name: Verify YAML flow parsing + run: npx tsx tests/verify-parsing.ts + + # ── 2. Smoke test — fast single flow ──────────────────────────────────────── + smoke: + name: Smoke test + needs: validate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@v1 + id: smoke + with: + flow: flows/youtube.yaml + platform: android + api-key: ${{ secrets.LLM_API_KEY }} + max-steps: 15 + report-name: smoke-report-${{ github.run_id }} + + - name: Report path + run: echo "Report at ${{ steps.smoke.outputs.report-path }}" + + # ── 3. Full regression — parallel flows (only on main / nightly) ──────────── + regression: + name: Regression / ${{ matrix.flow }} + needs: smoke + if: github.ref == 'refs/heads/main' || github.event_name == 'schedule' + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + flow: + - flows/login.yaml + - flows/search.yaml + - flows/checkout.yaml + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + flow: ${{ matrix.flow }} + platform: android + api-key: ${{ secrets.LLM_API_KEY }} + report-name: regression-${{ strategy.job-index }}-${{ github.run_id }} + + # ── 4. iOS smoke (only on main) ────────────────────────────────────────────── + ios-smoke: + name: iOS smoke test + needs: smoke + if: github.ref == 'refs/heads/main' + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + flow: flows/ios-smoke.yaml + platform: ios + api-key: ${{ secrets.LLM_API_KEY }} + report-name: ios-smoke-${{ github.run_id }} diff --git a/github-action/examples/ios-flow.yml b/github-action/examples/ios-flow.yml new file mode 100644 index 0000000..d594020 --- /dev/null +++ b/github-action/examples/ios-flow.yml @@ -0,0 +1,25 @@ +name: AppClaw — iOS Flow + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: appclaw-ios-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: iOS Simulator YAML Flow + runs-on: macos-14 # iOS simulator requires macOS (Apple Silicon) + + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + flow: flows/ios-smoke.yaml + platform: ios + provider: gemini + api-key: ${{ secrets.LLM_API_KEY }} diff --git a/github-action/examples/lambdatest-cloud.yml b/github-action/examples/lambdatest-cloud.yml new file mode 100644 index 0000000..7ba949f --- /dev/null +++ b/github-action/examples/lambdatest-cloud.yml @@ -0,0 +1,60 @@ +name: AppClaw — LambdaTest Cloud + +# Run flows on real cloud devices via LambdaTest. +# Works for both Android and iOS from ubuntu-latest — no macOS runner needed for iOS. +# +# Required secrets: +# LLM_API_KEY — your LLM provider key +# LT_USERNAME — LambdaTest username +# LT_ACCESS_KEY — LambdaTest access key +# LT_ANDROID_APP_ID — lt://APP... for Android app +# LT_IOS_APP_ID — lt://APP... for iOS app + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + # ── Android on LambdaTest ─────────────────────────────────────────────────── + android-cloud: + name: Android — LambdaTest cloud + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + flow: flows/youtube.yaml + platform: android + api-key: ${{ secrets.LLM_API_KEY }} + cloud-provider: lambdatest + lambdatest-username: ${{ secrets.LT_USERNAME }} + lambdatest-access-key: ${{ secrets.LT_ACCESS_KEY }} + lambdatest-device-name: 'Pixel 7' + lambdatest-os-version: '13' + lambdatest-app: ${{ secrets.LT_ANDROID_APP_ID }} + report-name: lt-android-${{ github.run_id }} + + # ── iOS on LambdaTest — from ubuntu-latest (no macOS runner needed) ────────── + ios-cloud: + name: iOS — LambdaTest cloud (ubuntu runner) + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + flow: flows/ios-smoke.yaml + platform: ios + api-key: ${{ secrets.LLM_API_KEY }} + cloud-provider: lambdatest + lambdatest-username: ${{ secrets.LT_USERNAME }} + lambdatest-access-key: ${{ secrets.LT_ACCESS_KEY }} + lambdatest-device-name: 'iPhone 14' + lambdatest-os-version: '16' + lambdatest-app: ${{ secrets.LT_IOS_APP_ID }} + report-name: lt-ios-${{ github.run_id }} diff --git a/github-action/examples/layer3-branch-test.yml b/github-action/examples/layer3-branch-test.yml new file mode 100644 index 0000000..ce8777e --- /dev/null +++ b/github-action/examples/layer3-branch-test.yml @@ -0,0 +1,94 @@ +name: AppClaw Action — Branch Test (Layer 3) + +# Drop this file into any repo's .github/workflows/ to test the action +# from the branch before it's merged and tagged. +# +# Usage: +# 1. Copy this file to your test repo at .github/workflows/layer3-test.yml +# 2. Add LLM_API_KEY to repo secrets (Settings → Secrets → Actions) +# 3. Push or trigger manually from the Actions tab + +on: + workflow_dispatch: + inputs: + flow: + description: 'Path to YAML flow file in your repo' + required: false + default: 'flows/youtube.yaml' + goal: + description: 'Natural language goal (leave empty to use flow instead)' + required: false + default: '' + platform: + description: 'Platform to test' + required: false + default: 'android' + type: choice + options: [android, ios] + +jobs: + # ── Test 1: YAML flow on Android ──────────────────────────────────────────── + android-flow: + name: Android — YAML flow + runs-on: ubuntu-latest + if: inputs.platform == 'android' && inputs.goal == '' + + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@feat/parallel-testing-video-recording + id: run + with: + flow: ${{ inputs.flow }} + platform: android + provider: gemini + api-key: ${{ secrets.LLM_API_KEY }} + report-name: layer3-android-flow-${{ github.run_id }} + + - name: Show report path + if: always() + run: echo "Report → ${{ steps.run.outputs.report-path }}" + + # ── Test 2: Natural language goal on Android ───────────────────────────────── + android-goal: + name: Android — natural language goal + runs-on: ubuntu-latest + if: inputs.platform == 'android' && inputs.goal != '' + + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@feat/parallel-testing-video-recording + id: run + with: + goal: ${{ inputs.goal }} + platform: android + provider: gemini + api-key: ${{ secrets.LLM_API_KEY }} + report-name: layer3-android-goal-${{ github.run_id }} + + - name: Show report path + if: always() + run: echo "Report → ${{ steps.run.outputs.report-path }}" + + # ── Test 3: YAML flow on iOS ───────────────────────────────────────────────── + ios-flow: + name: iOS — YAML flow + runs-on: macos-14 + if: inputs.platform == 'ios' + + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@feat/parallel-testing-video-recording + id: run + with: + flow: ${{ inputs.flow }} + platform: ios + provider: gemini + api-key: ${{ secrets.LLM_API_KEY }} + report-name: layer3-ios-flow-${{ github.run_id }} + + - name: Show report path + if: always() + run: echo "Report → ${{ steps.run.outputs.report-path }}" diff --git a/github-action/examples/matrix-parallel.yml b/github-action/examples/matrix-parallel.yml new file mode 100644 index 0000000..7254aeb --- /dev/null +++ b/github-action/examples/matrix-parallel.yml @@ -0,0 +1,30 @@ +name: AppClaw — Parallel Matrix + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + name: ${{ matrix.flow }} + runs-on: ubuntu-latest + strategy: + fail-fast: false # run all flows even if one fails + matrix: + flow: + - flows/login.yaml + - flows/search.yaml + - flows/checkout.yaml + - flows/profile.yaml + + steps: + - uses: actions/checkout@v4 + + - uses: AppiumTestDistribution/AppClaw/github-action@v1 + with: + flow: ${{ matrix.flow }} + platform: android + api-key: ${{ secrets.LLM_API_KEY }} + # Give each job a unique artifact name so they don't collide + report-name: report-${{ strategy.job-index }}-${{ github.run_id }} diff --git a/landing/usage.html b/landing/usage.html index fa78ba1..cf8d37c 100644 --- a/landing/usage.html +++ b/landing/usage.html @@ -1105,7 +1105,7 @@

Usage Guide

  • runFlow()
  • runGoal()
  • run()
  • -
  • Reports
  • +
  • Reports & Video
  • Vitest / Jest
  • CI Scripts
  • Options Reference
  • @@ -2579,6 +2579,44 @@

    Reports

    // ↑ writes report to .appclaw/runs/<runId>/ +

    Screen recording

    +

    + Pass video: true to record the screen for the entire run and embed the + video in the report. Recording starts automatically on the first run() call + and stops in teardown(). +

    + +
    +
    +
    + TypeScript +
    +
    const app = new AppClaw({
    +  provider:   'gemini',
    +  apiKey:     process.env.GEMINI_API_KEY,
    +  platform:   'android',
    +  reportName: 'YouTube Search',
    +  video:      true,               // record screen for the whole run
    +});
    +
    +await app.run('open YouTube app');
    +await app.run('tap Search');
    +await app.run('type Appium 3.0');
    +await app.run('tap the search button');
    +
    +await app.teardown();
    +// ↑ report includes recording.mp4 under the Recording tab
    +
    + +
    +
    Parallel-safe
    +

    + Each AppClaw instance records its own session independently — parallel + tests do not interfere. Port allocation (MJPEG, system port) is also handled + automatically per instance. +

    +
    +

    Viewing the report

    Run the built-in report server after your tests complete:

    @@ -2839,6 +2877,17 @@

    Options Reference

    'AppClaw SDK Run' Name shown in the report viewer. + + video + boolean + false + + Record the screen for the entire run and embed the video under the + Recording tab in the report. Recording starts on the first + run() call and stops automatically in teardown(). + Requires Appium screen recording support. + + mcpTransport string diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7926f99 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4237 @@ +{ + "name": "appclaw", + "version": "0.1.7", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "appclaw", + "version": "0.1.7", + "dependencies": { + "@ai-sdk/anthropic": "^1.0.0", + "@ai-sdk/google": "^3.0.43", + "@ai-sdk/openai": "^1.0.0", + "@modelcontextprotocol/sdk": "^1.22.0", + "ai": "^6.0.72", + "ai-sdk-ollama": "3.8.3", + "boxen": "^8.0.1", + "chalk": "^5.6.2", + "cli-spinners": "^3.4.0", + "cli-table3": "^0.6.5", + "df-vision": "1.1.76", + "dotenv": "^17.3.1", + "fast-xml-parser": "^4.5.0", + "gradient-string": "^3.0.0", + "marked": "^15.0.12", + "marked-terminal": "^7.3.0", + "mjpeg-consumer": "2.0.0", + "sharp": "^0.33.5", + "yaml": "^2.8.3", + "zod": "^3.23.0" + }, + "bin": { + "appclaw": "bin/appclaw.js" + }, + "devDependencies": { + "@types/bun": "^1.1.0", + "@types/gradient-string": "^1.1.6", + "prettier": "^3.8.1", + "tsx": "^4.21.0", + "typescript": "^5.6.0", + "vitest": "^4.1.2" + } + }, + "../device-farm/packages/stark-vision": { + "name": "df-vision", + "version": "1.1.76", + "license": "MIT", + "dependencies": { + "@google/genai": "^1.44.0", + "async-retry": "^1.3.3" + }, + "devDependencies": { + "@types/async-retry": "^1.4.9", + "@types/node": "^20.11.0", + "remove-files-webpack-plugin": "^1.5.0", + "typescript": "5.6.2", + "webpack": "^5.103.0", + "webpack-cli": "^6.0.1", + "webpack-node-externals": "^3.0.0", + "webpack-obfuscator": "^3.6.0" + } + }, + "../device-farm/packages/stark-vision/node_modules/typescript": { + "version": "5.6.2", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@ai-sdk/anthropic": { + "version": "1.2.12", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.95", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23", + "@vercel/oidc": "3.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.23", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google": { + "version": "3.0.53", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.21" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.21", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "1.3.24", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "1.1.3", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "2.2.8", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "nanoid": "^3.3.8", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.28.0", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "dev": true, + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/bun": { + "version": "1.3.11", + "dev": true, + "license": "MIT", + "dependencies": { + "bun-types": "1.3.11" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/gradient-string": { + "version": "1.1.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tinycolor2": "*" + } + }, + "node_modules/@types/node": { + "version": "25.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/tinycolor2": { + "version": "1.4.6", + "license": "MIT" + }, + "node_modules/@vercel/oidc": { + "version": "3.1.0", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ai": { + "version": "6.0.158", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.95", + "@ai-sdk/provider": "3.0.8", + "@ai-sdk/provider-utils": "4.0.23", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ai-sdk-ollama": { + "version": "3.8.3", + "license": "MIT", + "dependencies": { + "@ai-sdk/provider": "^3.0.8", + "@ai-sdk/provider-utils": "^4.0.23", + "jsonrepair": "^3.13.3", + "ollama": "^0.6.3" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "ai": "^6.0.154" + } + }, + "node_modules/ai-sdk-ollama/node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ai-sdk-ollama/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.23", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ai/node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ai/node_modules/@ai-sdk/provider-utils": { + "version": "4.0.23", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.6" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-align/node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boxen": { + "version": "8.0.1", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^8.0.0", + "chalk": "^5.3.0", + "cli-boxes": "^3.0.0", + "string-width": "^7.2.0", + "type-fest": "^4.21.0", + "widest-line": "^5.0.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bun-types": { + "version": "1.3.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/camelcase": { + "version": "8.0.0", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-highlight": { + "version": "2.1.11", + "license": "ISC", + "dependencies": { + "chalk": "^4.0.0", + "highlight.js": "^10.7.1", + "mz": "^2.4.0", + "parse5": "^5.1.1", + "parse5-htmlparser2-tree-adapter": "^6.0.0", + "yargs": "^16.0.0" + }, + "bin": { + "highlight": "bin/highlight" + }, + "engines": { + "node": ">=8.0.0", + "npm": ">=5.0.0" + } + }, + "node_modules/cli-highlight/node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-highlight/node_modules/chalk": { + "version": "4.1.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-spinners": { + "version": "3.4.0", + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-table3/node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-table3/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/cli-table3/node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-table3/node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/df-vision": { + "resolved": "../device-farm/packages/stark-vision", + "link": true + }, + "node_modules/dotenv": { + "version": "17.3.1", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "license": "MIT" + }, + "node_modules/emojilib": { + "version": "2.4.0", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.3.1", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-xml-parser": { + "version": "4.5.5", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gradient-string": { + "version": "3.0.0", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "tinygradient": "^1.1.5" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hono": { + "version": "4.12.9", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.1.0", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "license": "BSD-2-Clause" + }, + "node_modules/jsonrepair": { + "version": "3.13.3", + "license": "ISC", + "bin": { + "jsonrepair": "bin/cli.js" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/marked": { + "version": "15.0.12", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/marked-terminal": { + "version": "7.3.0", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "ansi-regex": "^6.1.0", + "chalk": "^5.4.1", + "cli-highlight": "^2.1.11", + "cli-table3": "^0.6.5", + "node-emoji": "^2.2.0", + "supports-hyperlinks": "^3.1.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "marked": ">=1 <16" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mjpeg-consumer": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-emoji": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/ollama": { + "version": "0.6.3", + "license": "MIT", + "dependencies": { + "whatwg-fetch": "^3.6.20" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parse5": { + "version": "5.1.1", + "license": "MIT" + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "parse5": "^6.0.1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "6.0.1", + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.0", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/router": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "license": "BSD-3-Clause" + }, + "node_modules/semver": { + "version": "7.7.4", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/sharp": { + "version": "0.33.5", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/skin-tone": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "unicode-emoji-modifier-base": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strnum": { + "version": "1.1.2", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "dev": true, + "license": "MIT" + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinygradient": { + "version": "1.1.5", + "license": "MIT", + "dependencies": { + "@types/tinycolor2": "^1.4.0", + "tinycolor2": "^1.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.21.0", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/widest-line": { + "version": "5.0.0", + "license": "MIT", + "dependencies": { + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yaml": { + "version": "2.8.3", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "license": "MIT", + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json index babc86b..2935dd6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "appclaw", - "version": "0.1.6", + "version": "0.1.7", "description": "Agentic AI layer for mobile automation via appium-mcp", "type": "module", "main": "dist/sdk/index.js", @@ -32,6 +32,7 @@ "format:check": "prettier --check .", "test": "vitest run tests/flow tests/sdk", "test:e2e": "vitest run tests/e2e/", + "test:e2e:android": "MCP_DEBUG=1 vitest run tests/e2e/android.test.ts", "test:watch": "vitest tests/flow tests/sdk", "build:vsix": "cd vscode-extension && npm run build && npx vsce package", "deploy:landing": "npm run deploy --prefix landing" diff --git a/src/agent/loop.ts b/src/agent/loop.ts index bb68ea1..ac19880 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -15,8 +15,9 @@ import { typeViaKeyboard, detectDeviceUdid, pressEnterKey } from '../mcp/keyboar import type { LLMProvider, AgentContext, ToolCallDecision } from '../llm/provider.js'; import type { ActionResult } from '../llm/schemas.js'; import { getScreenState } from '../perception/screen.js'; -import { diffScreen, computeScreenHash } from '../perception/screen-diff.js'; +import { diffScreen, computePerceptionHash } from '../perception/screen-diff.js'; import { createStuckDetector } from './stuck.js'; +import { shouldPreferVisionLocateTap } from './vision-tap-policy.js'; import { createRecoveryEngine } from './recovery.js'; import { askUser, classifyHITLRequest } from './human-in-the-loop.js'; import { tapAtCoordinates, isAIElement, parseAIElementCoords } from './element-finder.js'; @@ -233,7 +234,7 @@ export async function runAgent(options: AgentOptions): Promise { detectedPlatform = screen.platform; const diff = diffScreen(prevDom, screen.dom); - const screenHash = computeScreenHash(screen.dom); + const screenHash = computePerceptionHash(screen.dom, screen.screenshot); prevDom = screen.dom; // ─── 2. CHECKPOINT (on screen changes) ─────────────── @@ -316,6 +317,10 @@ export async function runAgent(options: AgentOptions): Promise { if (stuck.isStuck(goal)) { ui.stopSpinner(); ui.printStuck(step + 1); + const stuckSignals = stuck.getLastSignals(); + if (stuckSignals.length > 0) { + ui.printStepDetail(`stuck signals: ${stuckSignals.join(', ')}`); + } const failedActions = history.slice(-3).map((h) => h.action); const alternatives = recovery.suggestAlternatives(failedActions); @@ -495,65 +500,72 @@ export async function runAgent(options: AgentOptions): Promise { const reason = (decision.args.reason as string) ?? 'Goal completed'; // ── Post-done verification ────────────────────────── - // Guards against the LLM hallucinating goal completion. - // Take a fresh screenshot and ask the LLM to verify the goal is actually achieved. - // Only verify once per done attempt — if verification itself fails, trust the LLM. - if (captureScreenshot && llm.supportsVision && step > 0) { - try { - await sleep(stepDelay); - const verifyScreen = await getScreenState(mcp, maxElements, true, skipPageSource, false); - if (verifyScreen.screenshot) { - const verifyDecision = await llm.getDecision({ - goal: - `VERIFICATION: The agent claims the goal "${goal}" is achieved because: "${reason}". ` + - `Look at the screenshot carefully. Is the goal ACTUALLY achieved? ` + - `If YES → call "done" with the same reason. ` + - `If NO → call the appropriate action tool to continue working toward the goal. ` + - `Be strict: if the keyboard is still covering the screen, if the expected content is not visible, ` + - `or if the action clearly didn't complete, the goal is NOT achieved.`, - step, - maxSteps, - dom: verifyScreen.dom, - screenshot: verifyScreen.screenshot, - lastResult: `Agent called done with reason: "${reason}". Verifying...`, - screenChanges: { - changed: false, - addedCount: 0, - removedCount: 0, - summary: 'Verification step', - }, - platform: detectedPlatform, - }); + // Always verify before accepting "done" — guards against LLM hallucinating completion. + // Verification uses screenshot when available (vision mode) or DOM alone (DOM mode). + // Runs on every step including step 0 to catch immediate false positives. + try { + await sleep(stepDelay); + const verifyScreen = await getScreenState( + mcp, + maxElements, + captureScreenshot, + skipPageSource, + false + ); + if (verifyScreen.dom || verifyScreen.screenshot) { + const verifyDecision = await llm.getDecision({ + goal: + `VERIFICATION: The agent claims the goal "${goal}" is achieved because: "${reason}". ` + + `Study the current screen state carefully (screenshot and/or DOM). Is the goal ACTUALLY and FULLY achieved? ` + + `If YES → call "done" with the same reason. ` + + `If NO → call the appropriate action tool to continue working toward the goal. ` + + `Be strict: if the keyboard is still covering the screen, if the expected content is not visible, ` + + `or if the action clearly didn't complete, the goal is NOT achieved.`, + step, + maxSteps, + dom: verifyScreen.dom, + screenshot: verifyScreen.screenshot, + lastResult: `Agent called done with reason: "${reason}". Verifying...`, + screenChanges: { + changed: false, + addedCount: 0, + removedCount: 0, + summary: 'Verification step', + }, + platform: detectedPlatform, + }); - if (verifyDecision.usage) { - totalInputTokens += verifyDecision.usage.inputTokens; - totalOutputTokens += verifyDecision.usage.outputTokens; - totalCachedTokens += verifyDecision.usage.cachedTokens ?? 0; - } + if (verifyDecision.usage) { + totalInputTokens += verifyDecision.usage.inputTokens; + totalOutputTokens += verifyDecision.usage.outputTokens; + totalCachedTokens += verifyDecision.usage.cachedTokens ?? 0; + } - if (verifyDecision.toolName !== 'done') { - // Verification rejected — the goal is NOT actually achieved - ui.printWarning(`Done rejected by verification — continuing`); - lastResult = - `⚠️ VERIFICATION FAILED: You called "done" but the screenshot does NOT confirm the goal is achieved. ` + - `Continue working toward the goal. Do NOT call "done" again until you have verified success visually.`; - llm.feedToolResult(lastResult); - // Use the verification's screenshot for the next step - postActionScreenshot = verifyScreen.screenshot; - cachedPostScreen = verifyScreen; - history.push({ - step, - action: 'done_rejected', - decision, - result: lastResult, - screenHash, - }); - continue; // Back to the top of the loop - } + if (verifyDecision.toolName !== 'done') { + // Verification rejected — the goal is NOT actually achieved + ui.printWarning(`Done rejected by verification — continuing`); + lastResult = + `⚠️ VERIFICATION FAILED: You called "done" but the screen state does NOT confirm the goal is achieved. ` + + `Continue working toward the goal. Do NOT call "done" again until you have verified success on screen.`; + llm.feedToolResult(lastResult); + // Use the verification's screen for the next step + postActionScreenshot = verifyScreen.screenshot; + cachedPostScreen = verifyScreen; + history.push({ + step, + action: 'done_rejected', + decision, + result: lastResult, + screenHash, + }); + continue; // Back to the top of the loop } - } catch { - // Verification failed to execute — trust the LLM's original judgment } + } catch (err) { + // Verification failed to execute — log but accept the done to avoid blocking indefinitely + ui.printWarning( + `Done verification failed: ${err instanceof Error ? err.message : String(err)}` + ); } // ── Episodic memory: save winning trajectories ───── @@ -853,9 +865,13 @@ async function executeMetaTool( if (isVisionMode) { // ══ VISION MODE: AI vision only, no DOM locators ══ + // Keypad / single-digit targets: skip normalized fast-tap — LLM coords often hit + // adjacent keys (e.g. 5 vs backspace). Vision locate matches the labeled key. + const skipFastTapCoords = shouldPreferVisionLocateTap(selector); + // Fast path: LLM provided 0-1000 normalized coordinates — skip vision locate entirely // Uses same scaleCoordinates() from df-vision as the playground - if (tapX != null && tapY != null) { + if (tapX != null && tapY != null && !skipFastTapCoords) { const scaled = await scaleLLMCoords(tapX, tapY); const tapped = await tapAtCoordinates(mcp, scaled.x, scaled.y); if (tapped) { @@ -870,6 +886,10 @@ async function executeMetaTool( } attempts.push(`llm_coords [${scaled.x},${scaled.y}]: tap failed`); // Fall through to vision locate as backup + } else if (skipFastTapCoords && tapX != null && tapY != null && mcpDebug) { + console.log( + ` [vision-tap-policy] Ignoring LLM tap coords for keypad-like selector — using vision locate` + ); } if (isVisionLocateEnabled()) { diff --git a/src/agent/planner.ts b/src/agent/planner.ts index 54b35c9..9eabc31 100644 --- a/src/agent/planner.ts +++ b/src/agent/planner.ts @@ -168,7 +168,7 @@ Overlays include: Return "adapt" ONLY if you see an overlay in the DOM that is blocking the goal. The adapted goal should describe how to dismiss the specific overlay (tap a specific element from the DOM). -Return "done" if the current sub-goal is clearly already achieved on the current screen. +Return "done" ONLY if the current sub-goal is UNAMBIGUOUSLY and FULLY achieved — meaning there is specific, visible evidence in the DOM confirming completion (e.g., the correct screen is showing, the element is in the expected state, the expected text/value is visible). When in any doubt, return "continue". Return "continue" for everything else — this is the DEFAULT. When in doubt, return "continue". @@ -380,10 +380,11 @@ CRITICAL: Do NOT assume the screen matches what you'd expect after the completed Your job is to decide ONE of three actions: -**skip** — The sub-goal is ALREADY achieved on the current screen. For example: -- Sub-goal is "Open Settings" but Settings is already open -- Sub-goal is "Navigate to WiFi settings" but WiFi settings are already visible -- Sub-goal is "Enter email address" but the address is already in the field +**skip** — The sub-goal is CLEARLY and UNAMBIGUOUSLY already achieved on the current screen, with specific visible evidence. For example: +- Sub-goal is "Open Settings" but Settings is already open (DOM shows Settings screen elements) +- Sub-goal is "Navigate to WiFi settings" but WiFi settings are already visible in DOM +- Sub-goal is "Enter email address" but the address is already present in the field in the DOM +Only skip when there is CONCRETE evidence in the DOM/screenshot — never skip based on assumptions. **rewrite** — The sub-goal needs adaptation because the screen state is different than expected. For example: - Sub-goal is "Navigate to X" but X is already visible — rewrite to the actual action needed @@ -422,6 +423,7 @@ Rules: 9. IMPORTANT: When a sub-goal involves typing/entering text into a field, use the word "Type" explicitly (not "Enter" which is ambiguous). Example: "Type 'hello@email.com' into the To field" NOT "Enter hello@email.com in the recipient field" 10. FIELD-SPECIFIC SUB-GOALS: When a screen has multiple input fields, create a separate sub-goal for EACH field that needs to be filled. Always name the specific field in the sub-goal (e.g., "Type 'X' into the [field name] field"). NEVER say "Type into the field" without specifying WHICH field. The agent needs to know exactly which field to target. 11. ITERATIVE FEEDBACK LOOPS — Do NOT decompose tasks that require repeated action→observe→adapt cycles. If completing the goal requires submitting input, reading a response, and submitting again based on that response (e.g., word puzzles, quizzes, multi-round challenges), keep ALL of those cycles inside ONE sub-goal. Splitting "type", "tap submit", and "analyze result" into separate sub-goals destroys the agent's ability to adapt between rounds — it loses the context of previous results. The single sub-goal description should explain the full loop: what to input, how to submit, and how to interpret the feedback for the next round. +12. FIXED-ORDER KEYPAD / PIN / CODE — When the user specifies an exact multi-digit value (duration, PIN, OTP, room number, etc.), the plan must make the digit order unambiguous: either state the full sequence explicitly in one sub-goal, or split into one sub-goal per digit. Avoid a single vague step like "enter the digits" or "tap the keys in order" with no sequence — the executor cannot infer order reliably. Examples of SIMPLE goals (no decomposition needed): - "Open Settings" → simple (single app launch) diff --git a/src/agent/stuck.ts b/src/agent/stuck.ts index 2aaaa48..d86235b 100644 --- a/src/agent/stuck.ts +++ b/src/agent/stuck.ts @@ -6,9 +6,17 @@ * Returns context-aware recovery hints. */ +/** Keypad / timer / PIN style goals — hash may alternate while correcting digits. */ +export function isDataEntryLikeGoal(goal: string): boolean { + return /\b(type|digit|key|keypad|pin|passcode|timer|duration|hours?|minutes?|seconds?|code)\b|['']?\d{3,}/i.test( + goal + ); +} + export interface StuckDetector { recordAction(action: string, screenHash: string): void; isStuck(goal?: string): boolean; + getLastSignals(): string[]; getRecoveryHint(goal: string): string; /** Get a DOM-aware recovery hint that identifies untried interactive elements */ getDOMRecoveryHint(goal: string, currentDom: string, triedSelectors: string[]): string; @@ -21,6 +29,7 @@ export function createStuckDetector(windowSize: number = 8): StuckDetector { const recentHashes: string[] = []; let unchangedCount = 0; let stuckCount = 0; + let lastSignals: string[] = []; return { recordAction(action: string, screenHash: string) { @@ -82,11 +91,28 @@ export function createStuckDetector(windowSize: number = 8): StuckDetector { } } - const stuck = allSameAction || allSameHash || highRepetition || oscillating; + // Keypad/timer entry can flip between two hashes (mistype → backspace → retry). + // Do not treat that ABAB pattern alone as a stuck "toggle oscillation". + const oscillationCountsAsStuck = oscillating && !(goal && isDataEntryLikeGoal(goal)); + + // For keypad/timer/PIN entry, repeating `find_and_click` is expected while + // entering each next digit. Treat repetition as stuck only for non-entry goals. + const repetitionCountsAsStuck = + goal && isDataEntryLikeGoal(goal) ? false : allSameAction || highRepetition; + + const stuck = repetitionCountsAsStuck || allSameHash || oscillationCountsAsStuck; + lastSignals = []; + if (repetitionCountsAsStuck) lastSignals.push('repetition'); + if (allSameHash) lastSignals.push('unchanged'); + if (oscillationCountsAsStuck) lastSignals.push('oscillation'); if (stuck) stuckCount++; return stuck; }, + getLastSignals(): string[] { + return [...lastSignals]; + }, + getRecoveryHint(goal: string): string { // Check for oscillation (toggling back and forth) let isOscillating = false; @@ -96,13 +122,27 @@ export function createStuckDetector(windowSize: number = 8): StuckDetector { } if (isOscillating) { + if (isDataEntryLikeGoal(goal)) { + return ( + 'OSCILLATION (keypad / timer / PIN style): The UI is flipping between two states — usually you are tapping ' + + 'ADJACENT keys (e.g. a digit vs backspace/delete) or the same wrong key twice. This is NOT a toggle that ' + + 'means the goal is done.\n\n' + + '1. Read the LARGE on-screen value — compare it to what you still need to enter.\n' + + '2. Tap ONLY the next correct digit key, aiming at the CENTER of that key.\n' + + '3. Do NOT alternate backspace and the same digit in a loop. Use backspace at most once per real mistake.\n' + + '4. If coordinate taps (tapX/tapY) keep missing, call find_and_click WITHOUT tapX/tapY so vision locate runs.\n' + + '5. Call "done" ONLY when the displayed value matches the goal — not before.' + ); + } return ( - 'CRITICAL — OSCILLATION DETECTED: The screen is toggling between TWO states because you keep tapping the same element. ' + - 'This means the FIRST tap SUCCEEDED (it changed the state), and each subsequent tap UNDOES it. ' + - 'Your goal "' + + 'OSCILLATION DETECTED: The screen is toggling between two states. STOP TAPPING.\n\n' + + '1. Study the CURRENT SCREENSHOT carefully — what state is visible RIGHT NOW?\n' + + '2. Does the CURRENT visible state match what your goal "' + goal + - '" was ALREADY ACHIEVED on the first tap. ' + - 'You MUST call "done" NOW. Do NOT tap anything else — you are undoing your own work.' + '" requires?\n' + + '3. If YES (correct state visible) → call "done" and describe exactly what you see that confirms it.\n' + + '4. If NO (wrong state visible) → tap ONCE more, then verify the result before doing anything else.\n\n' + + 'Call "done" only based on what you can ACTUALLY SEE on screen — not assumptions.' ); } @@ -128,13 +168,14 @@ export function createStuckDetector(windowSize: number = 8): StuckDetector { if (isToggleGoal) { return ( - 'CRITICAL: You have been stuck for multiple rounds repeating the same action. ' + - 'STOP and think: your goal was "' + + 'STUCK ON TOGGLE: You have been repeating the same toggle action multiple times. STOP.\n\n' + + '1. Look at the CURRENT SCREENSHOT — what is the actual state of the toggle/switch right now?\n' + + '2. Does the current visible state match what the goal "' + goal + - '". ' + - 'The goal is VERY LIKELY ALREADY ACHIEVED. The action you keep repeating probably succeeded on the first try. ' + - 'You MUST call "done" NOW with a reason explaining the goal was completed. ' + - 'Do NOT tap, scroll, or interact again — call "done" immediately.' + '" requires?\n' + + '3. If YES (correct state visible on screen) → call "done" describing what you see.\n' + + '4. If NO (wrong state visible) → the toggle may have been reversed by repeated taps. Tap it ONCE and verify.\n\n' + + 'Do NOT call "done" based on assumptions — only on what you can clearly see on screen.' ); } @@ -169,9 +210,9 @@ export function createStuckDetector(windowSize: number = 8): StuckDetector { if (isToggleGoal) { hint += - 'You are tapping repeatedly on what appears to be a toggle action. ' + - 'The toggle LIKELY ALREADY SUCCEEDED on your first tap. Tapping again UNDOES the toggle! ' + - 'Call "done" NOW — the goal is achieved.\n\n'; + 'You are tapping repeatedly on a toggle. STOP and check the screenshot: ' + + 'what is the CURRENT visible state of the toggle? If it already shows the correct state, call "done" with that observation. ' + + 'If it shows the wrong state, tap it ONCE more and verify before deciding.\n\n'; } else { hint += 'Your tap actions are having NO EFFECT. Likely causes:\n' + @@ -267,6 +308,7 @@ export function createStuckDetector(windowSize: number = 8): StuckDetector { recentHashes.length = 0; unchangedCount = 0; stuckCount = 0; + lastSignals = []; }, }; } diff --git a/src/agent/vision-tap-policy.ts b/src/agent/vision-tap-policy.ts new file mode 100644 index 0000000..59394a3 --- /dev/null +++ b/src/agent/vision-tap-policy.ts @@ -0,0 +1,23 @@ +/** + * Vision-mode tap policy: when the LLM names a single keypad/timer key, raw tapX/tapY + * from the screenshot is error-prone (adjacent keys, backspace). Prefer vision locate + * (same idea as tree-index taps in tools like Droidrun — use a dedicated localizer, not + * a one-shot normalized guess). + */ + +export function shouldPreferVisionLocateTap(selector: string): boolean { + const s = selector.toLowerCase(); + if (!s.trim()) return false; + + if (/backspace|delete key|\bx\b.*(keypad|numpad|number)|(keypad|numpad).*\bx\b/.test(s)) { + return true; + } + if (/\bdigit\s*[0-9]\b/.test(s)) return true; + if (/\bkey\s*[0-9]\b/.test(s)) return true; + if (/\bnumber\s*[0-9]\b/.test(s)) return true; + if (/\b[0-9]\s*(key|button|digit)\b/.test(s)) return true; + if (/\b(press|tap)\s+[0-9]\b/.test(s)) return true; + if (/\bdouble\s*0\b|\b00\b.*(key|button)/.test(s)) return true; + + return false; +} diff --git a/src/config.ts b/src/config.ts index 151c823..4d7b0b1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -92,9 +92,25 @@ const envSchema = z.object({ /** Enable extended thinking/reasoning for supported providers (anthropic, gemini, openai) */ LLM_THINKING: z.enum(['on', 'off']).default('on'), - /** Max tokens the model can use for thinking (budget). Higher = deeper reasoning but slower + more expensive. */ + /** + * Gemini 2.5: thinking token budget (0 = off, -1 = dynamic per Google). + * Gemini 3.x: prefer LLM_GEMINI_THINKING_LEVEL; budget is not sent for 3.x to avoid odd interactions on 3 Pro. + * Anthropic: extended thinking budget. + */ LLM_THINKING_BUDGET: z.coerce.number().default(128), + /** + * Gemini 3.x only — reasoning depth (https://ai.google.dev/gemini-api/docs/thinking). + * Ignored for Gemini 2.5 (those use LLM_THINKING_BUDGET). + */ + LLM_GEMINI_THINKING_LEVEL: z.enum(['minimal', 'low', 'medium', 'high']).default('medium'), + + /** When Gemini thinking is on, request thought summaries in the API stream (includeThoughts). */ + LLM_GEMINI_INCLUDE_THOUGHTS: z + .enum(['true', 'false']) + .default('true') + .transform((v) => v === 'true'), + /** * If > 0, screenshots sent to the agent/planner LLM are downscaled so max(width,height) ≤ this value (aspect preserved). * Does not affect Stark vision or raw Appium captures — only multimodal model input. 0 = disabled. diff --git a/src/flow/run-yaml-flow.ts b/src/flow/run-yaml-flow.ts index 04e0f85..3a56378 100644 --- a/src/flow/run-yaml-flow.ts +++ b/src/flow/run-yaml-flow.ts @@ -978,30 +978,47 @@ export async function executeStep( return { success: false, message: 'Vision could not parse drag response' }; } const dragAction = dragActions[0]; - if (!dragAction || dragAction.action !== 'drag' || (dragAction.locators?.length ?? 0) < 2) { - const locCount = dragAction?.locators?.length ?? 0; - let msg: string; - if (!dragAction || dragAction.action !== 'drag') { - msg = `"${step.from}" and "${step.to}" are not visible on screen`; - } else if (locCount === 0) { - msg = `"${step.from}" and "${step.to}" are not visible on screen`; - } else { - // One locator found — assume "from" was found, "to" is missing - msg = `"${step.to}" is not visible on screen`; - } - return { success: false, message: msg }; - } - const fromCoords = dragAction.locators[0].coordinates as [number, number] | undefined; - const toCoords = dragAction.locators[1].coordinates as [number, number] | undefined; - if (!fromCoords || !toCoords) { - return { success: false, message: 'Drag: vision returned no coordinates' }; - } + const fromLocatorFallback = dragAction?.locators?.[0]; + const toLocatorFallback = dragAction?.locators?.[1]; + // Source must be found and visible + if (!dragAction || dragAction.action !== 'drag' || !fromLocatorFallback?.coordinates) { + return { + success: false, + message: `"${step.from}" is not visible on screen`, + }; + } + const fromCoords = fromLocatorFallback.coordinates as [number, number]; if (fromCoords[0] === 0 && fromCoords[1] === 0) { return { success: false, message: `Drag failed: "${step.from}" is not visible on screen` }; } - if (toCoords[0] === 0 && toCoords[1] === 0) { - return { success: false, message: `Drag failed: "${step.to}" is not visible on screen` }; + + // Destination: use locator coordinates if valid; otherwise infer direction from step.to / step.from + const rawToCoords = toLocatorFallback?.coordinates as [number, number] | undefined; + const hasValidTo = + rawToCoords && rawToCoords.length >= 2 && !(rawToCoords[0] === 0 && rawToCoords[1] === 0); + + let toCoords: [number, number]; + if (hasValidTo) { + toCoords = rawToCoords!; + } else { + // Infer direction from the destination description or overall instruction + // Stark coordinates are [y, x] in 0-1000 normalized space + const directionSource = `${step.to} ${step.from}`.toLowerCase(); + const DRAG_OFFSET = 200; + let dy = 0, + dx = 0; + if (/\bright\b/.test(directionSource)) dx = DRAG_OFFSET; + else if (/\bleft\b/.test(directionSource)) dx = -DRAG_OFFSET; + else if (/\bdown\b/.test(directionSource)) dy = DRAG_OFFSET; + else if (/\bup\b/.test(directionSource)) dy = -DRAG_OFFSET; + else { + return { success: false, message: `"${step.to}" is not visible on screen` }; + } + toCoords = [ + Math.max(0, Math.min(1000, fromCoords[0] + dy)), + Math.max(0, Math.min(1000, fromCoords[1] + dx)), + ]; } const fromBbox = scaleDragCoords(fromCoords, dragScreenSize); diff --git a/src/flow/vision-execute.ts b/src/flow/vision-execute.ts index 64c7b91..d9b36c6 100644 --- a/src/flow/vision-execute.ts +++ b/src/flow/vision-execute.ts @@ -537,6 +537,16 @@ export async function visionExecute( const t0 = performance.now(); const rawResponse = await client.understandAndLocate(instruction, imageBase64); logTiming('understandAndLocate', Math.round(performance.now() - t0)); + if (mcpDebug) + console.log( + ` ${theme.dim('vision')} ${theme.info('raw response:')} ${theme.dim(String(rawResponse).slice(0, 300))}` + ); + if (!rawResponse) { + return { + step: { kind: 'tap', label: instruction, verbatim: instruction } as FlowStep, + result: { success: false, message: 'Vision returned empty response' }, + }; + } const cleanedText = rawResponse.trim().replace(/(^```json|```$)/g, ''); let actions: any[]; @@ -587,12 +597,89 @@ export async function visionExecute( const value = action.value ?? null; const locators = action.locators ?? []; - // Swipe/scroll + if (mcpDebug) + console.log( + ` ${theme.dim('vision')} ${theme.info('parsed:')} action=${theme.warn(actionName)} value=${theme.dim(String(value))} locators=${theme.dim(JSON.stringify(locators).slice(0, 200))}` + ); + + // Swipe/scroll — full-screen (action IS the direction word) but only if no element locators if (SWIPE_ACTIONS.has(actionName)) { const direction = actionName as 'up' | 'down' | 'left' | 'right'; - const step: FlowStep = { kind: 'swipe', direction, verbatim: instruction }; - await mcp.callTool('appium_scroll', { direction }); - return { step, result: { success: true, message: `Swiped ${direction}` } }; + const firstLocator = locators[0]; + const firstCoords = firstLocator?.coordinates; + const hasElementCoords = + firstCoords && firstCoords.length >= 2 && !(firstCoords[0] === 0 && firstCoords[1] === 0); + + if (!hasElementCoords) { + const step: FlowStep = { kind: 'swipe', direction, verbatim: instruction }; + // appium_scroll only supports up/down; use appium_swipe for left/right + const scrollTool = + direction === 'left' || direction === 'right' ? 'appium_swipe' : 'appium_scroll'; + await mcp.callTool(scrollTool, { direction }); + return { step, result: { success: true, message: `Swiped ${direction}` } }; + } + // Has element coords — fall through to element-targeted swipe below + } + + // Element-targeted swipe: vision returns action=direction + locators, or action="swipe" + locators + // e.g. "swipe the font size slider to the right" → action:"swipe"|"right", locators[0]=slider + if ((SWIPE_ACTIONS.has(actionName) || actionName === 'swipe') && locators.length > 0) { + const elementLocator = locators[0]; + const coords = elementLocator?.coordinates; + const hasElementCoords = coords && coords.length >= 2 && !(coords[0] === 0 && coords[1] === 0); + + // Resolve direction: action name itself (when vision returns direction as action), then value, then instruction text + const valueStr = value ? String(value).toLowerCase().trim() : ''; + const direction = ( + SWIPE_ACTIONS.has(actionName) + ? actionName + : SWIPE_ACTIONS.has(valueStr) + ? valueStr + : /\bright\b/.test(instruction.toLowerCase()) + ? 'right' + : /\bleft\b/.test(instruction.toLowerCase()) + ? 'left' + : /\bdown\b/.test(instruction.toLowerCase()) + ? 'down' + : /\bup\b/.test(instruction.toLowerCase()) + ? 'up' + : null + ) as 'up' | 'down' | 'left' | 'right' | null; + + if (direction && hasElementCoords) { + const [ey, ex] = coords!; + const DRAG_OFFSET = 200; + const dy = direction === 'down' ? DRAG_OFFSET : direction === 'up' ? -DRAG_OFFSET : 0; + const dx = direction === 'right' ? DRAG_OFFSET : direction === 'left' ? -DRAG_OFFSET : 0; + const ty = Math.max(0, Math.min(1000, ey + dy)); + const tx = Math.max(0, Math.min(1000, ex + dx)); + const step: FlowStep = { kind: 'swipe', direction, verbatim: instruction }; + const fromBbox = scaleCoordinates([ey, ex], screenSize); + const toBbox = scaleCoordinates([ty, tx], screenSize); + const dragResult = await mcp.callTool('appium_drag_and_drop', { + sourceX: Math.round(fromBbox.center.x), + sourceY: Math.round(fromBbox.center.y), + targetX: Math.round(toBbox.center.x), + targetY: Math.round(toBbox.center.y), + duration: 600, + longPressDuration: 400, + }); + const dragText = + dragResult.content?.map((c: any) => (c.type === 'text' ? c.text : '')).join('') ?? ''; + const dragSuccess = + !dragText.toLowerCase().includes('failed') && !dragText.toLowerCase().includes('error'); + return { + step, + result: { + success: dragSuccess, + message: dragSuccess + ? `Swiped "${elementLocator.element}" ${direction}` + : `Swipe failed: ${dragText.slice(0, 200)}`, + }, + }; + } + + // Direction or coords missing — fall through to tap handler below } // Back @@ -691,27 +778,56 @@ export async function visionExecute( verbatim: instruction, }; - if (!fromLocator?.coordinates || !toLocator?.coordinates) { - const msg = !fromLocator?.coordinates - ? `"${step.from}" is not visible on screen` - : `"${step.to}" is not visible on screen`; - return { step, result: { success: false, message: msg } }; + if (!fromLocator?.coordinates) { + return { + step, + result: { success: false, message: `"${step.from}" is not visible on screen` }, + }; } const [fy, fx] = fromLocator.coordinates; - const [ty, tx] = toLocator.coordinates; - if (fy === 0 && fx === 0) { return { step, result: { success: false, message: `Drag failed: "${step.from}" is not visible on screen` }, }; } - if (ty === 0 && tx === 0) { - return { - step, - result: { success: false, message: `Drag failed: "${step.to}" is not visible on screen` }, - }; + + // Determine destination coordinates. + // When the vision model returns a directional drag (slider/seekbar), the destination + // locator is either missing, empty, or just a direction word ("right", "left") with + // zero/null coordinates. In that case, compute the destination from the source + + // a normalized offset derived from the direction keyword in the instruction or to-element. + let ty: number, tx: number; + const hasValidToCoords = + toLocator?.coordinates && + toLocator.coordinates.length >= 2 && + !(toLocator.coordinates[0] === 0 && toLocator.coordinates[1] === 0); + + if (hasValidToCoords) { + [ty, tx] = toLocator!.coordinates; + } else { + // Try to infer direction from destination element name, then fall back to full instruction. + // Stark coordinates are [y, x] in 0-1000 normalized space. + const directionSource = `${toLocator?.element ?? ''} ${instruction}`.toLowerCase(); + const DRAG_OFFSET = 200; // ~20% of screen — enough to move a slider visibly + let dy = 0, + dx = 0; + if (/\bright\b/.test(directionSource)) dx = DRAG_OFFSET; + else if (/\bleft\b/.test(directionSource)) dx = -DRAG_OFFSET; + else if (/\bdown\b/.test(directionSource)) dy = DRAG_OFFSET; + else if (/\bup\b/.test(directionSource)) dy = -DRAG_OFFSET; + else { + return { + step, + result: { + success: false, + message: `"${step.to || 'destination'}" is not visible on screen`, + }, + }; + } + ty = Math.max(0, Math.min(1000, fy + dy)); + tx = Math.max(0, Math.min(1000, fx + dx)); } const fromBbox = scaleCoordinates([fy, fx], screenSize); @@ -733,7 +849,7 @@ export async function visionExecute( result: { success: dragSuccess, message: dragSuccess - ? `Dragged "${step.from}" to "${step.to}"` + ? `Dragged "${step.from}" ${step.to ? `to "${step.to}"` : `(directional)`}` : `Drag failed: ${dragText.slice(0, 200)}`, }, }; diff --git a/src/index.ts b/src/index.ts index 8e2d9c5..a25744c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -910,10 +910,8 @@ async function main() { // Reset action history between sub-goals for clean context llm.resetHistory(); - // Each sub-goal gets a proportional share of max steps - const stepsPerGoal = planResult.isComplex - ? Math.max(10, Math.floor(config.MAX_STEPS / executor.all.length)) - : config.MAX_STEPS; + // Each sub-goal gets the full MAX_STEPS budget + const stepsPerGoal = config.MAX_STEPS; // ─── Screen-aware orchestration ───────────────────── // Before executing, check the screen and decide: skip, rewrite, or proceed diff --git a/src/llm/prompts.ts b/src/llm/prompts.ts index 1b4ae3d..af08a1a 100644 --- a/src/llm/prompts.ts +++ b/src/llm/prompts.ts @@ -69,6 +69,7 @@ This skips the separate vision-locate step and executes the action much faster. Example: find_and_click(selector="search icon top right", tapX=950, tapY=70) Example: find_and_type(selector="search bar at center top", text="hello", tapX=500, tapY=120) The system handles scaling to device coordinates. If your coordinates miss, it falls back to vision locate. +**Exception:** For single keypad digits, backspace, or "key N" targets, tap coordinates are ignored and vision locate runs — normalized guesses are too inaccurate on dense pads. **Writing good descriptions** — be specific about: - Visible text: "button labeled 'Send'", "field with hint 'Subject'" @@ -84,7 +85,14 @@ Some apps (games, custom UIs) have on-screen keyboards where you must tap each k - Then tap each letter key one by one in the correct order. - After the last letter, tap the submit/enter/confirm key. - Use the screenshot to locate each key by its label and position on the keyboard. -- After submitting, ALWAYS read the full screen feedback (colors, highlights, error messages) before deciding your next input. If the game rejects your input (e.g. "not a word"), delete it and choose a different valid input.`; +- After submitting, ALWAYS read the full screen feedback (colors, highlights, error messages) before deciding your next input. If the game rejects your input (e.g. "not a word"), delete it and choose a different valid input. + +**NUMERIC PADS (timers, alarms, PINs, passcodes, dialers):** +- Read the LARGE on-screen value first. Enter digits strictly in order until it matches the target (hours/minutes/seconds or code). +- If the value is already correct through the first N digits, tap ONLY the next digit — do not use backspace unless you see a wrong digit you must erase. +- Wrong key or accidental backspace: use backspace ONCE, then re-type only the tail digits you removed — never loop backspace + the same digit repeatedly. +- Aim at the visual CENTER of each key; wrong rows often hit backspace or an adjacent number. +- If two taps in a row with tapX/tapY missed the intended key, omit tapX/tapY on the next find_and_click so the pipeline uses vision locate (more accurate than guessed coordinates).`; /** Shared rules for both modes */ const SHARED_RULES = ` @@ -102,6 +110,7 @@ RULES 4. AFTER TAPPING — after find_and_click, check: did the screen change? If it looks the same, your tap may have missed. Try a different description. 5. OVERLAYS — if anything is covering the screen (dialog, popup, dropdown, suggestions), handle it FIRST. 6. NO REPETITION — if an action failed, try something DIFFERENT. Never repeat the same failing action. +6b. ORDERED DIGITS (timer, PIN, passcode) — "Different" means the **next correct** digit/key you have not successfully entered yet. It does **not** mean delete/backspace unless the visible value clearly has a wrong extra digit. Never tap delete to explore or to satisfy "try something different." 7. GO_BACK — only for dismissing popups or intentional navigation. Never mid-form — it discards your data. 8. DONE — only when the goal is fully achieved AND verified in the screenshot. 9. STAY FOCUSED — only your current goal. Ignore pending sub-goals. @@ -243,7 +252,9 @@ export function buildUserMessage(context: AgentContext): string { } parts.push( - `\n⚡ Is the goal "${context.goal}" ALREADY achieved based on what you see? If yes, call "done".` + `\n⚡ COMPLETION CHECK: Is the goal "${context.goal}" fully and verifiably achieved? ` + + `Only call "done" if you can point to SPECIFIC VISIBLE EVIDENCE on screen confirming completion. ` + + `If there is any doubt, take one more verification action first.` ); return parts.join('\n'); @@ -259,12 +270,13 @@ export function buildUserMessage(context: AgentContext): string { function buildContextualHints(context: AgentContext): string { const hints: string[] = []; - // Low on steps — push toward decisive action + // Low on steps — push toward decisive action, but still require evidence const stepRatio = (context.step + 1) / context.maxSteps; if (stepRatio > 0.7) { const remaining = context.maxSteps - context.step - 1; hints.push( - `⏳ LOW ON STEPS (${remaining} left) — prioritize direct actions. If the goal looks achieved, call "done" now.` + `⏳ LOW ON STEPS (${remaining} left) — take the most direct action available. ` + + `Only call "done" if you can see clear, specific evidence in the screenshot that the goal is fully achieved.` ); } diff --git a/src/llm/provider.ts b/src/llm/provider.ts index c20dcfe..638cf53 100644 --- a/src/llm/provider.ts +++ b/src/llm/provider.ts @@ -226,9 +226,15 @@ function buildMetaTools(agentMode: 'dom' | 'vision'): Record { return { done: tool({ description: - 'Signal that the goal has been achieved. Call this as soon as the task is complete.', + 'Signal that the goal has been achieved. ONLY call this when you can see SPECIFIC, OBSERVABLE EVIDENCE on the current screen that the goal is fully complete. ' + + 'Your reason MUST describe what you can see on screen that proves completion (e.g., "The timer shows 16:20", "WiFi toggle is now ON", "Message sent confirmation visible"). ' + + 'Never call done based on assumptions or because an action was performed — only when you can verify the result on screen.', inputSchema: z.object({ - reason: z.string().describe('Why the goal is complete'), + reason: z + .string() + .describe( + 'What you can SEE on screen right now that proves the goal is complete (cite specific visible elements, text, or state)' + ), }), }), @@ -283,10 +289,18 @@ function buildMetaTools(agentMode: 'dom' | 'vision'): Record { // Models known to support extended thinking const THINKING_MODELS: Record = { anthropic: /claude/, // All Claude models support thinking - gemini: /gemini-(2\.5|3\.|[4-9])/, // Gemini 2.5+ supports thinking + gemini: /gemini-(2\.5|3\.|[4-9])/, // Gemini 2.5+ and 3+ (Google thinking API) openai: /^(o1|o3|o4)/, // Only reasoning models (o-series) }; +function isGemini3Family(modelId: string): boolean { + return /gemini-3/i.test(modelId); +} + +function isGemini25Family(modelId: string): boolean { + return /gemini-2\.5/i.test(modelId); +} + export function buildThinkingOptions(config: AppClawConfig): Record | undefined { if (config.LLM_THINKING !== 'on') return undefined; if (!THINKING_PROVIDERS.has(config.LLM_PROVIDER)) return undefined; @@ -305,12 +319,25 @@ export function buildThinkingOptions(config: AppClawConfig): Record thinking: { type: 'enabled', budgetTokens: budget }, }, }; - case 'gemini': - return { - google: { - thinkingConfig: { thinkingBudget: budget }, - }, - }; + case 'gemini': { + // https://ai.google.dev/gemini-api/docs/thinking + const thinkingConfig: Record = {}; + if (config.LLM_GEMINI_INCLUDE_THOUGHTS) { + thinkingConfig.includeThoughts = true; + } + if (isGemini3Family(modelId)) { + thinkingConfig.thinkingLevel = config.LLM_GEMINI_THINKING_LEVEL; + return { google: { thinkingConfig } }; + } + if (isGemini25Family(modelId)) { + thinkingConfig.thinkingBudget = budget; + return { google: { thinkingConfig } }; + } + if (config.LLM_GEMINI_INCLUDE_THOUGHTS) { + return { google: { thinkingConfig } }; + } + return undefined; + } case 'openai': return { openai: { @@ -415,10 +442,18 @@ export function createLLMProvider(config: AppClawConfig, mcpTools: MCPToolInfo[] // Defer onTextStart until the first non-empty chunk arrives — avoids // a brief "Reasoning..." flicker for providers (Gemini, GPT-4o) that // go straight to tool calls with no text prefix. + // Gemini thought summaries map to reasoning-delta; plain pre-tool text is text-delta. + // textStream omits reasoning — use fullStream so goal-based runs show thinking. let reasoningText = ''; let streamingStarted = false; - for await (const chunk of stream.textStream) { - if (!chunk) continue; // Skip empty keep-alive frames from providers + for await (const part of stream.fullStream) { + const chunk = + part.type === 'reasoning-delta' + ? part.text + : part.type === 'text-delta' + ? part.text + : ''; + if (!chunk) continue; if (!streamingStarted) { callbacks.onTextStart?.(); streamingStarted = true; @@ -498,10 +533,11 @@ export function createLLMProvider(config: AppClawConfig, mcpTools: MCPToolInfo[] // Extract the first tool call const toolCall = result.toolCalls?.[0]; if (!toolCall) { + const fallbackReason = [result.reasoningText, result.text].filter(Boolean).join('\n'); return { toolName: 'done', - args: { reason: result.text || 'No action decided' }, - reasoning: result.text, + args: { reason: result.text || fallbackReason || 'No action decided' }, + reasoning: fallbackReason || result.text, usage, }; } @@ -512,10 +548,12 @@ export function createLLMProvider(config: AppClawConfig, mcpTools: MCPToolInfo[] // Track last tool for feedToolResult lastToolName = toolCall.toolName; + const reasoningCombined = [result.reasoningText, result.text].filter(Boolean).join('\n'); + return { toolName: toolCall.toolName, args: (toolArgs ?? {}) as Record, - reasoning: result.text || undefined, + reasoning: reasoningCombined || undefined, usage, }; }, diff --git a/src/perception/screen-diff.ts b/src/perception/screen-diff.ts index 989f409..8d622ac 100644 --- a/src/perception/screen-diff.ts +++ b/src/perception/screen-diff.ts @@ -7,6 +7,25 @@ import { createHash } from 'crypto'; import type { UIElement, CompactUIElement } from './types.js'; import { extractTexts } from './dom-trimmer.js'; +/** + * Perception hash for stuck detection and per-screen failure caches. + * When DOM is unavailable (vision-only runs), fingerprints the screenshot so + * real UI changes (e.g. timer digits) still advance the hash. + */ +export function computePerceptionHash(dom: string, screenshot?: string): string { + const trimmed = dom.trim(); + if (trimmed.length > 0) { + return computeScreenHash(trimmed); + } + if (screenshot && screenshot.length > 0) { + return createHash('md5') + .update(`img:${screenshot.length}:`) + .update(screenshot.slice(0, 32768)) + .digest('hex'); + } + return computeScreenHash(''); +} + /** Compute a hash for screen state comparison. */ export function computeScreenHash(input: string | UIElement[] | CompactUIElement[]): string { if (typeof input === 'string') { diff --git a/src/playground/index.ts b/src/playground/index.ts index 899351d..42d424b 100644 --- a/src/playground/index.ts +++ b/src/playground/index.ts @@ -1384,7 +1384,8 @@ async function processLine(line: string): Promise { } } catch (err: any) { ui.stopSpinner(); - console.log(` ${theme.dim('Vision shortcut failed, falling back…')}`); + const errMsg = err?.message ?? String(err); + console.log(` ${theme.dim(`Vision shortcut failed (${errMsg}), falling back…`)}`); } } diff --git a/src/sdk/config-builder.ts b/src/sdk/config-builder.ts index 274ab15..8249217 100644 --- a/src/sdk/config-builder.ts +++ b/src/sdk/config-builder.ts @@ -14,13 +14,14 @@ const OPTION_TO_ENV_VAR: Partial> = { apiKey: 'LLM_API_KEY', model: 'LLM_MODEL', platform: 'PLATFORM', + deviceUdid: 'DEVICE_UDID', agentMode: 'AGENT_MODE', maxSteps: 'MAX_STEPS', stepDelay: 'STEP_DELAY', mcpTransport: 'MCP_TRANSPORT', mcpHost: 'MCP_HOST', mcpPort: 'MCP_PORT', - // `silent` is SDK-only — no env-var equivalent. + // `silent`, `video`, `report`, `reportName` are SDK-only — no env-var equivalents. }; /** diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 0db4357..c63c088 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -27,10 +27,12 @@ export class AppClaw { // ── Report state ─────────────────────────────────────────── private readonly collector: RunArtifactCollector | null; + private readonly videoEnabled: boolean; private runStepCounter = 0; private runSuccess = true; private runFailedAt: number | undefined; private runFailureReason: string | undefined; + private recordingStarted = false; constructor(options: AppClawOptions = {}) { this.config = buildConfig(options); @@ -50,6 +52,8 @@ export class AppClaw { (options.platform ?? 'android') as 'android' | 'ios' ) : null; + + this.videoEnabled = options.video === true; } /** @@ -62,9 +66,20 @@ export class AppClaw { * @param instruction - e.g. "open YouTube app", "tap Search", "type Appium 3.0" */ async run(instruction: string): Promise { - const { client } = await this.session.connect(); + const { client, appResolver } = await this.session.connect(); + + // Start screen recording on the first step (best-effort — mirrors the YAML flow path) + if (!this.recordingStarted && this.videoEnabled) { + try { + await client.callTool('appium_screen_recording', { action: 'start' }); + this.recordingStarted = true; + } catch { + /* appium version or driver may not support recording — skip silently */ + } + } + const stepIndex = ++this.runStepCounter; - const runner = new StepRunner(client, this.collector ?? undefined, stepIndex); + const runner = new StepRunner(client, this.collector ?? undefined, stepIndex, appResolver); const result = await runner.run(instruction); // Track first failure for report finalization @@ -109,6 +124,20 @@ export class AppClaw { * Call this in afterAll() / test teardown hooks. */ async teardown(): Promise { + // Stop recording and attach to report before finalizing (MCP client must still be active) + if (this.recordingStarted && this.videoEnabled && this.collector) { + try { + const { client } = await this.session.connect(); + const stopResult = await client.callTool('appium_screen_recording', { action: 'stop' }); + const textContent = stopResult.content?.find((c: any) => c.type === 'text'); + const text = (textContent?.type === 'text' ? textContent.text : '')?.trim() ?? ''; + const match = text.match(/saved to:\s*(.+\.mp4)/i); + if (match?.[1]) this.collector.attachVideoFromPath(match[1].trim()); + } catch { + /* ignore — report will just not have a video */ + } + } + if (this.collector && this.runStepCounter > 0) { const flowResult: RunYamlFlowResult = { success: this.runSuccess, diff --git a/src/sdk/mcp-session.ts b/src/sdk/mcp-session.ts index ccc1983..338de11 100644 --- a/src/sdk/mcp-session.ts +++ b/src/sdk/mcp-session.ts @@ -9,15 +9,47 @@ * satisfying the Dependency Inversion Principle. */ +import * as net from 'net'; import { acquireSharedMCPClient } from '../mcp/client.js'; import { createPlatformSession } from '../device/session.js'; import type { MCPClient, MCPToolInfo, SharedMCPClient } from '../mcp/types.js'; import type { AppClawConfig } from '../config.js'; import type { Platform } from '../index.js'; +import { AppResolver } from '../agent/app-resolver.js'; + +/** Bind to port 0 to let the OS assign a free ephemeral port, then release it. */ +function findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.listen(0, '127.0.0.1', () => { + const addr = server.address() as net.AddressInfo; + server.close(() => resolve(addr.port)); + }); + server.on('error', reject); + }); +} + +/** Allocate platform-specific unique ports so parallel SDK instances don't collide. */ +async function buildParallelCaps(platform: Platform): Promise> { + if (platform === 'android') { + const [systemPort, mjpegPort] = await Promise.all([findFreePort(), findFreePort()]); + return { + 'appium:systemPort': systemPort, + 'appium:mjpegServerPort': mjpegPort, + 'appium:mjpegScreenshotUrl': `http://127.0.0.1:${mjpegPort}`, + }; + } + if (platform === 'ios') { + const wdaPort = await findFreePort(); + return { 'appium:wdaLocalPort': wdaPort }; + } + return {}; +} export interface ConnectedSession { client: MCPClient; tools: MCPToolInfo[]; + appResolver: AppResolver; } export class McpSession { @@ -25,6 +57,7 @@ export class McpSession { private handle: SharedMCPClient | null = null; private scopedClient: MCPClient | null = null; private cachedTools: MCPToolInfo[] = []; + private cachedAppResolver: AppResolver | null = null; constructor(config: AppClawConfig) { this.config = config; @@ -42,11 +75,31 @@ export class McpSession { port: this.config.MCP_PORT, }); const platform = (this.config.PLATFORM || 'android') as Platform; - const { scopedMcp } = await createPlatformSession(this.handle, this.config, platform); + // Allocate unique ports per instance so parallel tests don't collide on + // mjpegServerPort / systemPort (mirrors what the parallel runner does). + const extraCaps = await buildParallelCaps(platform); + // Pin to a specific device when DEVICE_UDID is set — required for parallel runs + // so concurrent instances don't race on appium-mcp's shared activeDevice global. + const udid = this.config.DEVICE_UDID?.trim(); + if (udid) extraCaps['appium:udid'] = udid; + const { scopedMcp } = await createPlatformSession( + this.handle, + this.config, + platform, + undefined, + extraCaps + ); this.scopedClient = scopedMcp; this.cachedTools = await this.handle.listTools(); + const appResolver = new AppResolver(); + await appResolver.initialize(this.scopedClient, platform); + this.cachedAppResolver = appResolver; } - return { client: this.scopedClient!, tools: this.cachedTools }; + return { + client: this.scopedClient!, + tools: this.cachedTools, + appResolver: this.cachedAppResolver!, + }; } /** @@ -59,6 +112,7 @@ export class McpSession { this.handle = null; this.scopedClient = null; this.cachedTools = []; + this.cachedAppResolver = null; } } } diff --git a/src/sdk/step-runner.ts b/src/sdk/step-runner.ts index 901a509..dcf954e 100644 --- a/src/sdk/step-runner.ts +++ b/src/sdk/step-runner.ts @@ -13,15 +13,27 @@ import { screenshot } from '../mcp/tools.js'; import { tryParseNaturalFlowLine } from '../flow/natural-line.js'; import { resolveNaturalStep } from '../flow/llm-parser.js'; import { executeStep } from '../flow/run-yaml-flow.js'; +import { lastVisionScreenshot } from '../flow/vision-execute.js'; +import { getCachedScreenSize } from '../vision/window-size.js'; +import { pngDimensionsFromBase64 } from '../vision/png-dimensions.js'; +import type { AppResolver } from '../agent/app-resolver.js'; import type { RunResult } from './types.js'; const DEFAULT_TAP_POLL = { maxAttempts: 3, intervalMs: 300 }; +function extractCoordinates(message?: string): { x: number; y: number } | undefined { + if (!message) return undefined; + const m = message.match(/\[(\d+),\s*(\d+)\]/); + if (m) return { x: parseInt(m[1], 10), y: parseInt(m[2], 10) }; + return undefined; +} + export class StepRunner { constructor( private readonly mcp: MCPClient, private readonly collector?: RunArtifactCollector, - private readonly stepIndex?: number + private readonly stepIndex?: number, + private readonly appResolver?: AppResolver ) {} async run(instruction: string): Promise { @@ -40,10 +52,11 @@ export class StepRunner { } // 4. Execute on device - const result = await executeStep(this.mcp, step, {}, undefined, DEFAULT_TAP_POLL); + const result = await executeStep(this.mcp, step, {}, this.appResolver, DEFAULT_TAP_POLL); // 5. Record step + screenshot in report if (this.collector && this.stepIndex !== undefined) { + const tapCoords = extractCoordinates(result.message); this.collector.addStep({ index: this.stepIndex, kind: step.kind, @@ -52,10 +65,26 @@ export class StepRunner { status: result.success ? 'passed' : 'failed', message: result.message, error: result.success ? undefined : result.message, + tapCoordinates: tapCoords, + deviceScreenSize: getCachedScreenSize(this.mcp) ?? undefined, }); - const screenshotB64 = await screenshot(this.mcp).catch(() => null); - if (screenshotB64) { - this.collector.attachScreenshot(this.stepIndex, screenshotB64); + + // In vision mode, visionExecute captured the pre-action screenshot — use it + // for the tap dot overlay (same as the YAML flow path). + const visionShot = lastVisionScreenshot; + if (visionShot) { + const dims = pngDimensionsFromBase64(visionShot) ?? undefined; + if (tapCoords) { + this.collector.attachBeforeScreenshot(this.stepIndex, visionShot, dims); + } else { + this.collector.attachScreenshot(this.stepIndex, visionShot, dims); + } + } else { + // DOM mode or non-visual step — take an after screenshot + const screenshotB64 = await screenshot(this.mcp).catch(() => null); + if (screenshotB64) { + this.collector.attachScreenshot(this.stepIndex, screenshotB64); + } } } diff --git a/src/sdk/types.ts b/src/sdk/types.ts index 1002da5..2fb2740 100644 --- a/src/sdk/types.ts +++ b/src/sdk/types.ts @@ -24,6 +24,12 @@ export interface AppClawOptions { model?: string; /** Target mobile platform. */ platform?: Platform; + /** + * Target a specific device by UDID (Android serial or iOS UDID). + * Required when running tests in parallel so each instance targets a different device. + * Get Android UDIDs from: adb devices + */ + deviceUdid?: string; /** Interaction strategy: DOM locators (default) or AI vision. */ agentMode?: AgentMode; /** Maximum number of agent steps before giving up. Default: 30. */ @@ -42,6 +48,11 @@ export interface AppClawOptions { report?: boolean; /** Name shown in the report viewer. Default: 'AppClaw SDK Run'. */ reportName?: string; + /** + * Record the screen during the run and embed the video in the report. + * Requires Appium to support `appium_screen_recording`. Default: false. + */ + video?: boolean; /** How to connect to appium-mcp. Default: 'stdio'. */ mcpTransport?: MCPTransport; /** appium-mcp host when transport is 'sse'. Default: 'localhost'. */ diff --git a/src/vision/window-size.ts b/src/vision/window-size.ts index a3bc78d..5588375 100644 --- a/src/vision/window-size.ts +++ b/src/vision/window-size.ts @@ -66,7 +66,16 @@ function tryParseSizeFromText(text: string): { width: number; height: number } | } catch { /* not JSON */ } - const m = trimmed.match(/\b(\d{2,5})\s*[x×,]\s*(\d{2,5})\b/); + // Handle "Width: 1440, Height: 3120" format returned by appium_get_window_size MCP tool + const wMatch = trimmed.match(/\bwidth[:\s]+(\d+)/i); + const hMatch = trimmed.match(/\bheight[:\s]+(\d+)/i); + if (wMatch && hMatch) { + const w = parseInt(wMatch[1], 10); + const h = parseInt(hMatch[1], 10); + if (w > 0 && h > 0) return { width: w, height: h }; + } + // Handle "NxM" or "N,M" compact formats + const m = trimmed.match(/\b(\d{2,5})\s*[x×]\s*(\d{2,5})\b/); if (m) { const w = parseInt(m[1], 10); const h = parseInt(m[2], 10); diff --git a/tests/e2e/android.test.ts b/tests/e2e/android.test.ts new file mode 100644 index 0000000..f73e38d --- /dev/null +++ b/tests/e2e/android.test.ts @@ -0,0 +1,25 @@ +import { AppClaw } from '../../src/sdk'; +import { describe, it } from 'vitest'; +import 'dotenv/config'; + +describe('SDK E2E — teardown after use', () => { + it('teardown() after runFlow() does not throw', async () => { + const app = new AppClaw({ + provider: 'gemini', + apiKey: process.env.LLM_API_KEY, + platform: 'android', + video: true, + }); + + await app.run('open YouTube app'); + await app.run('click on search icon'); + await app.run('wait 1 second'); + await app.run('type Appium 3.0'); + await app.run('wait 2 seconds'); + await app.run('click on the first result from the list'); + await app.run('wait 3 seconds'); + await app.run('scroll down 2 times'); + await app.run('verify if the screen has video uploaded by TestMu AI'); + await app.teardown(); + }, 90000); +}); diff --git a/tests/flow/perception-stuck.test.ts b/tests/flow/perception-stuck.test.ts new file mode 100644 index 0000000..9a2ba33 --- /dev/null +++ b/tests/flow/perception-stuck.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { computePerceptionHash, computeScreenHash } from '../../src/perception/screen-diff.js'; +import { createStuckDetector, isDataEntryLikeGoal } from '../../src/agent/stuck.js'; + +describe('computePerceptionHash', () => { + it('uses DOM when non-empty', () => { + const dom = ''; + expect(computePerceptionHash(dom, 'screenshotdata')).toBe(computeScreenHash(dom)); + }); + + it('uses screenshot when DOM empty', () => { + const a = computePerceptionHash('', 'base64imageA'); + const b = computePerceptionHash('', 'base64imageB'); + expect(a).not.toBe(b); + }); + + it('matches for same screenshot payload', () => { + const img = 'x'.repeat(50000); + expect(computePerceptionHash('', img)).toBe(computePerceptionHash('', img)); + }); + + it('whitespace-only DOM falls back to screenshot', () => { + const img = 'abc'; + expect(computePerceptionHash(' \n', img)).toBe(computePerceptionHash('', img)); + }); +}); + +describe('isDataEntryLikeGoal', () => { + it('matches timer typing sub-goals', () => { + expect(isDataEntryLikeGoal("Type '001635' into the timer duration input field")).toBe(true); + }); + + it('matches generic keypad goals', () => { + expect(isDataEntryLikeGoal('Enter PIN 1234')).toBe(true); + }); + + it('does not match unrelated goals', () => { + expect(isDataEntryLikeGoal('Open Settings and enable WiFi')).toBe(false); + }); +}); + +describe('createStuckDetector', () => { + it('does not flag ordered keypad entry as stuck when screen changes', () => { + const detector = createStuckDetector(); + const goal = "Tap the keys '1', '6', '3', '5' on the digit pad"; + + detector.recordAction('find_and_click', 'h1'); + detector.recordAction('find_and_click', 'h2'); + detector.recordAction('find_and_click', 'h3'); + + expect(detector.isStuck(goal)).toBe(false); + }); + + it('still flags data entry as stuck when screen stays unchanged', () => { + const detector = createStuckDetector(); + const goal = "Tap the keys '1', '6', '3', '5' on the digit pad"; + + detector.recordAction('find_and_click', 'same'); + detector.recordAction('find_and_click', 'same'); + detector.recordAction('find_and_click', 'same'); + detector.recordAction('find_and_click', 'same'); + + expect(detector.isStuck(goal)).toBe(true); + }); + + it('reports stuck signal names for debugging', () => { + const detector = createStuckDetector(); + const goal = 'Open Settings and enable WiFi'; + + detector.recordAction('find_and_click', 'same'); + detector.recordAction('find_and_click', 'same'); + detector.recordAction('find_and_click', 'same'); + detector.recordAction('find_and_click', 'same'); + + expect(detector.isStuck(goal)).toBe(true); + expect(detector.getLastSignals()).toEqual(expect.arrayContaining(['repetition', 'unchanged'])); + }); +}); diff --git a/tests/flow/vision-tap-policy.test.ts b/tests/flow/vision-tap-policy.test.ts new file mode 100644 index 0000000..7ff91a6 --- /dev/null +++ b/tests/flow/vision-tap-policy.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { shouldPreferVisionLocateTap } from '../../src/agent/vision-tap-policy.js'; + +describe('shouldPreferVisionLocateTap', () => { + it('matches digit and backspace selectors from the agent', () => { + expect(shouldPreferVisionLocateTap('digit 1 key')).toBe(true); + expect(shouldPreferVisionLocateTap('digit 6 key')).toBe(true); + expect(shouldPreferVisionLocateTap('digit 3 key')).toBe(true); + expect(shouldPreferVisionLocateTap('digit 5 key')).toBe(true); + expect(shouldPreferVisionLocateTap('backspace key')).toBe(true); + expect(shouldPreferVisionLocateTap('backspace key (x icon in bottom right of keypad)')).toBe( + true + ); + }); + + it('does not match generic UI chrome', () => { + expect(shouldPreferVisionLocateTap('Timer tab at bottom')).toBe(false); + expect(shouldPreferVisionLocateTap('round play button')).toBe(false); + expect(shouldPreferVisionLocateTap('search icon top right')).toBe(false); + }); +}); diff --git a/tests/sdk/appclaw.test.ts b/tests/sdk/appclaw.test.ts index b62b8dc..abc3331 100644 --- a/tests/sdk/appclaw.test.ts +++ b/tests/sdk/appclaw.test.ts @@ -24,6 +24,8 @@ vi.mock('../../src/agent/loop.js', () => ({ vi.mock('../../src/ui/terminal.js', () => ({ silenceTerminalUI: vi.fn(), + printWarning: vi.fn(), + printSetupOk: vi.fn(), theme: { dim: (s: string) => s, info: (s: string) => s }, }));