diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a031378..17e8320 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ permissions: jobs: ci: - name: Format & Build + name: Format, Build, Type-check & Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -28,3 +28,9 @@ jobs: - name: Build run: npm run build + + - name: Type-check tests + run: npm run typecheck + + - name: Test with coverage thresholds + run: npm run test:coverage diff --git a/.gitignore b/.gitignore index c954b7f..c9f8442 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ .agents/ .vscode/ .DS_Store +coverage/ diff --git a/AGENTS.md b/AGENTS.md index f5769ff..9fc9c45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,41 +11,53 @@ Keep this file focused on maintainer instructions for coding agents. Do not copy - User-facing setup and usage: [README.md](./README.md) - Full plugin configuration reference: [CONFIGURATION.md](./CONFIGURATION.md) - Agent-assisted user setup workflow: [AGENT_SETUP.md](./AGENT_SETUP.md) -- Local development and symlink testing: [CONTRIBUTING.md](./CONTRIBUTING.md) +- Local development and installed-package testing: [CONTRIBUTING.md](./CONTRIBUTING.md) - Example config shipped with the package: [opencode-easy-vision.example.jsonc](./opencode-easy-vision.example.jsonc) ## Project Facts - TypeScript, strict mode, ES modules only. -- Runtime target is Node.js `>=18.0.0`. -- Plugin framework is `@opencode-ai/plugin`. +- Runtime target is Node.js `>=22.18.0`. +- Targets OpenCode v2 only (tested on 2.1.4); v1 support ended with 1.6.1. Plugin framework is `@opencode/plugin` (Effect API); media/message types are from `@opencode/ai`. +- The only runtime import from the OpenCode ecosystem is `effect`, which the host resolves to its own instance. Import `@opencode/*` with `import type` only, so the published package needs no OpenCode dependencies. - Source lives in `src/`; compiled output is `dist/`. -- There is no test framework. Validation is formatting plus TypeScript build, followed by manual OpenCode testing when behavior changes. +- Tests are TypeScript in `test/`, run by Vitest directly against `src/` (one `.test.ts` per source module, shared fixtures in `test/helpers.ts`). Coverage thresholds in `vitest.config.ts` fail CI on regression. Runtime fixtures against a real OpenCode binary live in `scripts/v2-spike/`. ## Commands +Use [`just`](./justfile) for every task; run `just` to list recipes. The recipes wrap the npm scripts in `package.json`, which CI calls directly. + ```bash -npm install -npm run format -npm run format:check -npm run build +just install # npm ci +just fmt # format src/ and test/ +just build +just typecheck # type-check src/ and test/ +just test # Vitest; extra args pass through, e.g. `just test config` +just coverage # tests plus coverage thresholds +just check # everything CI runs: fmt-check, build, typecheck, coverage +just runtime # end-to-end against the installed OpenCode binary +just runtime-pack # same, from an npm-packed tarball in a clean project ``` -Use `npm run build` before committing code changes. CI runs `npm ci`, `npm run format:check`, and `npm run build`. +Run `just check` before committing code changes. -The Husky pre-commit hook runs `npm run format`. It only formats `src/**/*.ts`. +The Husky pre-commit hook runs `npm run format`, which formats `src/` and `test/`. ## Architecture -The plugin entry point is [src/index.ts](./src/index.ts). It registers `experimental.chat.messages.transform` and wires together the modules below. +The plugin entry point is [src/index.ts](./src/index.ts): a thin Effect shell that default-exports a typed `{ id, effect }` plugin object, routes logs to the host logger, loads config, runs cleanup, and registers the MCP transform plus the `context`, `compaction`, `title`, and `generate` hooks. Keep plugin logic out of Effect: hooks delegate to plain async code through `Effect.promise`. - [src/constants.ts](./src/constants.ts): package constants, defaults, MIME mappings, prompt templates. - [src/types.ts](./src/types.ts): shared interfaces and function types. - [src/config.ts](./src/config.ts): config discovery, JSON/JSONC parsing, validation, precedence, example-config auto-init, and runtime accessors. - [src/patterns.ts](./src/patterns.ts): case-insensitive wildcard model matching. -- [src/images.ts](./src/images.ts): image part detection, URL handling, temp-file writing, and image extraction. +- [src/images.ts](./src/images.ts): normalized image sources, URL handling, deterministic atomic temp-file writing. - [src/prompt.ts](./src/prompt.ts): prompt-template substitution and default injection prompt selection. -- [src/transform.ts](./src/transform.ts): message lookup, model extraction, image-part removal, and text-part replacement/creation. +- [src/transform.ts](./src/transform.ts): immutable V2 content rewriting, preserving text/media ordering and metadata. +- [src/request.ts](./src/request.ts): per-request rewriting (model match, location guard, tool routing, transform, failure fallback) over Promise-based host lookups. +- [src/v2.ts](./src/v2.ts): request-tool exposure routing without permission bypasses. +- [src/codemode.ts](./src/codemode.ts): MCP transform that disables Code Mode for the server providing `imageAnalysisTool`, respecting explicit user settings. +- [src/file-lock.ts](./src/file-lock.ts): cross-instance write/cleanup coordination, never configuration storage. - [src/cleanup.ts](./src/cleanup.ts): startup cleanup for plugin-owned temp files. When adding behavior, preserve these module boundaries. Add a new small module for a new concern instead of growing [src/index.ts](./src/index.ts). @@ -54,14 +66,14 @@ When adding behavior, preserve these module boundaries. Add a new small module f The hook should stay defensive and non-crashing: -1. Find the last user message. -2. Read the model from that message and check it against configured patterns. -3. Extract supported image file parts. -4. Save `data:` images to the configured temp directory; pass `file://` and HTTP(S) images through as paths/URLs. -5. Remove all image parts that the target model cannot consume directly. -6. Inject or update text with the MCP tool instructions and the user's original request. +1. Match the active request model and verify the session belongs to this instance's location. +2. Normalize images across all retained user messages; never change durable attachments. +3. Save bytes/base64/data sources; pass through usable file/HTTP URLs. Fail explicitly for refs and unsupported formats. +4. Replace targeted raw media even on failure; retain unrelated content and all original text. +5. Add analysis guidance only for the current task, honoring direct/Code Mode exposure. +6. Keep historical/auxiliary image context neutral; recreate missing generated files on later passes. -If image processing partially or fully fails, log the failure, show a toast when the user can act on it, and keep OpenCode running. +If image processing fails, log through the injected `Logger`/`Notifier` (host Effect logger; OpenCode discards plugin `console` output) and add model-visible failure notes; the server has no terminal UI dependency. Do not log image bytes, secret URLs, credentials, or raw provider errors. ## Coding Rules @@ -69,11 +81,11 @@ If image processing partially or fully fails, log the failure, show a toast when - Use `node:` prefixes for Node built-ins. - Keep import order as type imports, Node built-ins, external packages, then local modules. - Do not use `require`, `module.exports`, `as any`, `@ts-ignore`, or `@ts-expect-error`. -- Prefer type guards for narrowing, especially around OpenCode `Part` variants. +- Prefer type guards for narrowing around V2 content/source variants. - Prefer `??` for nullish defaults. - Await filesystem operations and wrap I/O in `try`/`catch` when failure should not crash the plugin. -- Do not mutate parameters; return new data structures for message/part changes. -- Keep functions small and mostly pure. I/O and OpenCode logging/toasts are the expected side effects. +- Do not mutate parameters; return new data structures for message/part changes. The one exception is the OpenCode hook event or MCP editor, whose API contract is mutation: assign the newly built value once (for example `event.messages = ...`). +- Keep functions small and mostly pure. I/O and server logging are the expected side effects. - Prefer Node built-ins over new dependencies unless the dependency removes real complexity. ## Configuration Changes @@ -86,21 +98,26 @@ Configuration behavior is documented in [CONFIGURATION.md](./CONFIGURATION.md) a - Update [CONFIGURATION.md](./CONFIGURATION.md) and the example JSONC file. - Avoid duplicating option tables or examples in this file. -The module-level `pluginConfig` state in [src/config.ts](./src/config.ts) is intentional. Other modules should use exported accessors instead of threading config through every call. +Each adapter instance owns a `createConfigStore()` with isolated accessor closures. Bind accessors to helper factories once rather than threading options through each operation. Never restore module-global configuration: V2 locations coexist in one service. Inline host plugin options (`ctx.options`) are not read; config comes only from the dedicated files. ## Validation For code changes, run: ```bash -npm run format:check -npm run build +just check ``` -Run `npm run format` only when formatting changes are needed. For behavior that depends on OpenCode runtime wiring, follow the manual testing flow in [CONTRIBUTING.md](./CONTRIBUTING.md). +For changes to OpenCode runtime wiring (`src/index.ts`, hooks, packaging), also run `just runtime-pack`. + +Every behavior change needs a test in the matching `test/.test.ts`. Do not lower coverage thresholds to make CI pass. Tests may cast to `unknown` only where OpenCode types are impractical to construct (branded IDs, the full plugin `Context`); keep such casts in `test/helpers.ts` or one documented spot per file. + +Run `just fmt` only when formatting changes are needed. For behavior that depends on OpenCode runtime wiring, follow the manual testing flow in [CONTRIBUTING.md](./CONTRIBUTING.md). ## Releases Always confirm with the user before running any release command. -Use `npm version patch`, `npm version minor`, or `npm version major` to bump, commit, and tag. Push both the commit and tag so [.github/workflows/publish.yml](./.github/workflows/publish.yml) can publish to npm. Do not run `npm publish` manually. +2.x is OpenCode v2 only. Do not publish v1 fixes from `main`. + +Release with `just release patch|minor|major`. It runs `just check`, then `npm version` (bump, commit, tag) and `git push --follow-tags`, so [.github/workflows/publish.yml](./.github/workflows/publish.yml) publishes to npm. The recipe asks for confirmation, but get the user's approval before running it. Do not run `npm publish` manually. diff --git a/AGENT_SETUP.md b/AGENT_SETUP.md index df2115a..5a7d5c7 100644 --- a/AGENT_SETUP.md +++ b/AGENT_SETUP.md @@ -2,19 +2,23 @@ Set up the `opencode-easy-vision` OpenCode plugin. Read existing configuration before editing it, and update keys in place instead of replacing whole files. +This plugin version (2.x) requires **OpenCode v2**. + --- ## Pre-flight — read context before editing Before editing any files, do all of the following: -1. **Fetch the full configuration reference** and keep it available throughout this session: `https://raw.githubusercontent.com/devadathanmb/opencode-easy-vision/main/CONFIGURATION.md` +1. **Check the OpenCode version:** Run `opencode --version`. If it is 1.x, stop and tell the user that this plugin version needs OpenCode v2; on OpenCode v1 they must use `"plugin": ["opencode-easy-vision@1"]` instead, configured as described in the 1.x README. + +2. **Fetch the full configuration reference** and keep it available throughout this session: `https://raw.githubusercontent.com/devadathanmb/opencode-easy-vision/main/CONFIGURATION.md` -2. **Inspect the existing OpenCode config:** Check `~/.config/opencode/opencode.json` and `.jsonc`, then check the `opencode.json` and `.jsonc` files that apply to the current project. +3. **Inspect the existing OpenCode config:** Check `~/.config/opencode/opencode.json` and `.jsonc`, then check the `opencode.json` and `.jsonc` files that apply to the current project. - Note whether the `mcp` block exists, what keys it already has, and whether `"opencode-easy-vision"` is already in the `plugin` array. + Note whether the `mcp` block exists and which servers it defines (OpenCode v2 uses `mcp.servers`; the older layout with servers directly under `mcp` is still accepted), and whether `"opencode-easy-vision"` is already in the `plugins` array or the older `plugin` array. -3. **Inspect plugin config files:** Check existing `.json` and `.jsonc` files at `~/.config/opencode/opencode-easy-vision.*` and `.opencode/opencode-easy-vision.*`. Also note any legacy `opencode-minimax-easy-vision` config files. +4. **Inspect plugin config files:** Check existing `.json` and `.jsonc` files at `~/.config/opencode/opencode-easy-vision.*` and `.opencode/opencode-easy-vision.*`. If you find `opencode-minimax-easy-vision.json(c)` files, tell the user they are no longer read and offer to rename them to `opencode-easy-vision.json(c)`. If an existing config sets `imageAnalysisTool` to `mcp_minimax_understand_image` (the 1.x default), it must be updated in Question 4. Note the current values of `models` and `imageAnalysisTool`. Do not create a plugin config file unless the chosen options require one. @@ -49,39 +53,39 @@ Ask the user: **If they choose 1 (MiniMax Coding Plan MCP):** -Ask for their MiniMax API key. They can create one at https://platform.minimax.io. Before writing it, tell them the key will be stored in the selected OpenCode config and ask for confirmation. +Ask for their MiniMax API key. They can create one at https://platform.minimax.io. Offer two ways to supply it: an environment variable referenced as `{env:MINIMAX_API_KEY}` (recommended), or the key written directly into the selected OpenCode config. If they choose the latter, tell them the key will be stored in that file and ask for confirmation. -Add or update the `MiniMax` entry in the selected OpenCode config's `mcp` block: +Add or update the `MiniMax` entry under `mcp.servers` in the selected OpenCode config: ```json "MiniMax": { "type": "local", "command": ["uvx", "minimax-coding-plan-mcp"], "environment": { - "MINIMAX_API_KEY": "", + "MINIMAX_API_KEY": "{env:MINIMAX_API_KEY}", "MINIMAX_API_HOST": "https://api.minimax.io" } } ``` -Tool name to use in plugin config: **`mcp_minimax_understand_image`**. +Tool name to use in plugin config: **`MiniMax_understand_image`** (the default). --- **If they choose 2 (openrouter-image-mcp):** -Ask for their OpenRouter API key. They can create one at https://openrouter.ai/keys. Before writing it, tell them the key will be stored in the selected OpenCode config and ask for confirmation. +Ask for their OpenRouter API key. They can create one at https://openrouter.ai/keys. As with MiniMax, offer `{env:OPENROUTER_API_KEY}` (recommended) or storing the key in the selected OpenCode config, and confirm before writing a key to a file. Ask which vision model they want to use. If unsure, recommend `nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free`, a free multimodal model designed for image perception and reasoning. Free model availability changes over time, so check [OpenRouter's free models](https://openrouter.ai/models?fmt=cards&max_price=0) for current alternatives and explain that free endpoints use shared capacity, so latency and availability can vary. -Add or update the `openrouter_image` entry in the selected OpenCode config's `mcp` block: +Add or update the `openrouter_image` entry under `mcp.servers` in the selected OpenCode config: ```json "openrouter_image": { "type": "local", "command": ["npx", "openrouter-image-mcp"], "environment": { - "OPENROUTER_API_KEY": "", + "OPENROUTER_API_KEY": "{env:OPENROUTER_API_KEY}", "OPENROUTER_MODEL": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" } } @@ -95,12 +99,14 @@ Tool name to use in plugin config: **`openrouter_image_analyze_image`**. Ask the user to describe the tool or provide its name or repo URL. Fetch its documentation to find: -- The correct `mcp` block entry for `opencode.json` -- The exact tool name the server exposes (look for function/tool definitions in the README or source) +- The correct `mcp.servers` entry for `opencode.json` +- The exact name of the image-analysis tool the server defines (look for function/tool definitions in the README or source) + +Add or update the entry under `mcp.servers`, checking first whether the block already exists. -Add or update the entry in the `mcp` block, checking first whether the block already exists. +OpenCode exposes the tool as `_`, with characters other than letters, digits, `_` and `-` in the server key replaced by `_`. For server key `vision` and tool `describe_image`, the name is `vision_describe_image`. Names are case-sensitive. -Use the exact tool name OpenCode exposes. Do not construct the name from the MCP server key. +Do not set `codemode` on this server: the plugin turns OpenCode's MCP Code Mode off for it automatically so the model can call the tool directly. --- @@ -150,9 +156,10 @@ For all options except 1: update the `models` key in the selected plugin config. ## Question 4 — MCP tool name -This step is only needed if the tool name is **not** `mcp_minimax_understand_image` (the built-in default). +The plugin config's `imageAnalysisTool` must end up equal to the tool name from Question 2. Check the selected scope's plugin config, and also any config at the other scope that would override it: -If the tool name from Question 2 differs from the default, add or update `imageAnalysisTool` in the selected plugin config: +- **Tool name is `MiniMax_understand_image` (the built-in default):** no `imageAnalysisTool` is needed. If an existing config sets `imageAnalysisTool` to anything else, including the 1.x default `mcp_minimax_understand_image`, remove that key (or set it to `MiniMax_understand_image`). +- **Any other tool name:** add or update `imageAnalysisTool` in the selected plugin config: ```jsonc { @@ -160,22 +167,22 @@ If the tool name from Question 2 differs from the default, add or update `imageA } ``` -Check whether the file already exists. If it does, update the `imageAnalysisTool` key in place. If not, create `opencode-easy-vision.jsonc` at the selected scope. - -If the tool name is `mcp_minimax_understand_image`, skip this step. +If the plugin config file already exists, update the key in place. If not, create `opencode-easy-vision.jsonc` at the selected scope. --- ## Step 5 — Add the plugin -Check whether `"opencode-easy-vision"` is already in the selected OpenCode config's `plugin` array. If it is, skip this step. If not, add it: +Check whether `"opencode-easy-vision"` is already in the selected OpenCode config's `plugins` array, or in an older `plugin` array (OpenCode v2 still reads it). If it is, skip this step. If it is pinned to a 1.x version (for example `opencode-easy-vision@1`), remove the pin. If it is missing, add it to `plugins`: ```json { - "plugin": ["opencode-easy-vision"] + "plugins": ["opencode-easy-vision"] } ``` +For the all-projects scope you can instead run `opencode plugin add opencode-easy-vision`. + --- ## Step 6 — Summarise and verify @@ -185,13 +192,14 @@ Before asking the user to restart, show them a brief summary of every change you - Which OpenCode config was updated (MCP entry, plugin array) - Which plugin config was updated and what was written to it (`models`, `imageAnalysisTool`), or note if no plugin config was needed -Then tell the user to **restart OpenCode**. +Then tell the user to **restart OpenCode**. OpenCode v2 runs plugins in a background service: if the plugin does not load after restarting the terminal, ask the user to run `opencode service restart`. Do not restart the service yourself without their approval. -After they restart, ask them to paste an image (`Cmd+V` / `Ctrl+V`) into the chat with the configured model selected and send a message. The model should call the MCP tool and return an image analysis. +After they restart, ask them to paste an image (`Cmd+V` / `Ctrl+V`) into the chat with the configured model selected and send a message. The model should call the MCP tool and return an image analysis. Only a completed tool call counts as success; text that merely looks like a tool call does not. **If it doesn't work, check in order:** -1. Is the MCP tool name in `imageAnalysisTool` exactly the name OpenCode exposes? +1. Is the MCP tool name in `imageAnalysisTool` exactly the name OpenCode exposes (`_`, case-sensitive)? Is the MCP server connected? 2. Does the model ID match the configured `models` pattern? Remember: OpenRouter-served models won't match `minimax/*` even if the underlying model is from MiniMax. 3. Is the API key set correctly in the MCP environment block in the selected OpenCode config? -4. If in doubt, re-read `https://raw.githubusercontent.com/devadathanmb/opencode-easy-vision/main/CONFIGURATION.md` for the full option reference. +4. Check OpenCode's log (`~/.local/share/opencode/log/opencode.log` by default) for lines tagged `plugin=easy-vision`. +5. If in doubt, re-read `https://raw.githubusercontent.com/devadathanmb/opencode-easy-vision/main/CONFIGURATION.md` for the full option reference. diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 32b5068..8414c0e 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -2,6 +2,8 @@ This plugin reads its configuration from dedicated `.json` or `.jsonc` files. See [Config File Locations](#config-file-locations) for where to place them. +These options apply to OpenCode v2 (plugin 2.x). Inline `options` in OpenCode's `plugins` array are not read; use the files below. Each project location gets its own config, even when one OpenCode service serves several projects. + ## Table of Contents - [Config File Locations](#config-file-locations) @@ -11,6 +13,7 @@ This plugin reads its configuration from dedicated `.json` or `.jsonc` files. Se - [`promptTemplate`](#prompttemplate) - [`tempDir`](#tempdir) - [`cleanupAfterHours`](#cleanupafterhours) + - [`disableCodeMode`](#disablecodemode) - [Full Example Configs](#full-example-configs) ## Config File Locations @@ -24,7 +27,7 @@ Each config option uses the following priority (highest first): The plugin merges options across levels. For example, a project `models` setting overrides the user setting while the user-level `imageAnalysisTool` remains in effect when the project config does not set it. -At each level, a valid JSONC file takes precedence over JSON. The plugin also reads the legacy `opencode-minimax-easy-vision` filenames when no current filename exists at that level. Current filenames take precedence over legacy filenames. +At each level, a valid JSONC file takes precedence over JSON. The pre-2.0 `opencode-minimax-easy-vision.json(c)` filenames are no longer read; rename them to `opencode-easy-vision.json(c)`. If no config exists, the plugin uses hardcoded defaults. On first load, an example `.jsonc` file is created at `~/.config/opencode/opencode-easy-vision.jsonc`. @@ -57,6 +60,8 @@ Controls which models trigger the plugin. The format is `provider/model` with wi Patterns without a `/` are matched against both the provider ID and the model ID. +Matching is case-insensitive and uses the active request's provider/model, not an earlier user turn's selection. Nested IDs such as `openrouter/deepseek/deepseek-v4-flash-0731` work; variants do not participate. Default provider patterns are unchanged. + **Example:** ```json @@ -68,10 +73,14 @@ Patterns without a `/` are matched against both the provider ID and the model ID ### `imageAnalysisTool` **Type:** `string` -**Default:** `"mcp_minimax_understand_image"` +**Default:** `"MiniMax_understand_image"` The MCP tool the plugin instructs the model to use. Use the exact tool name OpenCode exposes. +OpenCode v2 names MCP tools `_`, with characters other than letters, digits, `_` and `-` in the server name replaced by `_`. The example server `MiniMax` and tool `understand_image` yield this default. The name is matched exactly: it is not lowercased or given an `mcp_` prefix. Config files that still use the 1.x default `mcp_minimax_understand_image` need updating. + +The plugin also uses this name to find the MCP server whose Code Mode it disables (see [`disableCodeMode`](#disablecodemode)). If the tool is not available in a request (disconnected, misnamed, or denied by permissions), the model receives a note saying so; the plugin never calls the tool itself or bypasses permissions. A server can report connected shortly before its tools are registered, so retry after startup if needed. + **Example:** ```json @@ -87,12 +96,14 @@ The MCP tool the plugin instructs the model to use. Use the exact tool name Open Override the default prompt with a custom template. The template must include **at least one** of the following variables. If none are present, the plugin falls back to the default. Include both `{imageList}` and `{toolName}` when the model needs the image paths and the MCP tool name. +If the tool is still exposed through Code Mode, routing guidance for `execute` is appended to your template. All original text parts remain in order. Only the latest user message receives analysis instructions; images in older turns are replaced with their saved paths only. `compaction`, `title`, and transient `generate` requests receive paths and failure notes only, without tool-call instructions. + | Variable | Description | | -------------- | -------------------------------------------------- | | `{imageList}` | Newline-separated list: `- Image 1: /path/to/file` | | `{imageCount}` | Number of images, e.g. `1`, `3` | | `{toolName}` | The configured MCP tool name | -| `{userText}` | The user's original message text (may be empty) | +| `{userText}` | The text the user typed (may be empty); excludes skill bodies and attached-file contents | **Default template:** @@ -120,6 +131,10 @@ User's request: {userText} Custom directory where pasted images are saved before being passed to the MCP tool. +Paths are on the **OpenCode server host**. Remote MCPs/containers need an explicit shared filesystem; remote clients do not share these paths automatically. HTTP(S) sources may pass through, but V2 often materializes attachments into base64 first. Provider refs, private-header URLs, malformed data, and unsupported image formats get explicit failures. Targeted raw image content is removed for matching requests even if saving fails; non-image media and unmatched-model requests are unchanged. + +Filenames are SHA-256 hashes of normalized MIME and decoded bytes. Repeated requests recreate missing files; files are written atomically with owner-only permissions. Use a private temp directory for sensitive images. + **Example:** ```json @@ -135,6 +150,8 @@ Custom directory where pasted images are saved before being passed to the MCP to Temp files older than this many hours are deleted when the plugin initializes. The plugin does not delete files on a timer while OpenCode is running. +Cleanup only deletes files named like plugin-generated SHA-256 hashes, and coordinates with active writes in the same server process. Files from 1.x in the old `opencode-minimax-vision` temp directory are no longer cleaned up; your OS clears its temp directory on its own. Do not place your own files with 64-character hex names in a cleanup directory. + **Example:** ```json @@ -143,6 +160,23 @@ Temp files older than this many hours are deleted when the plugin initializes. T } ``` +### `disableCodeMode` + +**Type:** `boolean` +**Default:** `true` + +OpenCode v2 exposes MCP tools through Code Mode by default: the model cannot call them directly and has to write code for an `execute` tool instead. When this option is `true`, the plugin sets `codemode: false` on the MCP server that provides [`imageAnalysisTool`](#imageanalysistool), so the model can call the tool directly as the prompt instructs. Only that server is changed, and only when its `codemode` is not set explicitly in your OpenCode config; an explicit setting always wins. If the tool name could belong to more than one server (for example servers `foo` and `foo_bar` with tool `foo_bar_tool`), the plugin changes nothing and logs which servers matched; set `codemode: false` on the right server yourself. + +Set it to `false` to leave Code Mode alone. The plugin then appends guidance telling the model to reach the tool through `execute`. + +**Example:** + +```json +{ + "disableCodeMode": false +} +``` + ## Full Example Configs ### Minimal — just change the models diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7c46cb1..8a562a2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,44 +1,61 @@ # Contributing -## Local Development +## Build and tests -### 1. Install dependencies and build +The plugin targets **OpenCode v2** and is tested against **2.1.4**. OpenCode v1 is not supported; the last v1-compatible release is 1.6.1. Use Node.js >=22.18.0; CI uses Node 24. -```bash -npm install -npm run build +Tasks run through [`just`](https://just.systems); run `just` to list them. + +```sh +just install +just check # format check, build, type-check, tests with coverage thresholds ``` -The compiled plugin will be at `dist/index.js`. +Tests are TypeScript, run by [Vitest](https://vitest.dev) directly against `src/`, so no build is needed first. Each source module has a matching `test/.test.ts`; shared fixtures live in `test/helpers.ts`. `just test` runs them once (`just test config` runs only matching files), `just watch` re-runs on change, and `just typecheck` type-checks the tests. `just coverage` enforces the coverage thresholds in `vitest.config.ts` and writes an HTML report to `coverage/`. -Husky is configured as a `prepare` script and installs automatically on `npm install`. The pre-commit hook runs `npm run format`. +`just fmt` formats `src/` and `test/`; the Husky hook also runs it. -### 2. Symlink into OpenCode's plugin directory +`@opencode/plugin`, `@opencode/ai` and `effect` are dev dependencies used for types and tests. At runtime the plugin imports only `effect`, which OpenCode resolves to its own copy, so the published package depends on nothing but `strip-json-comments`. Keep OpenCode imports type-only (`import type`). -```bash -mkdir -p ~/.config/opencode/plugin -ln -sfn "$PWD/dist/index.js" ~/.config/opencode/plugin/opencode-easy-vision.js -``` +## Project-local OpenCode testing -Restart OpenCode to pick up the symlinked plugin. +Keep the root `index.js` bridge in the tarball: OpenCode's local-directory loader resolves a root `server`/`index`, not `package.main`. ESM/type exports and `main`/`types` still point to compiled `dist/` output. -### 3. Rebuild on changes +Build, then configure the **package directory**, not a global single-file symlink, in a clean project's `opencode.json`: -```bash -npm run build +```jsonc +{ + "$schema": "https://opencode.ai/config.json", + "plugins": ["/absolute/path/opencode-easy-vision"], + "mcp": { + "servers": { + "Vision": { + "type": "local", + "command": ["your-vision-mcp"], + }, + }, + }, +} ``` -OpenCode loads plugins at startup, so restart it after each rebuild to test changes. +Set `.opencode/opencode-easy-vision.jsonc` with the active model pattern and exact catalog tool name. Use environment variables for credentials. Run `opencode run --standalone --model --file ` with a private server; clipboard/TUI attachment-only behavior needs manual paste. -## Code Style +For deterministic runtime checks against the installed OpenCode binary (the runner checks for 2.1.4): -```bash -npm run format # format src/ -npm run format:check # check without writing +```sh +just runtime ``` -The project uses Prettier with the config in `.prettierrc.json`. +The fixture wrapper waits for catalog readiness and observes production rewriting before/after without rewriting images itself. It asserts actual outbound HTTP payloads, MCP reads, and which route the model sees in three MCP modes: `codemode: false`, `codemode: true` (tool stays behind `execute`), and unset (the plugin disables Code Mode). See the [probe docs](./scripts/v2-spike/README.md) for the isolated live OpenRouter test. + +## Installed-package acceptance + +Run `just runtime-pack`: it packs the plugin, installs the tarball into a clean temporary project, and runs the fixture against the installed entry, which proves the published dependency contract. Also test with the real vision MCP. Verify example-config auto-init after installation. Do not publish as part of testing. + +Plugin logs go to OpenCode's log file (`~/.local/share/opencode/log/opencode.log`, or `/data/opencode/log/opencode.log` in the fixture runner's isolated home), tagged `plugin=easy-vision`. Plugin `console` output is discarded by OpenCode. + +Watched config changes reload automatically; unwatched dependencies may need `opencode service restart`. Get permission before restarting an active service. Test reload/unload, concurrent locations, session moves, model switches, historic images, failures, auxiliary requests, permissions, and remote filesystem limits. ## CI -CI runs on every push via GitHub Actions (`.github/workflows/ci.yml`). It checks formatting and runs a build. Publishing to npm is gated on CI passing. +CI runs install, formatting, build, and Node tests. Live MCP/API tests are opt-in and require ephemeral credentials, never committed keys. diff --git a/README.md b/README.md index 73fe088..2e1415b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,18 @@ # OpenCode Easy Vision -An [OpenCode](https://opencode.ai) plugin that adds **vision support** to models that lack it. Paste images directly into the chat and ask questions, just like you would with Claude or GPT. +An [OpenCode](https://opencode.ai) V2 plugin that lets text-only models work with pasted images through an MCP vision tool. It saves images and rewrites outgoing model context, not durable chat history or model capabilities. > [!IMPORTANT] > -> **Renamed from `opencode-minimax-easy-vision`:** Existing users only need to replace the old package name in the `plugin` array with `opencode-easy-vision`. Existing plugin configuration continues to work; no other migration is required. +> **Version 2.x requires OpenCode v2** (tested with 2.1.4). OpenCode v1 is no longer supported. +> +> **Still on OpenCode v1?** Pin the last v1-compatible release, otherwise OpenCode installs 2.x and fails to load it: +> +> ```json +> { "plugin": ["opencode-easy-vision@1"] } +> ``` +> +> Upgrading from 1.x: your `opencode-easy-vision.json(c)` files keep working. The default tool name is now `MiniMax_understand_image`, and the old `opencode-minimax-easy-vision` config filenames are no longer read. See [Upgrading from 1.x](#upgrading-from-1x). ## Table of Contents @@ -17,6 +25,7 @@ An [OpenCode](https://opencode.ai) plugin that adds **vision support** to models - [Configuration](#configuration) - [Supported Image Formats](#supported-image-formats) - [Troubleshooting](#troubleshooting) +- [Upgrading from 1.x](#upgrading-from-1x) - [Uninstallation](#uninstallation) - [Contributing](#contributing) - [License](#license) @@ -35,7 +44,6 @@ This plugin automates the entire workaround. It intercepts pasted images, saves | Without plugin screenshot | With plugin screenshot | | The image is silently ignored by the model. | The model analyzes the attached image correctly before answering. | - https://github.com/user-attachments/assets/da7d8618-5de1-4a00-9250-a91493e3ea16 https://github.com/user-attachments/assets/fdb68339-b95b-46eb-90d4-ac2dbc0f436e @@ -53,12 +61,14 @@ The plugin works with any MCP server that can analyze an image from a local path ```json { "mcp": { - "MiniMax": { - "type": "local", - "command": ["uvx", "minimax-coding-plan-mcp"], - "environment": { - "MINIMAX_API_KEY": "your-api-key-here", - "MINIMAX_API_HOST": "https://api.minimax.io" + "servers": { + "MiniMax": { + "type": "local", + "command": ["uvx", "minimax-coding-plan-mcp"], + "environment": { + "MINIMAX_API_KEY": "{env:MINIMAX_API_KEY}", + "MINIMAX_API_HOST": "https://api.minimax.io" + } } } } @@ -72,12 +82,14 @@ Routes image analysis through OpenRouter, giving you access to any vision-capabl ```json { "mcp": { - "openrouter_image": { - "type": "local", - "command": ["npx", "openrouter-image-mcp"], - "environment": { - "OPENROUTER_API_KEY": "your-api-key-here", - "OPENROUTER_MODEL": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" + "servers": { + "openrouter_image": { + "type": "local", + "command": ["npx", "openrouter-image-mcp"], + "environment": { + "OPENROUTER_API_KEY": "{env:OPENROUTER_API_KEY}", + "OPENROUTER_MODEL": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" + } } } } @@ -92,31 +104,29 @@ Routes image analysis through OpenRouter, giving you access to any vision-capabl > > Any MCP server with an image analysis tool will work. The servers above are examples. For a different tool, set `imageAnalysisTool`; see [Configuration](#configuration). -#### 2. Install the plugin - -**With the OpenCode CLI (v1.3.4+):** - -Global (all projects): +> [!NOTE] +> +> OpenCode v2 exposes MCP tools through **Code Mode** by default, where the model can only reach them by writing code for an `execute` tool. The plugin turns Code Mode off for the one server that provides `imageAnalysisTool` so the model can call it directly. Other servers are untouched, and an explicit `codemode` setting on that server always wins. Set `disableCodeMode: false` in the plugin config to opt out. -```bash -opencode plugin opencode-easy-vision --global -``` +#### 2. Install the plugin -Project-level (current directory only): +Install globally with the OpenCode CLI: ```bash -opencode plugin opencode-easy-vision +opencode plugin add opencode-easy-vision ``` -**Or manually,** add it to the `plugin` array in your `opencode.json`: +Or add it to the `plugins` array of a global or project `opencode.json`: ```json { "$schema": "https://opencode.ai/config.json", - "plugin": ["opencode-easy-vision"] + "plugins": ["opencode-easy-vision"] } ``` +To work on a local checkout, see [CONTRIBUTING.md](./CONTRIBUTING.md). + --- ### Agent-Assisted Setup @@ -157,7 +167,7 @@ Each option uses the following priority order: 1. **Project level**: `.opencode/opencode-easy-vision.json` (or `.jsonc`) 2. **User level**: `~/.config/opencode/opencode-easy-vision.json` (or `.jsonc`) -On first load, an example config is created at `~/.config/opencode/opencode-easy-vision.jsonc`. See [CONFIGURATION.md](./CONFIGURATION.md) for config precedence, legacy config support, and the full option reference. +On first load, an example config is created at `~/.config/opencode/opencode-easy-vision.jsonc`. See [CONFIGURATION.md](./CONFIGURATION.md) for config precedence and the full option reference. ## Supported Image Formats @@ -165,21 +175,30 @@ PNG, JPEG, and WebP. Exact format support depends on the image analysis tool you ## Troubleshooting -**Plugin not updating after a new release?** +**Plugin not updating?** Use `opencode plugin list`, `opencode plugin check`, and `opencode plugin update`. Watched config changes reload automatically; unwatched local dependencies may require `opencode service restart`. Restarting the terminal alone does not restart server plugins. Do not delete cache directories as the normal update flow. -OpenCode caches plugins under `~/.cache/opencode/packages/`. If it's still running an old version after a release, clear the cache entry and restart: +**Tool unavailable?** OpenCode names MCP tools `_`, so the `MiniMax` example server exposes `MiniMax_understand_image`. Check that `imageAnalysisTool` matches exactly, that the server is connected, and that permissions allow the tool. A server can report connected shortly before its tools are registered. If you keep Code Mode on for the vision server, the plugin tells the model to reach the tool through `execute` instead. The plugin never calls tools itself or bypasses permissions. -```bash -rm -rf ~/.cache/opencode/packages/opencode-easy-vision@latest -``` +**Where are the logs?** In OpenCode's log file (`~/.local/share/opencode/log/opencode.log` by default), tagged `plugin=easy-vision`. Image failures are also written into the model's context so the model can tell you about them. + +**Remote clients or MCPs?** Generated paths are on the OpenCode server host. Containers and remote MCP servers need a shared filesystem or usable HTTP image URLs. + +**Older images and auxiliary requests?** Matching requests replace images across retained user history. Only the current task gets analysis instructions; older turns and compaction/title/transient generation get neutral paths or failure text. PNG/JPEG/WebP are supported; other image formats/provider refs fail explicitly without sending raw images to the text model. Unrelated PDFs/audio and all original text remain intact. **Plugin not activating?** By default, the plugin only activates for MiniMax provider models, with IDs matching `minimax/*`, `minimax-cn/*`, and related patterns. It does not activate for a MiniMax model accessed through OpenRouter or another provider. To use the plugin with a different model or provider, add the model's ID pattern to `models` in your config. See [CONFIGURATION.md](./CONFIGURATION.md). +## Upgrading from 1.x + +1. Make sure you are on OpenCode v2. Your existing `"plugin": ["opencode-easy-vision"]` entry keeps working; `"plugins": ["opencode-easy-vision"]` is the native v2 form. +2. Move your MCP server under `mcp.servers` (OpenCode v2's format). OpenCode also accepts the v1 layout. +3. If you set `imageAnalysisTool` to `mcp_minimax_understand_image`, change it to the name OpenCode v2 actually exposes, `MiniMax_understand_image` for the example server, or remove it to use the new default. +4. If you still use `opencode-minimax-easy-vision.json(c)` config files, rename them to `opencode-easy-vision.json(c)`. The old names are no longer read. + ## Uninstallation -1. Remove `opencode-easy-vision` from the `plugin` array in the `opencode.json` file where you installed it. +1. Remove the package from the `plugins` array where configured (or use `opencode plugin remove ` for global package installations). 2. Delete the plugin config files you no longer need: @@ -188,7 +207,7 @@ rm -f ~/.config/opencode/opencode-easy-vision.{json,jsonc} rm -f .opencode/opencode-easy-vision.{json,jsonc} ``` -If you migrated from `opencode-minimax-easy-vision` and no longer use it, you can also delete its legacy config files. +Saved images live in your OS temp directory under `opencode-easy-vision/` (or your configured `tempDir`) and are cleaned up automatically. ## Contributing @@ -201,6 +220,6 @@ AGPL-3.0. See [LICENSE](./LICENSE). ## References - [OpenCode Official Website](https://opencode.ai) -- [OpenCode Plugins Documentation](https://opencode.ai/docs/plugins/) +- [OpenCode V2 Plugins Documentation](https://opencode.ai/v2/docs/build/plugins) - [MiniMax Coding Plan MCP Repository](https://github.com/MiniMax-AI/MiniMax-Coding-Plan-MCP) - [openrouter-image-mcp Repository](https://github.com/JonathanJude/openrouter-image-mcp) diff --git a/index.js b/index.js new file mode 100644 index 0000000..a4c4ffd --- /dev/null +++ b/index.js @@ -0,0 +1,2 @@ +// V2 local-directory loading resolves a root index, rather than package.main. +export { EasyVisionPlugin, default } from "./dist/index.js"; diff --git a/justfile b/justfile new file mode 100644 index 0000000..8db7f91 --- /dev/null +++ b/justfile @@ -0,0 +1,66 @@ +# Task runner for opencode-easy-vision. Run `just` to list recipes. + +set shell := ["bash", "-euo", "pipefail", "-c"] + +# List available recipes +default: + @just --list --unsorted + +# Install dependencies exactly as locked +install: + npm ci + +# Compile src/ to dist/ +build: + npm run build + +# Run tests once; extra args go to Vitest (e.g. `just test config`) +test *args: + npx vitest run {{ args }} + +# Re-run tests on change +watch *args: + npx vitest {{ args }} + +# Run tests and enforce coverage thresholds (HTML report in coverage/) +coverage: + npm run test:coverage + +# Type-check sources and tests without emitting +typecheck: + npm run typecheck + +# Format src/ and test/ +fmt: + npm run format + +# Fail if anything is unformatted +fmt-check: + npm run format:check + +# Everything CI runs, in the same order +check: fmt-check build typecheck coverage + +# End-to-end run against the installed OpenCode binary using the checkout +runtime: build + node scripts/v2-spike/run.mjs "$PWD/dist/index.js" + +# Pack, install into a clean temp project, and run the end-to-end fixture against it +runtime-pack: build + #!/usr/bin/env bash + set -euo pipefail + work="$(mktemp -d "${TMPDIR:-/tmp}/easy-vision-pack-XXXXXX")" + npm pack --ignore-scripts --pack-destination "$work" >/dev/null + (cd "$work" && npm init -y >/dev/null && npm install --ignore-scripts --no-audit --no-fund ./opencode-easy-vision-*.tgz >/dev/null) + echo "Installed packages: $(ls "$work/node_modules" | tr '\n' ' ')" + node scripts/v2-spike/run.mjs "$work/node_modules/opencode-easy-vision/dist/index.js" + +# Remove build and coverage output +clean: + rm -rf dist coverage + +# Bump version (patch|minor|major), commit, tag, and push; CI publishes to npm +[confirm("Bump the version, tag, and push? CI will publish to npm.")] +release level: check + npm version {{ level }} + git push --follow-tags diff --git a/opencode-easy-vision.example.jsonc b/opencode-easy-vision.example.jsonc index 3062af5..804df36 100644 --- a/opencode-easy-vision.example.jsonc +++ b/opencode-easy-vision.example.jsonc @@ -13,7 +13,8 @@ // MCP tool name for image analysis. // Use the exact tool name OpenCode exposes. - "imageAnalysisTool": "mcp_minimax_understand_image", + // OpenCode names MCP tools _: server "MiniMax" + tool "understand_image". + "imageAnalysisTool": "MiniMax_understand_image", // Custom prompt template. Must include at least one variable: // {imageList} — newline-separated "- Image N: /path" @@ -23,10 +24,14 @@ // Leave as null to use the built-in default template. "promptTemplate": null, - // Directory where pasted images are saved before being passed to the MCP tool. + // Server-host directory; the MCP process must be able to access these paths. // Leave as null to use the OS temp directory + "opencode-easy-vision/". "tempDir": null, // Delete temp files older than this many hours on plugin startup. "cleanupAfterHours": 24, + + // Turn off OpenCode's MCP Code Mode for the server providing imageAnalysisTool, + // so the model can call the tool directly. An explicit codemode setting wins. + "disableCodeMode": true, } diff --git a/package-lock.json b/package-lock.json index b1de4a4..5140dac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,77 +12,4649 @@ "strip-json-comments": "^5.0.3" }, "devDependencies": { - "@opencode-ai/plugin": "^1.2.26", + "@opencode/ai": "^2.1.4", + "@opencode/plugin": "^2.1.4", "@types/node": "^25.0.0", + "@vitest/coverage-v8": "^5.0.5", + "effect": "^4.0.1", "husky": "^9.1.7", "prettier": "^3.8.1", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^5.0.5" + }, + "engines": { + "node": ">=22.18.0" + }, + "peerDependencies": { + "effect": "^4.0.1" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity": { + "version": "3.1057.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1057.0.tgz", + "integrity": "sha512-5MliYkp2u0+2arTp5fZIaxl+xmm90LEKv/VeSxhfNQW4t0fvWJrNO429/jchWQenNoDRrOGE59VfbuZUfwFujg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-node": "^3.972.47", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/fetch-http-handler": "^5.4.5", + "@smithy/node-http-handler": "^4.7.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.2.tgz", + "integrity": "sha512-ViOBLuGSSyPAmkKGJ6KJXtVN7MOEp3rnI7uH+kWxnHccvo8C9cvunEAnN5/oaZH1laQfsTBw7IOnmNkWlFZl2A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.35.2", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-cognito-identity": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.72.tgz", + "integrity": "sha512-UlY1+6rV1K7bFeMivM6qfQuVSBrkm/3fXBDzQO+3izAMZ64JAm0QQquCV+Ib/fGJgMNe02koq/Bgs1SBZTwfZw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.73.tgz", + "integrity": "sha512-PxlFZiF5IPUsPnuVSqjM9jlmRwZhL0xx40ccWfZiLQS8TRnHQpegpK1uAidatWuEqbJtbbY97A2Sezikz2AMUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.75.tgz", + "integrity": "sha512-Q/uIDy8eAVN7JgSKDg16F/f+hmNJj/ZyLJN8AkW2ofVdDgJVh8fO/A26iZ7YsvM2/k76hsf/u2lLAtyXsK8Ziw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.18.tgz", + "integrity": "sha512-KoyjZ9vdjuxEQ8obAH06xKP8H0WKqGhwUZLMqAOiS7G0xgUAht4X8f0ASLC/vhNLCWGblZOD3VQHWyTROGJHLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/credential-provider-env": "^3.972.73", + "@aws-sdk/credential-provider-http": "^3.972.75", + "@aws-sdk/credential-provider-login": "^3.972.80", + "@aws-sdk/credential-provider-process": "^3.972.73", + "@aws-sdk/credential-provider-sso": "^3.973.17", + "@aws-sdk/credential-provider-web-identity": "^3.972.79", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.80", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.80.tgz", + "integrity": "sha512-xF9A0gaO7k+1dYPFoo69OQatA9Ov0QuDZfqE8H0FLyIoOph+mlEC+I1Qo9UULupGuDfSYh+kVpfcDSxSYf/2fg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.85", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.85.tgz", + "integrity": "sha512-CrFcML/xImiSlquH4W3V6Npz3aSkPXHDVciAU1JOBN0vqjmkcz9H9up2sCHQqN76J/WrT+TJ580L8h2joksQrg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.73", + "@aws-sdk/credential-provider-http": "^3.972.75", + "@aws-sdk/credential-provider-ini": "^3.973.18", + "@aws-sdk/credential-provider-process": "^3.972.73", + "@aws-sdk/credential-provider-sso": "^3.973.17", + "@aws-sdk/credential-provider-web-identity": "^3.972.79", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.73.tgz", + "integrity": "sha512-tyNnT4aVuOEXmKKKZPExBi828vRd9heshobBsKF0bov5EORHSJc1AGsHOMRP57sDt+dIuVASUOywaixiX26t4g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.17.tgz", + "integrity": "sha512-gxKTWaUk2gSbBOH4m3NPRfEKcGXeDAOgHr7nNG5JktR1xZLo0R+DpMjbcleoHbB0uJREOG7hjIRE635IuBWsRQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/token-providers": "3.1149.0", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.79.tgz", + "integrity": "sha512-/jn0Gs/qm6fs6nTIA5yncHayybz42SATZ3rNxCEBYPJ9v69PLrDRgk07E9xTT2gpRcNmDXXbi27ZbuU06N0Z6Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-providers": { + "version": "3.1057.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1057.0.tgz", + "integrity": "sha512-rbrEHtz11g0kxsSkYr3fx2HABNNblp4AhB2MgPvJHgYOWfJ2eBviU7Mvoaef0PW8QH6lbZDfJcnM7eKvtvz3sw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.1057.0", + "@aws-sdk/core": "^3.974.15", + "@aws-sdk/credential-provider-cognito-identity": "^3.972.38", + "@aws-sdk/credential-provider-env": "^3.972.41", + "@aws-sdk/credential-provider-http": "^3.972.43", + "@aws-sdk/credential-provider-ini": "^3.972.46", + "@aws-sdk/credential-provider-login": "^3.972.45", + "@aws-sdk/credential-provider-node": "^3.972.47", + "@aws-sdk/credential-provider-process": "^3.972.41", + "@aws-sdk/credential-provider-sso": "^3.972.45", + "@aws-sdk/credential-provider-web-identity": "^3.972.45", + "@aws-sdk/nested-clients": "^3.997.13", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.5", + "@smithy/credential-provider-imds": "^4.3.6", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.47.tgz", + "integrity": "sha512-bovCKvDsLocNTzaj6VrrMVJ2BGROqO0JlwcGseO67a+I9Bo+08nUJhSQfZUIx4+A4a7I4101GEJd/AYm7dQ68w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/signature-v4-multi-region": "^3.996.48", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.48", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.48.tgz", + "integrity": "sha512-cFDXfEbXaGwbnpvR/DyqfxfjxpjXVRVthQLgkwvcfDS5u3+8G9WSj1X1Ag/pTRg/QOeOno1Tq3ycEdrKsCUcOA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.6", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1149.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1149.0.tgz", + "integrity": "sha512-CAwYyA8eVk1w6h8YgLvkjIOakWPwURaUFrP7BRVgkxTFUM+O87thyKsGKJ6rVPbP8eoEV10cBWQ5YfaYl3qSVQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.2", + "@aws-sdk/nested-clients": "^3.997.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.10.tgz", + "integrity": "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", + "integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@effect/opentelemetry": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@effect/opentelemetry/-/opentelemetry-4.0.1.tgz", + "integrity": "sha512-MZ7M1a1dxMqTqeNwIGSECjO7RrReEgeEwHo8BnbOqP+8IfVpdiJi/iPCmkFVWlAxanMl4qhsq69e0Hkul9t+bA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <2.0.0", + "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", + "@opentelemetry/resources": ">=2.0.0 <3.0.0", + "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", + "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", + "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", + "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", + "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", + "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", + "effect": "^4.0.1" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/api-logs": { + "optional": true + }, + "@opentelemetry/resources": { + "optional": true + }, + "@opentelemetry/sdk-logs": { + "optional": true + }, + "@opentelemetry/sdk-metrics": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "@opentelemetry/sdk-trace-node": { + "optional": true + }, + "@opentelemetry/sdk-trace-web": { + "optional": true + } + } + }, + "node_modules/@effect/platform-node": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@effect/platform-node/-/platform-node-4.0.1.tgz", + "integrity": "sha512-FT0Fu5LO0/XP9zBpj0HqrtJNLluK4StNKqf6TSulUsjp8FPBsDVXGJiDuyoN5J2yVMT7tgvhxSwaDcObWVRIaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@effect/platform-node-shared": "^4.0.1", + "undici": "^8.11.2" }, "engines": { "node": ">=18.0.0" }, - "peerDependencies": { - "@opencode-ai/plugin": ">=1.0.0" + "peerDependencies": { + "effect": "^4.0.1", + "redis": ">=5.0.0 <7.0.0" + } + }, + "node_modules/@effect/platform-node-shared": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@effect/platform-node-shared/-/platform-node-shared-4.0.1.tgz", + "integrity": "sha512-VanaejudEhKJ4mpmqnEzdncVUhN6sGSO9ZDND54mRxAlZxj+4dYAWjDTYAy9o4tJo9aALeP2U8cVcOFzEdSeLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ws": "^8.18.1", + "ws": "^8.22.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "effect": "^4.0.1" + } + }, + "node_modules/@gar/promise-retry": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", + "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@isaacs/string-locale-compare": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/string-locale-compare/-/string-locale-compare-1.1.0.tgz", + "integrity": "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@npmcli/agent": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.2.tgz", + "integrity": "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==", + "dev": true, + "license": "ISC", + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^11.2.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/arborist": { + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@npmcli/arborist/-/arborist-9.4.0.tgz", + "integrity": "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@isaacs/string-locale-compare": "^1.1.0", + "@npmcli/fs": "^5.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/metavuln-calculator": "^9.0.2", + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/query": "^5.0.0", + "@npmcli/redact": "^4.0.0", + "@npmcli/run-script": "^10.0.0", + "bin-links": "^6.0.0", + "cacache": "^20.0.1", + "common-ancestor-path": "^2.0.0", + "hosted-git-info": "^9.0.0", + "json-stringify-nice": "^1.1.4", + "lru-cache": "^11.2.1", + "minimatch": "^10.0.3", + "nopt": "^9.0.0", + "npm-install-checks": "^8.0.0", + "npm-package-arg": "^13.0.0", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "pacote": "^21.0.2", + "parse-conflict-json": "^5.0.1", + "proc-log": "^6.0.0", + "proggy": "^4.0.0", + "promise-all-reject-late": "^1.0.0", + "promise-call-limit": "^3.0.1", + "semver": "^7.3.7", + "ssri": "^13.0.0", + "treeverse": "^3.0.0", + "walk-up-path": "^4.0.0" + }, + "bin": { + "arborist": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/config": { + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/@npmcli/config/-/config-10.8.1.tgz", + "integrity": "sha512-MAYk9IlIGiyC0c9fnjdBSQfIFPZT0g1MfeSiD1UXTq2zJOLX55jS9/sETJHqw/7LN18JjITrhYfgCfapbmZHiQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/map-workspaces": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "ci-info": "^4.0.0", + "ini": "^6.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "walk-up-path": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", + "dev": true, + "license": "ISC", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/git": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.2.tgz", + "integrity": "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "ini": "^6.0.0", + "lru-cache": "^11.2.1", + "npm-pick-manifest": "^11.0.1", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/installed-package-contents": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz", + "integrity": "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-bundled": "^5.0.0", + "npm-normalize-package-bin": "^5.0.0" + }, + "bin": { + "installed-package-contents": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/map-workspaces": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/map-workspaces/-/map-workspaces-5.0.3.tgz", + "integrity": "sha512-o2grssXo1e774E5OtEwwrgoszYRh0lqkJH+Pb9r78UcqdGJRDRfhpM8DvZPjzNLLNYeD/rNbjOKM3Ss5UABROw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/name-from-folder": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "glob": "^13.0.0", + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/metavuln-calculator": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@npmcli/metavuln-calculator/-/metavuln-calculator-9.0.3.tgz", + "integrity": "sha512-94GLSYhLXF2t2LAC7pDwLaM4uCARzxShyAQKsirmlNcpidH89VA4/+K1LbJmRMgz5gy65E/QBBWQdUvGLe2Frg==", + "dev": true, + "license": "ISC", + "dependencies": { + "cacache": "^20.0.0", + "json-parse-even-better-errors": "^5.0.0", + "pacote": "^21.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/name-from-folder": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/name-from-folder/-/name-from-folder-4.0.0.tgz", + "integrity": "sha512-qfrhVlOSqmKM8i6rkNdZzABj8MKEITGFAY+4teqBziksCQAOLutiAxM1wY2BKEd8KjUSpWmWCYxvXr0y4VTlPg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/node-gyp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz", + "integrity": "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/package-json": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.5.tgz", + "integrity": "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/git": "^7.0.0", + "glob": "^13.0.0", + "hosted-git-info": "^9.0.0", + "json-parse-even-better-errors": "^5.0.0", + "proc-log": "^6.0.0", + "semver": "^7.5.3", + "spdx-expression-parse": "^4.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/promise-spawn": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz", + "integrity": "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "which": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/query": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/query/-/query-5.0.0.tgz", + "integrity": "sha512-8TZWfTQOsODpLqo9SVhVjHovmKXNpevHU0gO9e+y4V4fRIOneiXy0u0sMP9LmS71XivrEWfZWg50ReH4WRT4aQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/redact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", + "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@npmcli/run-script": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.4.tgz", + "integrity": "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/node-gyp": "^5.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "node-gyp": "^12.1.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@opencode/ai": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@opencode/ai/-/ai-2.1.4.tgz", + "integrity": "sha512-980bht1i8xv6hN/TQ+3tGujHuxfduje3jW69SKBvmiDxQBZ468Mk7sR4ztOy3NB/4fbI8iWEYhrcc3GbWWMxrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@aws-sdk/credential-providers": "3.1057.0", + "@opencode/schema": "2.1.4", + "@smithy/eventstream-codec": "4.2.14", + "@smithy/util-utf8": "4.2.2", + "aws4fetch": "1.0.20", + "effect": "4.0.1", + "google-auth-library": "10.5.0" + } + }, + "node_modules/@opencode/client": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@opencode/client/-/client-2.1.4.tgz", + "integrity": "sha512-xNHP8Xu4wcq4G849Xnbb8IxYxMTzF0TqvohWVUQZH5aURrVHIIbHMfXbZY6s4i4C9kpVbwjXFFwWAo6KIK3oiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@opencode/protocol": "2.1.4", + "@opencode/schema": "2.1.4" + }, + "peerDependencies": { + "effect": "4.0.1", + "solid-js": ">=1.9.0" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + }, + "solid-js": { + "optional": true + } + } + }, + "node_modules/@opencode/plugin": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@opencode/plugin/-/plugin-2.1.4.tgz", + "integrity": "sha512-SGAkdGp0VRxos2BEkRiyRl75G7DtuvP0ywgiEiHz1PbdUwUe2EAURMrzFgLCYCpWEm/zaNrguxPDVL0carrCsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@opencode/ai": "2.1.4", + "@opencode/client": "2.1.4", + "@opencode/protocol": "2.1.4", + "@opencode/schema": "2.1.4", + "@opencode/util": "2.1.4", + "effect": "4.0.1" + }, + "peerDependencies": { + "@opencode/theme": "2.1.4", + "@opentui/core": ">=0.6.8", + "@opentui/solid": ">=0.6.8", + "solid-js": ">=1.9.0" + }, + "peerDependenciesMeta": { + "@opencode/theme": { + "optional": true + }, + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + }, + "solid-js": { + "optional": true + } + } + }, + "node_modules/@opencode/protocol": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@opencode/protocol/-/protocol-2.1.4.tgz", + "integrity": "sha512-dnifgvY6p4xdHkXRH3PEaobfCqrHb1AhihKxPNRNkGn5SueRCFCfK2pZW1GkeC36fGCmcLxlfihuIMaQL1BVzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@opencode/schema": "2.1.4", + "effect": "4.0.1" + } + }, + "node_modules/@opencode/schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@opencode/schema/-/schema-2.1.4.tgz", + "integrity": "sha512-PLr8eFW81Yz6U7YsSgfSUClUUH//8omh3HiXu/L08VX5arorWNAu2uSjoXyHIGkm7TaTbdXBGPmmDfpvrDM2wQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "1.1.0", + "effect": "4.0.1" + } + }, + "node_modules/@opencode/util": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@opencode/util/-/util-2.1.4.tgz", + "integrity": "sha512-Ihj6DqbnnovgUYSXki4PgvGxNbmB1wggDySIdM55Rz+sTfwFZ+zwROh3PrC4a8Vx/cJ6xQg41Uxa4nbcTbGI4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@effect/opentelemetry": "4.0.1", + "@effect/platform-node": "4.0.1", + "@effect/platform-node-shared": "4.0.1", + "@npmcli/arborist": "9.4.0", + "@npmcli/config": "10.8.1", + "@opentelemetry/api": "1.9.0", + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/context-async-hooks": "2.8.0", + "@opentelemetry/exporter-trace-otlp-http": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", + "@opentelemetry/sdk-metrics": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0", + "@opentelemetry/sdk-trace-node": "2.8.0", + "cross-spawn": "7.0.6", + "effect": "4.0.1", + "glob": "13.0.5", + "mime-types": "3.0.2", + "minimatch": "10.2.5", + "npm-package-arg": "13.0.2", + "open": "11.0.4", + "pacote": "21.5.1" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", + "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.8.0.tgz", + "integrity": "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/exporter-trace-otlp-http": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.219.0.tgz", + "integrity": "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-exporter-base": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.219.0.tgz", + "integrity": "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-transformer": "0.219.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/otlp-transformer": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz", + "integrity": "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", + "@opentelemetry/sdk-metrics": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-logs": { + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", + "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.4.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-metrics": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz", + "integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.9.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "deprecated": "Use @opentelemetry/sdk-trace package", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.8.0.tgz", + "integrity": "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg==", + "deprecated": "Use @opentelemetry/sdk-trace package", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.8.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.155.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", + "integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@redis/bloom": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.3.0.tgz", + "integrity": "sha512-NQ5poYpZr0jv6zazN1cy6JY5kIipKFZ3R9GTL4kOUTX3a9YAfY29U8bsE4lWhVQEM+G4V0BXVC57mubIdDijeA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.3.0" + } + }, + "node_modules/@redis/client": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-6.3.0.tgz", + "integrity": "sha512-fHrjwGCBvAANGi+TuRuwKi83sIOzgHr8eh+Uf48w3fSX7Hhrz174NrWqfJhAC2vnN0d/tfaZpdr6+o6JC3QHwg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-6.3.0.tgz", + "integrity": "sha512-yt6vRfPBXtW/3qm53cF81TSQpLMjWdW4/2E9WQ2GgbvsKkuCKCFQIcMiKlil1ZMnezK+IQuS795xKU5psbaegw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.3.0" + } + }, + "node_modules/@redis/search": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-6.3.0.tgz", + "integrity": "sha512-RPXAZmKjZZG7Tm7m1w9keFdXzbL91PiSHQBfQRe9xwW40jNsTRbG0FwhIz4QlHPU4gKDAsCy8IQHj4yZl4H9rw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.3.0" + } + }, + "node_modules/@redis/time-series": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.3.0.tgz", + "integrity": "sha512-+fJOB8mN1z5WRakCJNh5lVdz1KqvHGSOistsKeitD24QsCtPwyRQXWC0iq6T/i/ubLE/YcguZqPm8F598MKk4A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.3.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz", + "integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz", + "integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz", + "integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz", + "integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz", + "integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz", + "integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz", + "integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz", + "integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz", + "integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz", + "integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz", + "integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz", + "integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz", + "integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz", + "integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz", + "integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@sigstore/bundle": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", + "integrity": "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/core": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.1.tgz", + "integrity": "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/protobuf-specs": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.2.tgz", + "integrity": "sha512-SQqvFMt4V78fdjcDdYX6HbiVSOR4QK3ZgwCa2KOsopAgPIHy1rU5UDUmzLl02r5oyyaYcYHR1hpwDRk/yUe+Mw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@sigstore/sign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-4.1.1.tgz", + "integrity": "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gar/promise-retry": "^1.0.2", + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.0", + "@sigstore/protobuf-specs": "^0.5.0", + "make-fetch-happen": "^15.0.4", + "proc-log": "^6.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/tuf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-4.0.2.tgz", + "integrity": "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/protobuf-specs": "^0.5.0", + "tuf-js": "^4.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@sigstore/verify": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.1.tgz", + "integrity": "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.35.2", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.2.tgz", + "integrity": "sha512-ejTukBuP/Pg3t9N8Ux+0zKsXeHWm7p6W3HbXFOyltKasSxZwCFmvTut7kBdZSCF3m9y1bXXyVu6p2xuMFcsTyQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz", + "integrity": "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.5.2.tgz", + "integrity": "sha512-nxu3SgmAw9JXT2CtkU0m/XNLWpP9MsaBx1zAGAypCbYj15tIFlmcYwpF+Oh18le83d+IM9PT7ENdXnE4C+d5mA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.5.2.tgz", + "integrity": "sha512-iq+cW3mAb7vfcxEEpYi3zXKpDtbrIFyanWjQl4zBq4seWD4OSxXDWSfespZxenX6aEaighn+NR3u1nU1DSvs3w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tufjs/canonical-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", + "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/@tufjs/models": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-4.1.0.tgz", + "integrity": "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/canonical-json": "2.0.0", + "minimatch": "^10.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-5.0.5.tgz", + "integrity": "sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/istanbul-lib-coverage": "^1.0.2", + "@vitest/istanbul-lib-report": "^1.0.2", + "ast-v8-to-istanbul": "^1.0.7", + "magicast": "^0.5.5", + "obug": "^3.0.0", + "std-env": "^4.3.0", + "tinyrainbow": "^3.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "5.0.5", + "vitest": "5.0.5" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/istanbul-lib-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.2.tgz", + "integrity": "sha512-9J/JMwOf9AoJhAywhrn7ScKTL38hsWQP/qPG60OtaAFcQ5OXPwKsxZFlbnuCKmZ61m8/lGgHYnFpdyQZUvG/iA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/istanbul-lib-report": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@vitest/istanbul-lib-report/-/istanbul-lib-report-1.0.2.tgz", + "integrity": "sha512-gUsfXZJbzPamoIY5TvHFiMMoXESBrUMo+xqaj+rYrWI69+EnvRlYBlP96ZnHPY4vX8kyUpgAnFUCg5wZG/HkDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/istanbul-lib-coverage": "1.0.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abbrev": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.7.tgz", + "integrity": "sha512-kFL68AG6ajd8fg248zwM9GQrUWEp79gsmjum34OEXjs4yHuUMZfYKwOLW9GMmB4oNvVrj+EAGxsP7ye2UR9UlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/aws4fetch": { + "version": "1.0.20", + "resolved": "https://registry.npmjs.org/aws4fetch/-/aws4fetch-1.0.20.tgz", + "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bin-links": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/bin-links/-/bin-links-6.0.2.tgz", + "integrity": "sha512-frE1t78WOwJ45PKV2cF2tNPjTcs9L1J9s6VkrV59wanRP4GlaomuxYPVma7BwthMg8WnfSory4w5PTE6FZZ81w==", + "dev": true, + "license": "ISC", + "dependencies": { + "cmd-shim": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "proc-log": "^6.0.0", + "read-cmd-shim": "^6.0.0", + "write-file-atomic": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cacache": { + "version": "20.0.4", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", + "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/fs": "^5.0.0", + "fs-minipass": "^3.0.0", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cmd-shim": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/cmd-shim/-/cmd-shim-8.0.0.tgz", + "integrity": "sha512-Jk/BK6NCapZ58BKUxlSI+ouKRbjH1NLZCgJkYoab+vEHUY3f6OzpNBN9u7HFSv9J6TRDGs4PLOHezoKGaFRSCA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/common-ancestor-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/common-ancestor-path/-/common-ancestor-path-2.0.0.tgz", + "integrity": "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">= 18" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/effect": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.1.tgz", + "integrity": "sha512-b1VlQG9g8fwxE5QnIZuPoOP/0MsqHJSRKxUijjoM80E5q95FXrX5yWtY5XI8G+GJsBQAVSDbLS458z9a++uVvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.4", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.4.tgz", + "integrity": "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "gaxios": "7.1.3", + "google-logging-utils": "1.1.3", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/glob": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.5.tgz", + "integrity": "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.1", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.5.0.tgz", + "integrity": "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.0.0", + "gcp-metadata": "^8.0.0", + "google-logging-utils": "^1.0.0", + "gtoken": "^8.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.2.0.tgz", + "integrity": "sha512-WE9av4wKDZgRjBwgVUabocx8T6/7o3Ca1Fat46FXDhXVAFibzNadedcOXrdgd1Kzmk8tsk/9ZH89Wyf/SqeZ3A==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/gtoken": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", + "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "gaxios": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore-walk": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", + "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^10.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ip-address": { + "version": "10.7.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz", + "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", + "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=20" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", + "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-stringify-nice": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/json-stringify-nice/-/json-stringify-nice-1.1.4.tgz", + "integrity": "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw==", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jsonparse": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", + "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", + "dev": true, + "engines": [ + "node >= 0.2.0" + ], + "license": "MIT" + }, + "node_modules/just-diff": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/just-diff/-/just-diff-6.0.2.tgz", + "integrity": "sha512-S59eriX5u3/QhMNq3v/gm8Kd0w8OS6Tz2FS1NG4blv+z0MuQcBRJyFWjdovM0Rad4/P4aUPFtnkNjMjyMlMSYA==", + "dev": true, + "license": "MIT" + }, + "node_modules/just-diff-apply": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/just-diff-apply/-/just-diff-apply-5.5.0.tgz", + "integrity": "sha512-OYTthRfSh55WOItVqwpefPtNt2VdKsq5AnAK6apdtR6yCH8pr0CmSr710J0Mf+WdQy7K/OzMy7K2MgAfdQURDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.4.tgz", + "integrity": "sha512-jQz9sVhrZuphH4NrAIHSgkDJCluQO7Ioi4KGRLa7aTUjAODQ13hENX0YIytqYE/rIkO485gQG2LeBxJaW53kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/magicast": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.5.tgz", + "integrity": "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-fetch-happen": { + "version": "15.0.6", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", + "integrity": "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/agent": "^4.0.0", + "@npmcli/redact": "^4.0.0", + "cacache": "^20.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^6.0.0", + "ssri": "^13.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", + "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^2.0.0", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + }, + "optionalDependencies": { + "iconv-lite": "^0.7.2" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", + "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC" + }, + "node_modules/minipass-sized": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-gyp": { + "version": "12.4.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", + "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "tar": "^7.5.4", + "tinyglobby": "^0.2.12", + "undici": "^6.25.0", + "which": "^6.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/node-gyp/node_modules/undici": { + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/nopt": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^4.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-bundled": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", + "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^5.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-install-checks": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", + "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "semver": "^7.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", + "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-package-arg": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", + "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", + "dev": true, + "license": "ISC", + "dependencies": { + "hosted-git-info": "^9.0.0", + "proc-log": "^6.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^7.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-packlist": { + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", + "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", + "dev": true, + "license": "ISC", + "dependencies": { + "ignore-walk": "^8.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-pick-manifest": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", + "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-install-checks": "^8.0.0", + "npm-normalize-package-bin": "^5.0.0", + "npm-package-arg": "^13.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/npm-registry-fetch": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", + "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@npmcli/redact": "^4.0.0", + "jsonparse": "^1.3.1", + "make-fetch-happen": "^15.0.0", + "minipass": "^7.0.2", + "minipass-fetch": "^5.0.0", + "minizlib": "^3.0.1", + "npm-package-arg": "^13.0.0", + "proc-log": "^6.0.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/open": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz", + "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.5.1", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.2.1", + "wsl-utils": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.1.1.tgz", + "integrity": "sha512-6Lh3jQPLthYujSrlw6ccWXBGZTkoTCz27Hd92g79LH/kjAbgoEu0t442cCCNbegPSJsA0lwDX8z0bcKtkpA32g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/pacote": { + "version": "21.5.1", + "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz", + "integrity": "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@gar/promise-retry": "^1.0.0", + "@npmcli/git": "^7.0.0", + "@npmcli/installed-package-contents": "^4.0.0", + "@npmcli/package-json": "^7.0.0", + "@npmcli/promise-spawn": "^9.0.0", + "@npmcli/run-script": "^10.0.0", + "cacache": "^20.0.0", + "fs-minipass": "^3.0.0", + "minipass": "^7.0.2", + "npm-package-arg": "^13.0.0", + "npm-packlist": "^10.0.1", + "npm-pick-manifest": "^11.0.1", + "npm-registry-fetch": "^19.0.0", + "proc-log": "^6.0.0", + "sigstore": "^4.0.0", + "ssri": "^13.0.0", + "tar": "^7.4.3" + }, + "bin": { + "pacote": "bin/index.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/parse-conflict-json": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/parse-conflict-json/-/parse-conflict-json-5.0.1.tgz", + "integrity": "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "json-parse-even-better-errors": "^5.0.0", + "just-diff": "^6.0.0", + "just-diff-apply": "^5.2.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "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", + "peer": true, + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.7.tgz", + "integrity": "sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/powershell-utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", + "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/proggy": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/proggy/-/proggy-4.0.0.tgz", + "integrity": "sha512-MbA4R+WQT76ZBm/5JUpV9yqcJt92175+Y0Bodg3HgiXzrmKu7Ggq+bpn6y6wHH+gN9NcyKn3yg1+d47VaKwNAQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/promise-all-reject-late": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/promise-all-reject-late/-/promise-all-reject-late-1.0.1.tgz", + "integrity": "sha512-vuf0Lf0lOxyQREH7GDIOUMLS7kz+gs8i6B+Yi8dC68a2sychGrHTJYghMBD6k7eUcH0H5P73EckCA48xijWqXw==", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/promise-call-limit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/promise-call-limit/-/promise-call-limit-3.0.2.tgz", + "integrity": "sha512-mRPQO2T1QQVw11E7+UdCJu7S61eJVWknzml9sC1heAdj1jxl0fWMBypIt9ZOcLFf8FkG995ZD7RnVk7HH72fZw==", + "dev": true, + "license": "ISC", + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/read-cmd-shim": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz", + "integrity": "sha512-1zM5HuOfagXCBWMN83fuFI/x+T/UhZ7k+KIzhrHXcQoeX5+7gmaDYjELQHmmzIodumBHeByBJT4QYS7ufAgs7A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/redis": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-6.3.0.tgz", + "integrity": "sha512-XFQbPie1lGpKeUZ8ySYY43yLnQy/iIQtHNvIr/1XdaABpb96rUSdm3Mfl+98FEVbv9ZJIzINMno5s0Nh8E5LEw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@redis/bloom": "6.3.0", + "@redis/client": "6.3.0", + "@redis/json": "6.3.0", + "@redis/search": "6.3.0", + "@redis/time-series": "6.3.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rolldown": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz", + "integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.155.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.15", + "@rolldown/binding-android-arm64": "1.2.15", + "@rolldown/binding-darwin-arm64": "1.2.15", + "@rolldown/binding-darwin-x64": "1.2.15", + "@rolldown/binding-freebsd-x64": "1.2.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.15", + "@rolldown/binding-linux-arm64-gnu": "1.2.15", + "@rolldown/binding-linux-arm64-musl": "1.2.15", + "@rolldown/binding-linux-ppc64-gnu": "1.2.15", + "@rolldown/binding-linux-s390x-gnu": "1.2.15", + "@rolldown/binding-linux-x64-gnu": "1.2.15", + "@rolldown/binding-linux-x64-musl": "1.2.15", + "@rolldown/binding-openharmony-arm64": "1.2.15", + "@rolldown/binding-win32-arm64-msvc": "1.2.15", + "@rolldown/binding-win32-x64-msvc": "1.2.15" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sigstore": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz", + "integrity": "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@sigstore/bundle": "^4.0.0", + "@sigstore/core": "^3.2.1", + "@sigstore/protobuf-specs": "^0.5.0", + "@sigstore/sign": "^4.1.1", + "@sigstore/tuf": "^4.0.2", + "@sigstore/verify": "^3.1.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" } }, - "node_modules/@opencode-ai/plugin": { - "version": "1.2.26", - "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.2.26.tgz", - "integrity": "sha512-pC71KGAI9T0+S84KpbEq9THp5pT7KOq+GmfdXkvQ7KSH5zi+iASWRhqorir73sKmEj2MQfpbe1BxdcU5qbeOwA==", + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", "dev": true, "license": "MIT", "dependencies": { - "@opencode-ai/sdk": "1.2.26", - "zod": "4.1.8" + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.24", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz", + "integrity": "sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ssri": { + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", + "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@opencode-ai/sdk": { - "version": "1.2.26", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.2.26.tgz", - "integrity": "sha512-HPB+0pfvTMPj2KEjNLF3oqgldKW8koTJ7ssqXwzndazqxS+gUynzvdIKIQP4+QIInNcc5nJMG9JtfLcePGgTLQ==", + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, - "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/husky": { - "version": "9.1.7", - "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", - "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", - "bin": { - "husky": "bin.js" + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" }, "engines": { - "node": ">=18" + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" }, - "funding": { - "url": "https://github.com/sponsors/typicode" + "engines": { + "node": ">=8" } }, - "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" + "dependencies": { + "ansi-regex": "^6.2.2" }, "engines": { - "node": ">=14" + "node": ">=12" }, "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" } }, "node_modules/strip-json-comments": { @@ -97,6 +4669,102 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tar": { + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/treeverse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/treeverse/-/treeverse-3.0.0.tgz", + "integrity": "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tuf-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", + "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tufjs/models": "4.1.0", + "debug": "^4.4.3", + "make-fetch-happen": "^15.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -111,6 +4779,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.12.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz", + "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -118,14 +4796,392 @@ "dev": true, "license": "MIT" }, - "node_modules/zod": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", - "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/vite": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", + "tinyglobby": "^0.2.17" + }, + "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.7.1 || ^0.8.0", + "esbuild": "^0.27.0 || ^0.28.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": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.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/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", + "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^4.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "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/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "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/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/write-file-atomic": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-7.0.1.tgz", + "integrity": "sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==", + "dev": true, + "license": "ISC", + "dependencies": { + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.1.tgz", + "integrity": "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==", "dev": true, "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.2.0" + }, + "engines": { + "node": ">=20" + }, "funding": { - "url": "https://github.com/sponsors/colinhacks" + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" } } } diff --git a/package.json b/package.json index 180c254..d22f622 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,16 @@ "description": "OpenCode plugin that restores the paste-and-ask workflow for text-only models by saving pasted images and injecting MCP tool instructions", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, "type": "module", "files": [ + "index.js", "dist", "opencode-easy-vision.example.jsonc", "AGENT_SETUP.md", @@ -14,10 +22,14 @@ "scripts": { "clean": "node --eval \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "build": "npm run clean && tsc", - "format": "prettier --write src/**/*.ts", - "format:check": "prettier --check src/**/*.ts", + "test": "vitest run", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" vitest.config.ts", + "format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\" vitest.config.ts", "prepublishOnly": "npm run build", - "prepare": "husky" + "prepare": "husky", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "typecheck": "tsc -p tsconfig.test.json" }, "keywords": [ "opencode", @@ -31,17 +43,18 @@ "author": "Devadathan M B ", "license": "AGPL-3.0", "devDependencies": { - "@opencode-ai/plugin": "^1.2.26", + "@opencode/ai": "^2.1.4", + "@opencode/plugin": "^2.1.4", "@types/node": "^25.0.0", + "@vitest/coverage-v8": "^5.0.5", + "effect": "^4.0.1", "husky": "^9.1.7", "prettier": "^3.8.1", - "typescript": "^5.7.0" - }, - "peerDependencies": { - "@opencode-ai/plugin": ">=1.0.0" + "typescript": "^5.7.0", + "vitest": "^5.0.5" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.18.0" }, "repository": { "type": "git", @@ -49,5 +62,13 @@ }, "dependencies": { "strip-json-comments": "^5.0.3" + }, + "peerDependencies": { + "effect": "^4.0.1" + }, + "peerDependenciesMeta": { + "effect": { + "optional": true + } } } diff --git a/scripts/v2-spike/README.md b/scripts/v2-spike/README.md new file mode 100644 index 0000000..0cb8b03 --- /dev/null +++ b/scripts/v2-spike/README.md @@ -0,0 +1,58 @@ +# V2 runtime spike + +This is the Phase 0 compatibility probe, not the migrated Easy Vision plugin. It targets the installed **OpenCode 2.1.4** binary and uses Node.js with no new repository dependencies. + +Recorded baseline outcomes and remaining gates: [RESULTS.md](./RESULTS.md). + +```sh +node scripts/v2-spike/run.mjs +``` + +The runner creates temporary projects and an isolated home/config/data/cache environment, then invokes private servers with `opencode run --standalone`. It does not use your running service, existing configuration, provider keys, or MCP connections. A loopback-only OpenAI-compatible fixture produces deterministic tool calls; a local stdio MCP fixture checks that the saved PNG is readable. Neither fixture performs real image analysis or calls an external model. + +The runner checks the binary version before proceeding and installs exactly `@opencode/plugin@2.1.4` into a temporary plugin package with lifecycle scripts disabled. npm registry access is required; repository dependencies are unchanged. Each OpenCode invocation has a two-minute timeout. The child environment uses an allowlist so provider credentials and OpenCode configuration overrides are not inherited. The fixtures are deliberately limited to synthetic inline PNGs. + +The probe waits up to twenty seconds for MCP connection **and** the actual tool registry entry before admitting each test prompt. A connected server alone does not mean its tools have reached the model's catalog yet. + +Files: + +- `run.mjs`: temporary environment, provider fixture, and assertions. +- `plugin.mjs`: minimal V2 context-hook probe; not the production plugin. +- `mcp.mjs`: stdio MCP fixture implementing `understand_image`. +- `after.mjs`: non-mutating observer loaded after the real production package. + +Pass an absolute production `dist/index.js` as the first argument to `run.mjs` to test that package instead of the image-rewriting probe. OpenCode loads the real package directory natively between before/after observers; neither observer saves or rewrites images in this mode. Use the entrypoint from an installed tarball to validate the package and dependency contract. + +Checks: + +- A model declaring only text input still exposes PNG media to the context hook. +- V2 exposes the active model as `{ providerID, id }`. +- The hook can replace media before provider dispatch, preserving user text. +- Image-only input works. +- Tool-driven continuations rerun the hook with stable saved image paths. +- The `MiniMax` server registers `MiniMax_understand_image`. +- Direct MCP exposure and default Code Mode both allow access to the saved PNG. + +Outbound assertions run against the actual provider HTTP payload, not just the hook's edited messages. Synthetic outbound payloads are retained in each project's `outbound.json`; hook records contain shape metadata and generated paths, not image bytes or user prompts. + +The temporary artifact directory is printed on success or command failure. It contains synthetic fixtures, hook metadata, isolated OpenCode state/logs, and per-command output for debugging. It is deliberately retained; remove that exact directory yourself when finished. Do not attach real images to this probe. + +The CLI rejects empty `run --file` messages. The image-only case uses a fixture-only admission hook to clear a sentinel before persistence. The production migration must still use request-context hooks, not admission edits. + +## Live OpenRouter gate + +The ignored local setup at `.opencode/v2-test/opencode.json` uses the catalog's text-only `deepseek/deepseek-v4-flash-0731` and a direct `Vision_understand_image` MCP tool backed by `google/gemini-2.5-flash`. DeepSeek V4.1 Flash advertises image input and is not a text-only test candidate. `openrouter-mcp.mjs` is a test backend, not a production dependency. + +The OpenRouter catalog queried on 2026-10-11 contained 458 entries, 162 without image input and 119 of those advertising tool support. The complete non-vision subset is retained in the ignored `.opencode/v2-test/non-vision-models.json`. These are catalog declarations, not a guarantee of endpoint availability or account access. + +Set `OPENROUTER_API_KEY` in the launching environment, then run: + +```sh +node scripts/v2-spike/run-live.mjs +# Optional: attach a different image (sent to OpenRouter). +node scripts/v2-spike/run-live.mjs /absolute/path/image.png +``` + +This incurs real API charges. By default it sends a synthetic red PNG. The launcher uses isolated OpenCode home/config/state directories and a private server. Only the vision MCP tool is permitted. The ignored local config contains absolute paths for this checkout; do not commit credentials or generated state. The observer needs its pinned dependency installed first (`npm install --ignore-scripts --prefix .opencode/v2-test/plugin`). The local setup now loads the installed production package separately; `productionEntry` on the observer switches off its image rewriting. A completed tool call must use a plugin-generated image path to pass. + +Still requires follow-up: clipboard/TUI behavior, reload/unload, concurrent location isolation, session moves, auxiliary hooks, and installed-package testing. Passing these probes does not establish those behaviors or prove compatibility with any other OpenCode release. diff --git a/scripts/v2-spike/RESULTS.md b/scripts/v2-spike/RESULTS.md new file mode 100644 index 0000000..ece50e5 --- /dev/null +++ b/scripts/v2-spike/RESULTS.md @@ -0,0 +1,10 @@ +# V2 baseline probe results + +## 2026-10-26 re-run on OpenCode 2.1.4 + +The production package was re-validated against **OpenCode 2.1.4** after moving to the Effect plugin API, making OpenCode imports type-only, and adding the Code Mode transform. `node scripts/v2-spike/run.mjs ` passed in both modes, run once from the checkout and once from an `npm pack` tarball installed into a clean project. The installed package contained only `opencode-easy-vision` and `strip-json-comments`, which proves OpenCode supplies `effect` at runtime. + +- The fixture runs three MCP modes and asserts the route the model sees: `direct` (`codemode: false`), `codemode` (`codemode: true`, so the tool stays behind `execute` and the plugin must not override it) and `auto` (unset, so the plugin disables Code Mode and the tool is exposed directly). All three passed, each with one MCP call per task. +- Plugin logs reached `opencode.log` tagged `plugin=easy-vision`. The earlier `console` logging from the Promise API version never reached the log. + +The sections below record the original 2.0.26 baseline. diff --git a/scripts/v2-spike/after.mjs b/scripts/v2-spike/after.mjs new file mode 100644 index 0000000..6049a0e --- /dev/null +++ b/scripts/v2-spike/after.mjs @@ -0,0 +1,40 @@ +import assert from "node:assert/strict"; +import { appendFile } from "node:fs/promises"; +import { join } from "node:path"; + +// Loaded after the real package. This fixture observes only; it never rewrites. +export default { + id: "easy-vision-v2-after", + async setup(ctx) { + await ctx.session.hook("context", async (event) => { + const plugins = await ctx.plugin.list(); + await appendFile( + join(ctx.location.directory, "hooks.jsonl"), + JSON.stringify({ + plugins: plugins.data.map((plugin) => ({ + id: plugin.id, + state: plugin.state, + source: plugin.source, + })), + }) + "\n", + ); + const paths = event.messages.flatMap((message) => + message.content + .filter( + (part) => + part.type === "text" && + part.text.trim().startsWith("Attached image: "), + ) + .map((part) => part.text.trim().slice("Attached image: ".length)), + ); + assert( + paths.length > 0, + "Production package did not replace the attached image; check native entrypoint loading", + ); + await appendFile( + join(ctx.location.directory, "hooks.jsonl"), + JSON.stringify({ processed: true, paths }) + "\n", + ); + }); + }, +}; diff --git a/scripts/v2-spike/mcp.mjs b/scripts/v2-spike/mcp.mjs new file mode 100644 index 0000000..b1831a0 --- /dev/null +++ b/scripts/v2-spike/mcp.mjs @@ -0,0 +1,69 @@ +import { appendFile, readFile } from "node:fs/promises"; +import { createInterface } from "node:readline"; + +const tool = { + name: "understand_image", + description: + "Synthetic fixture: verify a saved PNG is readable. Does not analyze images.", + inputSchema: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + additionalProperties: false, + }, +}; + +for await (const line of createInterface({ input: process.stdin })) { + const request = JSON.parse(line); + if (request.id === undefined) continue; + try { + let result; + switch (request.method) { + case "initialize": + result = { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "vision-spike-fixture", version: "1.0.0" }, + }; + break; + case "ping": + result = {}; + break; + case "tools/list": + result = { tools: [tool] }; + break; + case "tools/call": { + if (request.params.name !== tool.name) + throw new Error("Unknown fixture tool"); + const path = request.params.arguments.path; + const bytes = await readFile(path); + if ( + !bytes + .subarray(0, 8) + .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + ) { + throw new Error("Not a PNG"); + } + await appendFile( + process.env.SPIKE_CALLS, + JSON.stringify({ path, bytes: bytes.length }) + "\n", + ); + result = { content: [{ type: "text", text: "SPIKE_PNG_READABLE" }] }; + break; + } + default: + throw new Error("Unsupported fixture method"); + } + process.stdout.write( + JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n", + ); + } catch (error) { + process.stdout.write( + JSON.stringify({ + jsonrpc: "2.0", + id: request.id, + error: { code: -32603, message: error.message }, + }) + "\n", + ); + } +} diff --git a/scripts/v2-spike/openrouter-mcp.mjs b/scripts/v2-spike/openrouter-mcp.mjs new file mode 100644 index 0000000..25828b8 --- /dev/null +++ b/scripts/v2-spike/openrouter-mcp.mjs @@ -0,0 +1,120 @@ +import { readFile } from "node:fs/promises"; +import { createInterface } from "node:readline"; + +// Live test backend only. The production plugin never invokes a model itself. +const tool = { + name: "understand_image", + description: + "Analyze a saved local image using a vision-capable OpenRouter model.", + inputSchema: { + type: "object", + properties: { + path: { type: "string", description: "Absolute path to the saved image" }, + prompt: { type: "string", description: "Question about the image" }, + }, + required: ["path", "prompt"], + additionalProperties: false, + }, +}; +for await (const line of createInterface({ input: process.stdin })) { + const request = JSON.parse(line); + if (request.id === undefined) continue; + try { + let result; + switch (request.method) { + case "initialize": + result = { + protocolVersion: "2024-11-05", + capabilities: { tools: {} }, + serverInfo: { name: "openrouter-vision-test", version: "1.0.0" }, + }; + break; + case "ping": + result = {}; + break; + case "tools/list": + result = { tools: [tool] }; + break; + case "tools/call": { + if (request.params.name !== tool.name) throw new Error("Unknown tool"); + const { path, prompt } = request.params.arguments; + if (typeof path !== "string" || typeof prompt !== "string") + throw new Error("path and prompt are required"); + const key = process.env.OPENROUTER_API_KEY; + if (!key) + throw new Error("Set OPENROUTER_API_KEY before starting the test"); + const bytes = await readFile(path); + if (bytes.length > 10 * 1024 * 1024) + throw new Error("Test image exceeds 10 MiB"); + let mime; + if ( + bytes + .subarray(0, 8) + .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + ) + mime = "image/png"; + else if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) + mime = "image/jpeg"; + else if ( + bytes.toString("ascii", 0, 4) === "RIFF" && + bytes.toString("ascii", 8, 12) === "WEBP" + ) + mime = "image/webp"; + else throw new Error("Expected PNG, JPEG, or WebP"); + const response = await fetch( + "https://openrouter.ai/api/v1/chat/completions", + { + method: "POST", + signal: AbortSignal.timeout(90_000), + headers: { + authorization: `Bearer ${key}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + model: process.env.VISION_MODEL ?? "google/gemini-2.5-flash", + max_tokens: 1024, + messages: [ + { + role: "user", + content: [ + { type: "text", text: prompt }, + { + type: "image_url", + image_url: { + url: `data:${mime};base64,${bytes.toString("base64")}`, + }, + }, + ], + }, + ], + }), + }, + ); + if (!response.ok) + throw new Error( + `OpenRouter vision request failed (HTTP ${response.status})`, + ); + const body = await response.json(); + const text = body.choices?.[0]?.message?.content; + if (typeof text !== "string" || !text.trim()) + throw new Error("OpenRouter returned no analysis"); + result = { content: [{ type: "text", text }] }; + break; + } + default: + throw new Error("Unsupported method"); + } + process.stdout.write( + JSON.stringify({ jsonrpc: "2.0", id: request.id, result }) + "\n", + ); + } catch (error) { + // Do not serialize provider responses, credentials, or file contents on failure. + process.stdout.write( + JSON.stringify({ + jsonrpc: "2.0", + id: request.id, + error: { code: -32603, message: error.message }, + }) + "\n", + ); + } +} diff --git a/scripts/v2-spike/plugin.mjs b/scripts/v2-spike/plugin.mjs new file mode 100644 index 0000000..0a958f8 --- /dev/null +++ b/scripts/v2-spike/plugin.mjs @@ -0,0 +1,136 @@ +import { createHash } from "node:crypto"; +import { appendFile, mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout } from "node:timers/promises"; +import { Plugin } from "@opencode/plugin"; + +// This probe handles synthetic inline PNGs only; it is not the production adapter. +export default Plugin.define({ + id: "easy-vision-v2-spike", + async setup(ctx) { + const root = ctx.location.directory; + const record = (value) => + appendFile(join(root, "hooks.jsonl"), JSON.stringify(value) + "\n"); + await record({ lifecycle: "setup", version: ctx.app.version }); + // A private server may accept its first prompt before MCP startup completes. + // Waiting at admission is fixture orchestration, not production prompt rewriting. + await ctx.session.hook("prompt", async () => { + const name = ctx.options.live ? "Vision" : "MiniMax"; + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + const servers = await ctx.mcp.list(); + const server = servers.data.find((entry) => entry.name === name); + const tools = await ctx.tool.list(); + if ( + server?.status.status === "connected" && + tools.some((tool) => tool.id === `${name}_understand_image`) + ) { + await record({ readiness: "connected", server: name }); + return; + } + if ( + server && + server.status.status !== "pending" && + server.status.status !== "connected" + ) { + throw new Error(`Probe MCP ${name} is ${server.status.status}`); + } + await setTimeout(100); + } + throw new Error(`Probe MCP ${name} did not connect before admission`); + }); + if (!ctx.options.live) { + // `run --file` rejects empty CLI messages; clear this fixture-only + // sentinel at admission to exercise actual attachment-only context. + await ctx.session.hook("prompt", (event) => { + if (event.prompt.text === "SPIKE_IMAGE_ONLY_SENTINEL") { + event.prompt.text = ""; + } + }); + await ctx.session.hook("model.request", (event) => { + event.headers["x-spike-kind"] = event.kind; + }); + } + await ctx.session.hook("context", async (event) => { + const registry = await ctx.tool.list(); + await record({ + requestTools: Object.keys(event.tools), + registry: registry.map((tool) => tool.id), + }); + if (ctx.options.live && !event.tools[ctx.options.toolName]) { + throw new Error( + "Configured vision tool is absent from the live request tool list", + ); + } + if (ctx.options.productionEntry) { + await record({ + model: { providerID: event.model.providerID, id: event.model.id }, + sources: event.messages.flatMap((message) => + message.role === "user" + ? message.content + .filter( + (part) => + part.type === "media" && part.media.kind === "image", + ) + .map((part) => part.media.source.type) + : [], + ), + tools: Object.keys(event.tools), + }); + return; + } + const paths = []; + const sources = []; + const messages = []; + for (const message of event.messages) { + const content = []; + for (const part of message.content) { + if ( + message.role !== "user" || + part.type !== "media" || + part.media.kind !== "image" + ) { + content.push(part); + continue; + } + const source = part.media.source; + sources.push(source.type); + if (source.type !== "bytes" && source.type !== "base64") { + throw new Error( + `Probe expects inline PNG, received ${source.type}`, + ); + } + const bytes = + source.type === "bytes" + ? source.data + : Buffer.from(source.data, "base64"); + const hash = createHash("sha256") + .update(bytes) + .update(part.media.mediaType) + .digest("hex"); + await mkdir(join(root, "images"), { recursive: true }); + const path = join(root, "images", `easy-vision-${hash}.png`); + await writeFile(path, bytes); + paths.push(path); + content.push({ + type: "text", + text: ctx.options.live + ? `Attached image saved at ${path}. Use the ${ctx.options.toolName} tool with this path to analyze it before answering. Do not guess the image contents.` + : `SPIKE_IMAGE_PATH=${path}`, + }); + } + messages.push({ ...message, content }); + } + event.messages = messages; + await record({ + model: { providerID: event.model.providerID, id: event.model.id }, + sources, + paths, + tools: Object.keys(event.tools), + }); + }); + return async () => { + await record({ lifecycle: "unload" }); + }; + }, +}); diff --git a/scripts/v2-spike/run-live.mjs b/scripts/v2-spike/run-live.mjs new file mode 100644 index 0000000..0a24690 --- /dev/null +++ b/scripts/v2-spike/run-live.mjs @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { copyFile, mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const key = process.env.OPENROUTER_API_KEY; +if (!key) throw new Error("Set OPENROUTER_API_KEY before launching the test"); +const source = dirname(fileURLToPath(import.meta.url)); +const project = resolve(source, "../../.opencode/v2-test"); +const home = await mkdtemp(join(tmpdir(), "easy-vision-v2-live-home-")); +await mkdir(join(home, "config"), { recursive: true }); +await copyFile(join(source, "plugin.mjs"), join(project, "plugin/index.mjs")); +const image = process.argv[2] + ? resolve(process.argv[2]) + : join(project, "red.png"); +if (!process.argv[2]) { + await writeFile( + image, + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "base64", + ), + ); +} +const env = { + PATH: process.env.PATH, + HOME: home, + TMPDIR: home, + XDG_CONFIG_HOME: join(home, "config"), + XDG_DATA_HOME: join(home, "data"), + XDG_CACHE_HOME: join(home, "cache"), + XDG_STATE_HOME: join(home, "state"), + OPENCODE_CLI_CONFIG_CONTENT: "{}", + OPENROUTER_API_KEY: key, +}; +console.log( + "Running live V2 probe; sends the test image to OpenRouter and incurs API charges.", +); +console.log(`Isolated OpenCode state: ${home}`); +const child = spawn( + "opencode", + [ + "run", + "--standalone", + "--format", + "json", + "--file", + image, + "Use the vision tool to describe the attached image. What colors do you see?", + ], + { cwd: project, env, stdio: ["ignore", "pipe", "inherit"] }, +); +let output = ""; +child.stdout.on("data", (data) => { + output += data; + process.stdout.write(data); +}); +const timer = setTimeout(() => child.kill("SIGKILL"), 180_000); +try { + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + assert.equal(code, 0, "Live OpenCode invocation failed"); + const events = output + .split("\n") + .filter((line) => line.startsWith("{")) + .map(JSON.parse); + const calls = events.filter( + (event) => + event.type === "tool_use" && + event.part?.tool === "Vision_understand_image" && + event.part.state?.status === "completed", + ); + assert( + calls.length > 0, + "No completed vision MCP invocation; tool-call-shaped text is not success", + ); + assert( + calls.some((call) => + /[0-9a-f]{64}\.(png|jpg|webp)$/.test(call.part.state.input.path), + ), + "Vision MCP did not use a plugin-generated image path", + ); + assert( + !events.some((event) => event.type === "error"), + "Live request emitted an error", + ); + assert( + events.some((event) => event.type === "text" && event.part?.text), + "No model answer", + ); + console.log( + "PASS: text-only model -> saved PNG -> real vision MCP invocation -> answer", + ); +} finally { + clearTimeout(timer); +} diff --git a/scripts/v2-spike/run.mjs b/scripts/v2-spike/run.mjs new file mode 100644 index 0000000..5794444 --- /dev/null +++ b/scripts/v2-spike/run.mjs @@ -0,0 +1,363 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const baseline = "2.1.4"; +const productionEntry = process.argv[2] + ? pathToFileURL(process.argv[2]).href + : undefined; +const source = dirname(fileURLToPath(import.meta.url)); +const root = await mkdtemp(join(tmpdir(), "easy-vision-v2-spike-")); +console.log(`Probe artifacts: ${root}`); +const home = join(root, "home"); +await mkdir(home, { recursive: true }); +// Deliberate allowlist: do not inherit provider keys, config overrides, or service addresses. +const env = { + PATH: process.env.PATH, + HOME: home, + TMPDIR: root, + XDG_CONFIG_HOME: join(home, "config"), + XDG_DATA_HOME: join(home, "data"), + XDG_CACHE_HOME: join(home, "cache"), + XDG_STATE_HOME: join(home, "state"), + OPENCODE_CLI_CONFIG_CONTENT: "{}", +}; +async function command(binary, args, cwd, label, timeout = 120_000) { + const child = spawn(binary, args, { + cwd, + env, + stdio: ["ignore", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (data) => { + output += data; + }); + child.stderr.on("data", (data) => { + output += data; + }); + const timer = setTimeout(() => child.kill("SIGKILL"), timeout); + try { + const code = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", resolve); + }); + await writeFile(join(root, `${label}.log`), output); + assert.equal( + code, + 0, + `${label} failed; inspect ${join(root, `${label}.log`)}`, + ); + return output; + } finally { + clearTimeout(timer); + } +} +const version = await command("opencode", ["--version"], root, "version"); +assert.match(version, /\bv2\.1\.4\b/, `Probe requires OpenCode ${baseline}`); +const plugin = join(root, "plugin"); +await mkdir(plugin); +await copyFile(join(source, "plugin.mjs"), join(plugin, "index.mjs")); +if (productionEntry) { + const code = await readFile(join(plugin, "index.mjs"), "utf8"); + // Plugin.define is an identity helper. The observer fixture needs no SDK + // installation; the actual installed package must resolve its own dependency. + await writeFile( + join(plugin, "index.mjs"), + code.replace( + 'import { Plugin } from "@opencode/plugin";', + "const Plugin = { define: (definition) => definition };", + ), + ); +} +await writeFile( + join(plugin, "package.json"), + JSON.stringify({ + name: "easy-vision-v2-spike", + private: true, + type: "module", + main: "index.mjs", + dependencies: productionEntry ? {} : { "@opencode/plugin": baseline }, + }), +); +if (!productionEntry) + await command( + "npm", + ["install", "--ignore-scripts", "--no-audit", "--no-fund"], + plugin, + "install", + ); +const after = join(root, "after"); +if (productionEntry) { + await mkdir(after); + await copyFile(join(source, "after.mjs"), join(after, "index.mjs")); + await writeFile( + join(after, "package.json"), + JSON.stringify({ + name: "easy-vision-after", + type: "module", + main: "index.mjs", + }), + ); +} +await copyFile(join(source, "mcp.mjs"), join(root, "mcp.mjs")); +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "base64", +); +const image = join(root, "fixture.png"); +await writeFile(image, png); +const requests = []; +const errors = []; +// Route the model must see in the current mode: "direct" or "execute". +let expectedRoute; +const server = createServer(async (req, res) => { + try { + let raw = ""; + for await (const chunk of req) raw += chunk; + const body = JSON.parse(raw); + const primary = req.headers["x-spike-kind"] === "primary"; + if (primary) { + requests.push(body); + assert( + !JSON.stringify(body.messages).includes("image_url"), + "Raw image reached provider", + ); + } + const previous = body.messages.some((message) => message.role === "tool"); + const text = body.messages + .flatMap((message) => + typeof message.content === "string" + ? [message.content] + : (message.content ?? []) + .filter((part) => part.type === "text") + .map((part) => part.text), + ) + .join("\n"); + const path = text.match( + /(?:SPIKE_IMAGE_PATH=|Attached image: )([^\s]+)/, + )?.[1]; + let delta = { content: primary ? "SPIKE_DONE" : "Fixture title" }; + let finish = "stop"; + if (primary && !previous) { + assert(path, "Saved path missing from provider context"); + const names = (body.tools ?? []).map((tool) => tool.function?.name); + const direct = names.includes("MiniMax_understand_image"); + assert(direct || names.includes("execute"), "Vision route unavailable"); + assert.equal( + direct ? "direct" : "execute", + expectedRoute, + "Vision tool exposed through the wrong route", + ); + const name = direct ? "MiniMax_understand_image" : "execute"; + const args = direct + ? { path } + : { + code: `return await tools.MiniMax.understand_image(${JSON.stringify({ path })});`, + }; + delta = { + tool_calls: [ + { + index: 0, + id: "call_spike", + type: "function", + function: { name, arguments: JSON.stringify(args) }, + }, + ], + }; + finish = "tool_calls"; + } + if (body.stream) { + res.writeHead(200, { "content-type": "text/event-stream" }); + const chunk = (delta, finish_reason) => ({ + id: "fixture", + object: "chat.completion.chunk", + created: 0, + model: "text-only", + choices: [{ index: 0, delta, finish_reason }], + }); + res.write( + `data: ${JSON.stringify(chunk({ role: "assistant", ...delta }, null))}\n\n`, + ); + res.end(`data: ${JSON.stringify(chunk({}, finish))}\n\ndata: [DONE]\n\n`); + } else { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + id: "fixture", + object: "chat.completion", + created: 0, + model: "text-only", + choices: [ + { + index: 0, + message: { role: "assistant", ...delta }, + finish_reason: finish, + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + ); + } + } catch (error) { + errors.push(error); + res.writeHead(500); + res.end("Fixture assertion failed"); + } +}); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const jsonLines = async (path) => + (await readFile(path, "utf8")) + .trim() + .split("\n") + .filter(Boolean) + .map(JSON.parse); +try { + // direct: `codemode: false` set by the user. codemode: `codemode: true` set by + // the user, so the tool must stay behind execute. auto: unset, so the production + // plugin disables Code Mode and the probe plugin leaves it on. + for (const mode of ["direct", "codemode", "auto"]) { + expectedRoute = + mode === "direct" || (mode === "auto" && productionEntry) + ? "direct" + : "execute"; + const project = join(root, mode); + await mkdir(project); + const callsFile = join(project, "calls.jsonl"); + await writeFile( + join(project, "opencode.json"), + JSON.stringify({ + plugins: [ + { package: plugin, options: { productionEntry } }, + ...(productionEntry + ? [dirname(dirname(fileURLToPath(productionEntry))), after] + : []), + ], + model: "spike/text-only", + providers: { + spike: { + name: "Isolated fixture", + package: "@opencode/ai/providers/openai-compatible", + settings: { + baseURL: `http://127.0.0.1:${server.address().port}/v1`, + apiKey: "fixture-not-a-secret", + }, + models: { + "text-only": { + capabilities: { + tools: true, + input: ["text"], + output: ["text"], + }, + limit: { context: 32000, output: 1000 }, + }, + }, + }, + }, + permissions: [{ action: "*", resource: "*", effect: "allow" }], + mcp: { + servers: { + MiniMax: { + type: "local", + command: [process.execPath, join(root, "mcp.mjs")], + environment: { SPIKE_CALLS: callsFile }, + ...(mode === "auto" ? {} : { codemode: mode === "codemode" }), + }, + }, + }, + }), + ); + if (productionEntry) { + await mkdir(join(project, ".opencode")); + await writeFile( + join(project, ".opencode/opencode-easy-vision.json"), + JSON.stringify({ + models: ["spike/text-only"], + imageAnalysisTool: "MiniMax_understand_image", + tempDir: join(project, "images"), + }), + ); + } + const start = requests.length; + for (const [label, text] of [ + ["text", "SPIKE_ORIGINAL_REQUEST"], + ["image-only", ""], + ]) { + const args = [ + "run", + "--standalone", + "--format", + "json", + "--model", + "spike/text-only", + "--file", + image, + ]; + args.push(text || "SPIKE_IMAGE_ONLY_SENTINEL"); + const output = await command( + "opencode", + args, + project, + `${mode}-${label}`, + ); + assert.match(output, /SPIKE_DONE/); + } + assert.equal(errors.length, 0, errors[0]?.message); + const hooks = await jsonLines(join(project, "hooks.jsonl")); + const contexts = hooks.filter((hook) => hook.model); + assert( + contexts.length >= 4, + "Context hook did not run on tool continuations", + ); + assert( + contexts.every( + (hook) => + hook.model.providerID === "spike" && hook.model.id === "text-only", + ), + ); + assert( + contexts.every((hook) => hook.sources.length > 0), + "Persisted media not available on every pass", + ); + const paths = ( + productionEntry ? hooks.filter((hook) => hook.processed) : contexts + ).flatMap((hook) => hook.paths); + assert.equal(new Set(paths).size, 1, "Saved paths changed between passes"); + assert.deepEqual(await readFile(paths[0]), png); + const calls = await jsonLines(callsFile); + assert.equal( + calls.length, + 2, + "Expected one real fixture MCP call per task", + ); + assert(calls.every((call) => call.path === paths[0])); + assert(requests.length >= start + 4); + assert( + JSON.stringify(requests[start].messages).includes( + "SPIKE_ORIGINAL_REQUEST", + ), + ); + await writeFile( + join(project, "outbound.json"), + JSON.stringify(requests.slice(start), null, 2), + ); + console.log( + `PASS ${mode} (${expectedRoute}): media, original text, image-only, continuation, stable path, MCP read`, + ); + } + console.log( + `OpenCode ${baseline} synthetic runtime probe passed. Real vision and lifecycle gates remain.`, + ); +} finally { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); +} diff --git a/src/cleanup.ts b/src/cleanup.ts index 739474d..2a0e4ff 100644 --- a/src/cleanup.ts +++ b/src/cleanup.ts @@ -1,13 +1,12 @@ +import type { ConfigAccessors, Logger } from "./types.js"; import { join } from "node:path"; import { readdir, stat, unlink } from "node:fs/promises"; import { existsSync } from "node:fs"; -import { getTempDirsForCleanup, getCleanupAfterHours } from "./config.js"; -import type { Logger } from "./types.js"; +import { withFileLock } from "./file-lock.js"; -// Keep matching legacy UUID names while also accepting deterministic SHA-256 names. -// Both formats are plugin-owned, so unrelated files in custom directories are safe. -const PLUGIN_FILE_PATTERN = - /^(?:[0-9a-f]{64}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.(png|jpg|webp)$/; +// Only deterministic SHA-256 names are plugin-owned, so unrelated files in custom +// directories are never touched. +const PLUGIN_FILE_PATTERN = /^[0-9a-f]{64}\.(png|jpg|webp)$/; async function tryDeleteIfExpired( filepath: string, @@ -43,7 +42,11 @@ async function cleanupTempDir( .map((e) => join(dir, e)); const results = await Promise.all( - candidates.map((filepath) => tryDeleteIfExpired(filepath, now, cutoffMs)), + candidates.map((filepath) => + withFileLock(filepath, () => + tryDeleteIfExpired(filepath, now, cutoffMs), + ), + ), ); const deleted = results.filter(Boolean).length; if (deleted > 0) log(`Cleaned up ${deleted} old temp file(s) from ${dir}`); @@ -52,13 +55,16 @@ async function cleanupTempDir( } } -export async function cleanupOldTempFiles(log: Logger): Promise { - const cutoffMs = getCleanupAfterHours() * 60 * 60 * 1000; +export async function cleanupOldTempFiles( + config: ConfigAccessors, + log: Logger, +): Promise { + const cutoffMs = config.getCleanupAfterHours() * 60 * 60 * 1000; const now = Date.now(); await Promise.all( - getTempDirsForCleanup().map((dir) => - cleanupTempDir(dir, now, cutoffMs, log), - ), + config + .getTempDirsForCleanup() + .map((dir) => cleanupTempDir(dir, now, cutoffMs, log)), ); } diff --git a/src/codemode.ts b/src/codemode.ts new file mode 100644 index 0000000..6ad8128 --- /dev/null +++ b/src/codemode.ts @@ -0,0 +1,60 @@ +import type { MCPEditor } from "@opencode/plugin/promise/mcp"; +import type { Logger } from "./types.js"; + +// Mirrors OpenCode's MCP tool naming: `${namespace(server)}_${tool}`. +function namespace(server: string): string { + return server.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +/** + * Servers whose namespace could have produced `toolName`. More than one match is + * ambiguous: with servers "foo" and "foo_bar", "foo_bar_tool" may be either + * "foo" + "bar_tool" or "foo_bar" + "tool", and distinct names can share a namespace. + */ +export function findToolServers( + toolName: string, + servers: readonly string[], +): string[] { + return servers.filter((server) => + toolName.startsWith(`${namespace(server)}_`), + ); +} + +// Code Mode hides MCP tools behind `execute`, which contradicts a prompt that names +// the tool directly. Only an unambiguous vision server is changed, and an explicit +// user `codemode` setting always wins. +export function createCodeModeTransform( + getToolName: () => string, + isEnabled: () => boolean, + log: Logger, +) { + const logged = new Set(); + const logOnce = (message: string) => { + if (logged.has(message)) return; + logged.add(message); + log(message); + }; + return (editor: MCPEditor): void => { + if (!isEnabled()) return; + const toolName = getToolName(); + const servers = findToolServers( + toolName, + editor.list().map(([name]) => name), + ); + if (servers.length > 1) { + logOnce( + `Left Code Mode unchanged: ${toolName} could belong to MCP servers ${servers.map((s) => `"${s}"`).join(", ")}. Set "codemode": false on the right server.`, + ); + return; + } + const [server] = servers; + if (server === undefined) return; + editor.update(server, (config) => { + if (config.codemode !== undefined) return; + config.codemode = false; + logOnce( + `Disabled Code Mode for MCP server "${server}" so ${toolName} is directly callable`, + ); + }); + }; +} diff --git a/src/config.ts b/src/config.ts index 91d0304..83eb3b9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,3 +1,4 @@ +import type { ConfigAccessors, PluginConfig, Logger } from "./types.js"; import { homedir, tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { copyFile, mkdir, readFile } from "node:fs/promises"; @@ -7,24 +8,19 @@ import stripJsonComments from "strip-json-comments"; import { CONFIG_FILENAME, CONFIG_FILENAME_JSONC, - LEGACY_CONFIG_FILENAME, - LEGACY_CONFIG_FILENAME_JSONC, EXAMPLE_CONFIG_FILENAME, TEMP_DIR_NAME, - LEGACY_TEMP_DIR_NAME, MAX_TOOL_NAME_LENGTH, PROMPT_TEMPLATE_VARIABLES, DEFAULT_MODEL_PATTERNS, DEFAULT_IMAGE_ANALYSIS_TOOL, DEFAULT_CLEANUP_AFTER_HOURS, + DEFAULT_DISABLE_CODE_MODE, } from "./constants.js"; -import type { PluginConfig, Logger } from "./types.js"; - -let pluginConfig: PluginConfig = {}; // Path Resolution -function getUserConfigPath(filename: string): string { - return join(homedir(), ".config", "opencode", filename); +function getUserConfigPath(filename: string, directory: string): string { + return join(directory, filename); } function getProjectConfigPath(directory: string, filename: string): string { @@ -68,6 +64,10 @@ function parseCleanupAfterHours(value: unknown): number | undefined { return value; } +function parseDisableCodeMode(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + function parseConfigObject(raw: unknown): PluginConfig { if (!raw || typeof raw !== "object") return {}; @@ -78,6 +78,7 @@ function parseConfigObject(raw: unknown): PluginConfig { promptTemplate: parsePromptTemplate(obj.promptTemplate), tempDir: parseTempDir(obj.tempDir), cleanupAfterHours: parseCleanupAfterHours(obj.cleanupAfterHours), + disableCodeMode: parseDisableCodeMode(obj.disableCodeMode), }; } @@ -125,26 +126,13 @@ function configExists(paths: ConfigPaths): boolean { async function loadConfigAtLevel( level: "project" | "user", - currentPaths: ConfigPaths, - legacyPaths: ConfigPaths, + paths: ConfigPaths, onParseError: (path: string) => void, log: Logger, ): Promise { - const current = await readConfigPair(currentPaths, onParseError); - if (current !== null) { - if (configExists(legacyPaths)) { - log( - `Ignoring alternate ${level} config because ${current.path} is present`, - ); - } - return current; - } - - const legacy = await readConfigPair(legacyPaths, onParseError); - if (legacy !== null) { - log(`Using ${level} config at ${legacy.path}`); - } - return legacy; + const loaded = await readConfigPair(paths, onParseError); + if (loaded !== null) log(`Using ${level} config at ${loaded.path}`); + return loaded; } // Example Config Auto-Init @@ -194,10 +182,12 @@ function selectConfigValue( return { value: undefined, source: "default" }; } -export async function loadPluginConfig( +async function loadPluginConfig( directory: string, log: Logger, -): Promise { + userDirectory: string, + autoInit: boolean, +): Promise { const onParseError = (path: string) => log(`WARN: Config file has invalid JSON and will be ignored: ${path}`); @@ -217,34 +207,20 @@ export async function loadPluginConfig( } const userPaths = { - jsonc: getUserConfigPath(CONFIG_FILENAME_JSONC), - json: getUserConfigPath(CONFIG_FILENAME), - }; - const legacyUserPaths = { - jsonc: getUserConfigPath(LEGACY_CONFIG_FILENAME_JSONC), - json: getUserConfigPath(LEGACY_CONFIG_FILENAME), + jsonc: getUserConfigPath(CONFIG_FILENAME_JSONC, userDirectory), + json: getUserConfigPath(CONFIG_FILENAME, userDirectory), }; const projectPaths = { jsonc: getProjectConfigPath(directory, CONFIG_FILENAME_JSONC), json: getProjectConfigPath(directory, CONFIG_FILENAME), }; - const legacyProjectPaths = { - jsonc: getProjectConfigPath(directory, LEGACY_CONFIG_FILENAME_JSONC), - json: getProjectConfigPath(directory, LEGACY_CONFIG_FILENAME), - }; const [projectConfig, userConfig] = await Promise.all([ - loadConfigAtLevel( - "project", - projectPaths, - legacyProjectPaths, - onParseError, - log, - ), - loadConfigAtLevel("user", userPaths, legacyUserPaths, onParseError, log), + loadConfigAtLevel("project", projectPaths, onParseError, log), + loadConfigAtLevel("user", userPaths, onParseError, log), ]); - if (!configExists(userPaths) && !configExists(legacyUserPaths)) { + if (autoInit && !configExists(userPaths)) { await createExampleConfigIfMissing(userPaths.jsonc, log); } @@ -298,42 +274,54 @@ export async function loadPluginConfig( `Using default cleanupAfterHours: ${DEFAULT_CLEANUP_AFTER_HOURS}`, ); - pluginConfig = { + const disableCodeMode = selectConfigValue( + projectConfig?.config.disableCodeMode, + userConfig?.config.disableCodeMode, + ); + logSelected( + "disableCodeMode", + disableCodeMode, + String(disableCodeMode.value), + null, + ); + + return { models: models.value, imageAnalysisTool: imageAnalysisTool.value, promptTemplate: promptTemplate.value, tempDir: tempDir.value, cleanupAfterHours: cleanupAfterHours.value, + disableCodeMode: disableCodeMode.value, }; } -// Accessors -export function getConfiguredModels(): readonly string[] { - return pluginConfig.models ?? DEFAULT_MODEL_PATTERNS; -} - -export function getImageAnalysisTool(): string { - return pluginConfig.imageAnalysisTool ?? DEFAULT_IMAGE_ANALYSIS_TOOL; -} - -export function getPromptTemplate(): string | undefined { - return pluginConfig.promptTemplate; -} - -export function getTempDir(): string { - return pluginConfig.tempDir ?? join(tmpdir(), TEMP_DIR_NAME); -} - -export function getTempDirsForCleanup(): readonly string[] { - return [ - ...new Set([ - getTempDir(), - join(tmpdir(), TEMP_DIR_NAME), - join(tmpdir(), LEGACY_TEMP_DIR_NAME), - ]), - ]; -} - -export function getCleanupAfterHours(): number { - return pluginConfig.cleanupAfterHours ?? DEFAULT_CLEANUP_AFTER_HOURS; +export function createConfigStore( + initial: PluginConfig = {}, + options: { userDirectory?: string; autoInit?: boolean } = {}, +): ConfigAccessors & { load(directory: string, log: Logger): Promise } { + let config: PluginConfig = { ...initial, models: initial.models?.slice() }; + const getTempDir = () => config.tempDir ?? join(tmpdir(), TEMP_DIR_NAME); + return { + async load(directory, log) { + config = await loadPluginConfig( + directory, + log, + options.userDirectory ?? join(homedir(), ".config", "opencode"), + options.autoInit ?? true, + ); + }, + getConfiguredModels: () => + (config.models ?? DEFAULT_MODEL_PATTERNS).slice(), + getImageAnalysisTool: () => + config.imageAnalysisTool ?? DEFAULT_IMAGE_ANALYSIS_TOOL, + getPromptTemplate: () => config.promptTemplate, + getTempDir, + getTempDirsForCleanup: () => [ + ...new Set([getTempDir(), join(tmpdir(), TEMP_DIR_NAME)]), + ], + getCleanupAfterHours: () => + config.cleanupAfterHours ?? DEFAULT_CLEANUP_AFTER_HOURS, + getDisableCodeMode: () => + config.disableCodeMode ?? DEFAULT_DISABLE_CODE_MODE, + }; } diff --git a/src/constants.ts b/src/constants.ts index 7715f53..49338d7 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -4,14 +4,10 @@ export const PLUGIN_NAME = "easy-vision"; // Config filenames export const CONFIG_FILENAME = "opencode-easy-vision.json"; export const CONFIG_FILENAME_JSONC = "opencode-easy-vision.jsonc"; -export const LEGACY_CONFIG_FILENAME = "opencode-minimax-easy-vision.json"; -export const LEGACY_CONFIG_FILENAME_JSONC = - "opencode-minimax-easy-vision.jsonc"; export const EXAMPLE_CONFIG_FILENAME = "opencode-easy-vision.example.jsonc"; // Directories and limits export const TEMP_DIR_NAME = "opencode-easy-vision"; -export const LEGACY_TEMP_DIR_NAME = "opencode-minimax-vision"; // Treat tool names longer than this as a misconfiguration — MCP tool names have a defined upper bound. export const MAX_TOOL_NAME_LENGTH = 256; @@ -27,8 +23,9 @@ export const DEFAULT_MODEL_PATTERNS: readonly string[] = [ "minimax-token-plan/*", "minimax-cn-token-plan/*", ]; -export const DEFAULT_IMAGE_ANALYSIS_TOOL = "mcp_minimax_understand_image"; +export const DEFAULT_IMAGE_ANALYSIS_TOOL = "MiniMax_understand_image"; export const DEFAULT_CLEANUP_AFTER_HOURS = 24; +export const DEFAULT_DISABLE_CODE_MODE = true; // MIME types — OpenCode normalizes all JPEG variants to image/jpeg, // so image/jpg (non-standard, not in IANA registry) is not needed. diff --git a/src/file-lock.ts b/src/file-lock.ts new file mode 100644 index 0000000..c6858b6 --- /dev/null +++ b/src/file-lock.ts @@ -0,0 +1,21 @@ +// Shared only for filesystem coordination, never for project configuration. +const pending = new Map>(); + +export async function withFileLock( + path: string, + action: () => Promise, +): Promise { + const previous = pending.get(path) ?? Promise.resolve(); + let release: () => void = () => {}; + const current = new Promise((resolve) => { + release = resolve; + }); + pending.set(path, current); + await previous; + try { + return await action(); + } finally { + release(); + if (pending.get(path) === current) pending.delete(path); + } +} diff --git a/src/images.ts b/src/images.ts index ea08986..916e6b9 100644 --- a/src/images.ts +++ b/src/images.ts @@ -1,162 +1,156 @@ -import type { Part, FilePart } from "@opencode-ai/sdk"; +import type { + ConfigAccessors, + ImageAttachment, + ImageResult, + Logger, +} from "./types.js"; +import { createHash, randomUUID } from "node:crypto"; +import { + access, + lstat, + mkdir, + rename, + unlink, + utimes, + writeFile, +} from "node:fs/promises"; import { join } from "node:path"; -import { mkdir, writeFile } from "node:fs/promises"; -import { createHash } from "node:crypto"; import { fileURLToPath } from "node:url"; -import { SUPPORTED_MIME_TYPES, MIME_TO_EXTENSION } from "./constants.js"; -import { getTempDir } from "./config.js"; -import type { SavedImage, Logger, Notifier } from "./types.js"; +import { MIME_TO_EXTENSION, SUPPORTED_MIME_TYPES } from "./constants.js"; +import { withFileLock } from "./file-lock.js"; -// Type Guards -// -// Messages in OpenCode contain "parts" - an array of different content types: -// - TextPart: The user's typed text -// - FilePart: Attached files (images, PDFs, etc.) with mime type and URL - -export function isImageFilePart(part: Part): part is FilePart { - if (part.type !== "file") return false; - const mime = (part as FilePart).mime?.toLowerCase() ?? ""; - return SUPPORTED_MIME_TYPES.has(mime); -} - -// URL Handlers -// -// Images can arrive via different URL schemes: -// - file:// → Already on disk, just need the local path -// - data: → Base64-encoded, must decode and save to temp file -// - http(s): → Remote URL, pass through for MCP tool to fetch directly - -function handleFileUrl( - url: string, - filePart: FilePart, - log: Logger, -): SavedImage { - const localPath = fileURLToPath(url); - log(`Image already on disk: ${localPath}`); - return { path: localPath, mime: filePart.mime, partId: filePart.id }; +export function normalizeMime(mime: string): string { + return mime.split(";", 1)[0].trim().toLowerCase(); } -function parseBase64DataUrl( - dataUrl: string, -): { mime: string; data: Buffer } | null { - const match = dataUrl.match(/^data:([^;]+);base64,(.+)$/); - if (!match) return null; - - try { - return { mime: match[1], data: Buffer.from(match[2], "base64") }; - } catch { - return null; +export function decodeBase64(value: string): Buffer { + const text = value.replace(/\s/g, ""); + if ( + !text || + !/^[A-Za-z0-9+/]*={0,2}$/.test(text) || + text.length % 4 === 1 || + (text.includes("=") && text.length % 4 !== 0) + ) { + throw new Error("Malformed base64 image data"); } -} - -async function handleDataUrl( - url: string, - filePart: FilePart, - log: Logger, - notify: Notifier, -): Promise { - // Pasted clipboard images arrive as base64 data URLs. - // Decode and save to a temp file so the MCP tool can read it. - const parsed = parseBase64DataUrl(url); - if (!parsed) { - log(`Failed to parse data URL for part ${filePart.id}`); - return null; + const bytes = Buffer.from(text, "base64"); + if ( + !bytes.length || + bytes.toString("base64").replace(/=+$/, "") !== text.replace(/=+$/, "") + ) { + throw new Error("Malformed base64 image data"); } + return bytes; +} +// The name is a content hash, but the directory may be shared (e.g. /tmp on +// Linux): only trust a regular file we own with the expected size. Anything else, +// including a planted symlink, is atomically replaced by the rename below. +async function isReusable(path: string, size: number): Promise { try { - const savedPath = await saveImageToTemp( - parsed.data, - filePart.mime, - filePart.id, + const stats = await lstat(path); + return ( + stats.isFile() && + stats.size === size && + stats.uid === (process.getuid?.() ?? stats.uid) ); - log(`Saved image to: ${savedPath}`); - return { path: savedPath, mime: filePart.mime, partId: filePart.id }; - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - log(`Failed to save image: ${msg}`); - notify.error(`Could not save image to disk: ${msg}`, "Easy Vision"); - return null; + } catch { + return false; } } -function handleHttpUrl( - url: string, - filePart: FilePart, - log: Logger, -): SavedImage { - // Remote URLs are passed directly to the MCP tool, which can fetch them itself. - log(`Image is remote URL: ${url}`); - return { path: url, mime: filePart.mime, partId: filePart.id }; -} - -// File Operations - -function getExtensionForMime(mime: string): string { - // PNG fallback: unknown MIME types are saved as .png so the file is always - // openable, even if the extension doesn't perfectly match the content. - return MIME_TO_EXTENSION[mime.toLowerCase()] ?? "png"; -} - -async function ensureTempDir(): Promise { - const dir = getTempDir(); - await mkdir(dir, { recursive: true }); - return dir; -} - -async function saveImageToTemp( - data: Buffer, +async function persist( + bytes: Uint8Array, mime: string, - partId: string, + directory: string, ): Promise { - const tempDir = await ensureTempDir(); - // OpenCode reruns message transforms after tool calls. A stable name keeps every - // pass for the same attachment pointed at the path already shown to the model. - const fileId = createHash("sha256").update(partId).digest("hex"); - const filename = `${fileId}.${getExtensionForMime(mime)}`; - const filepath = join(tempDir, filename); - await writeFile(filepath, data); - return filepath; -} - -// Main Processor - -async function processImagePart( - filePart: FilePart, - log: Logger, - notify: Notifier, -): Promise { - const url = filePart.url; - - if (!url) { - log(`Skipping image part ${filePart.id}: no URL`); - return null; - } - - if (url.startsWith("file://")) { - return handleFileUrl(url, filePart, log); - } - - if (url.startsWith("data:")) { - return handleDataUrl(url, filePart, log, notify); - } - - if (url.startsWith("http://") || url.startsWith("https://")) { - return handleHttpUrl(url, filePart, log); - } - - log(`Unsupported URL scheme for part ${filePart.id}: ${url.slice(0, 50)}...`); - return null; + if (!bytes.length) throw new Error("Empty image data"); + const hash = createHash("sha256") + .update(mime) + .update("\0") + .update(bytes) + .digest("hex"); + const path = join(directory, `${hash}.${MIME_TO_EXTENSION[mime]}`); + await withFileLock(path, async () => { + // Every request replays the whole history, so most saves hit an existing file. + if (await isReusable(path, bytes.length)) { + // Refresh mtime so startup cleanup never deletes a file that is in use. + const now = new Date(); + await utimes(path, now, now); + return; + } + await mkdir(directory, { recursive: true }); + const staging = `${path}.writing-${randomUUID()}`; + try { + await writeFile(staging, bytes, { mode: 0o600 }); + await rename(staging, path); + } finally { + await unlink(staging).catch(() => {}); + } + }); + return path; } -export async function extractImagesFromParts( - parts: Part[], - log: Logger, - notify: Notifier, -): Promise { - const results = await Promise.all( - parts - .filter(isImageFilePart) - .map((part) => processImagePart(part, log, notify)), - ); - return results.filter((r): r is SavedImage => r !== null); +export function createImageProcessor(config: ConfigAccessors, log: Logger) { + return async (attachment: ImageAttachment): Promise => { + const mime = normalizeMime(attachment.mime); + try { + if (!SUPPORTED_MIME_TYPES.has(mime)) + throw new Error(`Unsupported image format (${mime})`); + const source = attachment.source; + let path: string; + switch (source.type) { + case "bytes": + path = await persist(source.data, mime, config.getTempDir()); + break; + case "base64": + path = await persist( + decodeBase64(source.data), + mime, + config.getTempDir(), + ); + break; + case "ref": + throw new Error( + "Provider image reference cannot be resolved; attach the original image instead", + ); + case "url": { + if (attachment.requiresHeaders) + throw new Error( + "Image URL requires private headers; attach the image bytes instead", + ); + if (source.expiresAt !== undefined && source.expiresAt <= Date.now()) + throw new Error("Image URL has expired; attach the image again"); + const url = new URL(source.url); + if (url.protocol === "file:") { + path = fileURLToPath(url); + await access(path); + } else if (url.protocol === "http:" || url.protocol === "https:") { + path = source.url; + } else if (url.protocol === "data:") { + const match = source.url.match(/^data:([^;,]+);base64,([\s\S]*)$/i); + if (!match || normalizeMime(match[1]) !== mime) + throw new Error("Malformed or mismatched image data URL"); + path = await persist( + decodeBase64(match[2]), + mime, + config.getTempDir(), + ); + } else { + throw new Error("Unsupported image URL scheme"); + } + break; + } + } + return { ok: true, image: { path, mime } }; + } catch (error) { + // Never log payloads, source URLs, private headers, or I/O error details. + const reason = + error instanceof Error && !("code" in error) + ? error.message + : "Could not save/read the image; check the server temp directory and permissions"; + log(`Image processing failed: ${reason}`); + return { ok: false, reason }; + } + }; } diff --git a/src/index.ts b/src/index.ts index e2a4ee7..b9106ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,126 +1,72 @@ -import type { Plugin } from "@opencode-ai/plugin"; +import type { Plugin } from "@opencode/plugin/effect"; +import type { Logger, Notifier } from "./types.js"; +import { Effect } from "effect"; import { PLUGIN_NAME } from "./constants.js"; -import { loadPluginConfig, getImageAnalysisTool } from "./config.js"; -import { modelMatchesAnyPattern } from "./patterns.js"; -import { isImageFilePart, extractImagesFromParts } from "./images.js"; -import { generateInjectionPrompt } from "./prompt.js"; -import { - isTextPart, - findLastUserMessage, - getModelFromMessage, - removeProcessedImageParts, - withUpdatedTextPart, -} from "./transform.js"; +import { createConfigStore } from "./config.js"; import { cleanupOldTempFiles } from "./cleanup.js"; -import type { Logger, Notifier } from "./types.js"; - -export const EasyVisionPlugin: Plugin = async (input) => { - const { client, directory } = input; - - const log: Logger = (msg) => { - client.app - .log({ body: { service: PLUGIN_NAME, level: "info", message: msg } }) - .catch(() => {}); - }; - - function makeNotifyMethod( - level: "warn" | "error", - variant: "warning" | "error", - ) { - return (message: string, title?: string) => { - client.app - .log({ body: { service: PLUGIN_NAME, level, message } }) - .catch(() => {}); - client.tui - .showToast({ body: { title, message, variant, duration: 5000 } }) - .catch(() => {}); - }; - } - - const notify: Notifier = { - warn: makeNotifyMethod("warn", "warning"), - error: makeNotifyMethod("error", "error"), - }; - - await loadPluginConfig(directory, log); - await cleanupOldTempFiles(log); - log("Plugin initialized"); - - return { - "experimental.chat.messages.transform": async (_input, output) => { - const { messages } = output; - - const result = findLastUserMessage(messages); - if (!result) return; - - const { message: lastUserMessage, index: lastUserIndex } = result; - - const model = getModelFromMessage(lastUserMessage); - if (!modelMatchesAnyPattern(model)) return; - - const hasImages = lastUserMessage.parts.some(isImageFilePart); - if (!hasImages) return; - - log("Found images in message, processing"); - - // Collect all image part IDs up front so failed saves are also removed from the - // message — otherwise raw data: blobs stay in parts and the model can't use them. - const allImagePartIds = new Set( - lastUserMessage.parts.filter(isImageFilePart).map((p) => p.id), +import { createCodeModeTransform } from "./codemode.js"; +import { createRequestRewriter } from "./request.js"; + +// The Effect API is used only for this shell: OpenCode drops plugin console +// output, but Effect logs inherit the host logger and reach OpenCode's log file. +// `effect` resolves to the host's instance at runtime. +export const EasyVisionPlugin: Plugin.Plugin = { + id: "opencode-easy-vision", + effect: Effect.fn(function* (ctx) { + const services = yield* Effect.context(); + const run = Effect.runPromiseWith(services); + const emit = (effect: Effect.Effect) => { + Effect.runForkWith(services)( + effect.pipe(Effect.annotateLogs("plugin", PLUGIN_NAME)), ); + }; + const log: Logger = (message) => emit(Effect.logInfo(message)); + const notify: Notifier = { + warn: (message) => emit(Effect.logWarning(message)), + error: (message) => emit(Effect.logError(message)), + }; - const savedImages = await extractImagesFromParts( - lastUserMessage.parts, - log, - notify, - ); - if (savedImages.length === 0) { - log("Failed to process any attached images"); - notify.error( - "Could not process attached images. Check logs for details.", - "Easy Vision", - ); - messages[lastUserIndex] = { - ...lastUserMessage, - parts: removeProcessedImageParts( - lastUserMessage.parts, - allImagePartIds, + const config = createConfigStore(); + yield* Effect.promise(() => config.load(ctx.location.directory, log)); + yield* Effect.promise(() => cleanupOldTempFiles(config, log)); + + const requests = createRequestRewriter( + config, + { + directory: ctx.location.directory, + getSessionDirectory: (sessionID) => + run(ctx.session.get({ sessionID })).then( + (session) => session.location.directory, ), - }; - return; - } - - if (savedImages.length < allImagePartIds.size) { - notify.warn( - `${savedImages.length} of ${allImagePartIds.size} image(s) were processed successfully. Some images could not be saved.`, - "Easy Vision", - ); - } - - log(`Saved ${savedImages.length} image(s), injecting tool instructions`); - - const existingTextPart = lastUserMessage.parts.find(isTextPart); - const userText = existingTextPart?.text ?? ""; - - const transformedText = generateInjectionPrompt( - savedImages, - userText, - getImageAnalysisTool(), - ); - - const trimmedParts = removeProcessedImageParts( - lastUserMessage.parts, - allImagePartIds, - ); - messages[lastUserIndex] = { - ...lastUserMessage, - parts: withUpdatedTextPart( - { ...lastUserMessage, parts: trimmedParts }, - transformedText, - ), - }; - }, - }; + listTools: () => run(ctx.tool.list()), + }, + log, + notify, + ); + + yield* ctx.mcp.transform( + createCodeModeTransform( + config.getImageAnalysisTool, + config.getDisableCodeMode, + log, + ), + ); + yield* ctx.session.hook("context", (event) => + Effect.promise(() => requests.rewrite(event, false, event.tools)), + ); + yield* ctx.session.hook("compaction", (event) => + Effect.promise(() => requests.rewrite(event, true)), + ); + yield* ctx.session.hook("title", (event) => + Effect.promise(() => requests.rewrite(event, true)), + ); + // Transient generation is deliberately neutral: it must not initiate image analysis. + yield* ctx.session.hook("generate", (event) => + Effect.promise(() => requests.rewrite(event, true)), + ); + yield* Effect.addFinalizer(() => Effect.sync(requests.dispose)); + log("Plugin initialized"); + }), }; export default EasyVisionPlugin; diff --git a/src/patterns.ts b/src/patterns.ts index 1fa10e0..46c6256 100644 --- a/src/patterns.ts +++ b/src/patterns.ts @@ -1,5 +1,4 @@ -import { getConfiguredModels } from "./config.js"; -import type { ModelInfo } from "./types.js"; +import type { ConfigAccessors, ModelInfo } from "./types.js"; function matchesWildcardPattern(pattern: string, value: string): boolean { const p = pattern.toLowerCase(); @@ -47,9 +46,11 @@ function matchesSinglePattern(pattern: string, model: ModelInfo): boolean { ); } -export function modelMatchesAnyPattern(model: ModelInfo | undefined): boolean { - if (!model) return false; - - const patterns = getConfiguredModels(); - return patterns.some((pattern) => matchesSinglePattern(pattern, model)); +export function createModelMatcher(config: ConfigAccessors) { + return (model: ModelInfo | undefined): boolean => { + if (!model) return false; + return config + .getConfiguredModels() + .some((pattern) => matchesSinglePattern(pattern, model)); + }; } diff --git a/src/prompt.ts b/src/prompt.ts index d28d293..6ed45a7 100644 --- a/src/prompt.ts +++ b/src/prompt.ts @@ -1,63 +1,41 @@ +import type { ConfigAccessors, SavedImage, ToolRoute } from "./types.js"; import { DEFAULT_PROMPT_TEMPLATE_MULTIPLE, DEFAULT_PROMPT_TEMPLATE_SINGLE, } from "./constants.js"; -import { getPromptTemplate } from "./config.js"; -import type { SavedImage } from "./types.js"; -// Since the target model doesn't natively understand image attachments, -// we replace them with text instructions that tell the model to use an -// MCP tool (e.g., understand_image) with the file path or URL. -// The user's original text is preserved as "User's request: ...". - -function applyPromptTemplate( - template: string, - vars: { - imageList: string; - imageCount: number; - toolName: string; - userText: string; - }, -): string { - return template - .replace(/\{imageList\}/g, vars.imageList) - .replace(/\{imageCount\}/g, String(vars.imageCount)) - .replace(/\{toolName\}/g, vars.toolName) - .replace(/\{userText\}/g, vars.userText); -} - -export function generateInjectionPrompt( - images: SavedImage[], - userText: string, - toolName: string, -): string { - if (images.length === 0) return userText; - - const imageList = images - .map((img, idx) => `- Image ${idx + 1}: ${img.path}`) - .join("\n"); - - const customTemplate = getPromptTemplate(); - if (customTemplate !== undefined) { - return applyPromptTemplate(customTemplate, { +export function createPromptGenerator(config: ConfigAccessors) { + return ( + images: readonly SavedImage[], + userText: string, + route: ToolRoute, + ): string => { + const toolName = config.getImageAnalysisTool(); + const imageList = images + .map((image, index) => `- Image ${index + 1}: ${image.path}`) + .join("\n"); + if (route === "unavailable") { + return `Saved image context:\n${imageList}\nEasy Vision: ${toolName} is not available for this request. Connect the vision MCP and allow its tool (or execute for Code Mode). Do not claim to have analyzed the images.`; + } + const template = + config.getPromptTemplate() ?? + (images.length === 1 + ? DEFAULT_PROMPT_TEMPLATE_SINGLE + : DEFAULT_PROMPT_TEMPLATE_MULTIPLE); + const variables: Record = { imageList, - imageCount: images.length, + imageCount: String(images.length), toolName, - userText, - }); - } - - const isSingle = images.length === 1; - const template = isSingle - ? DEFAULT_PROMPT_TEMPLATE_SINGLE - : DEFAULT_PROMPT_TEMPLATE_MULTIPLE; - - return applyPromptTemplate(template, { - imageList, - imageCount: images.length, - toolName, - // When the user pastes an image with no accompanying text, {userText} would be - // blank, leaving the model without any instruction. Fall back to a sensible default. - userText: userText || "(analyze the image)", - }); + userText: userText || "(analyze the image)", + }; + const prompt = template.replace( + /\{(imageList|imageCount|toolName|userText)\}/g, + (_, name: string) => variables[name], + ); + const routing = + route === "codemode" + ? `\nThis MCP tool is exposed through Code Mode, not as a direct function. Use execute and its search/catalog facilities to discover the exact ${toolName} tool and signature, then invoke it through the returned tools catalog. Do not guess the namespace or bypass permissions.` + : ""; + return `${prompt}${routing}\nReuse any image analysis already present in this conversation; do not repeat completed analysis.`; + }; } diff --git a/src/request.ts b/src/request.ts new file mode 100644 index 0000000..4056412 --- /dev/null +++ b/src/request.ts @@ -0,0 +1,92 @@ +import type { SessionRequest } from "@opencode/plugin/effect/session"; +import type { ConfigAccessors, Logger, Notifier } from "./types.js"; +import { resolve } from "node:path"; +import { createModelMatcher } from "./patterns.js"; +import { createImageProcessor } from "./images.js"; +import { createPromptGenerator } from "./prompt.js"; +import { + isImagePart, + stripImagesAfterFailure, + transformMessages, +} from "./transform.js"; +import { resolveToolRoute } from "./v2.js"; + +// Host lookups as Promises so request rewriting stays plain async code. +export interface RequestHost { + readonly directory: string; + getSessionDirectory(sessionID: SessionRequest["sessionID"]): Promise; + listTools(): Promise< + readonly { id: string; options?: { codemode?: boolean } }[] + >; +} + +export function createRequestRewriter( + config: ConfigAccessors, + host: RequestHost, + log: Logger, + notify: Notifier, +) { + const matches = createModelMatcher(config); + const process = createImageProcessor(config, log); + const prompt = createPromptGenerator(config); + const warnings = new Set(); + const warnOnce = (key: string, message: string) => { + if (warnings.has(key)) return; + if (warnings.size >= 256) { + const oldest = warnings.values().next().value; + if (oldest !== undefined) warnings.delete(oldest); + } + warnings.add(key); + notify.warn(message); + }; + + const rewrite = async ( + event: SessionRequest, + neutral: boolean, + tools: Record = {}, + ): Promise => { + if ( + !matches({ providerID: event.model.providerID, modelID: event.model.id }) + ) + return; + if ( + !event.messages.some( + (message) => + message.role === "user" && message.content.some(isImagePart), + ) + ) + return; + try { + const sessionDirectory = await host.getSessionDirectory(event.sessionID); + if (resolve(sessionDirectory) !== resolve(host.directory)) return; + const registered = neutral ? [] : await host.listTools(); + const route = resolveToolRoute( + config.getImageAnalysisTool(), + tools, + registered, + ); + if (!neutral && route === "unavailable") + warnOnce( + `${event.sessionID}:tool`, + `Vision tool ${config.getImageAnalysisTool()} is unavailable in this request. Check MCP connection, tool name, exposure, and permissions.`, + ); + event.messages = await transformMessages(event.messages, { + process, + prompt, + route, + neutral, + onFailure: (reason, index) => + warnOnce(`${event.sessionID}:${index}:${reason}`, reason), + }); + } catch { + // Never restore unsupported raw media after an adapter-level failure. + event.messages = stripImagesAfterFailure(event.messages); + warnOnce( + `${event.sessionID}:unexpected`, + "Image processing failed; see the attachment failure note in model context.", + ); + } + }; + + return { rewrite, dispose: () => warnings.clear() }; +} diff --git a/src/transform.ts b/src/transform.ts index a3d931c..4b66e72 100644 --- a/src/transform.ts +++ b/src/transform.ts @@ -1,65 +1,149 @@ -import type { Message, Part, TextPart } from "@opencode-ai/sdk"; -import { randomUUID } from "node:crypto"; -import type { ModelInfo } from "./types.js"; +import type { ContentPart, Message } from "@opencode/ai"; +import type { + ImageAttachment, + ImageResult, + SavedImage, + ToolRoute, +} from "./types.js"; +import { normalizeMime } from "./images.js"; -export function isTextPart(part: Part): part is TextPart { - return part.type === "text"; +export function isImagePart(part: ContentPart): boolean { + return ( + part.type === "media" && + (part.media.kind === "image" || + normalizeMime(part.media.mediaType).startsWith("image/")) + ); } -// The transformation flow: -// 1. Find the last user message (most recent request) -// 2. Extract and save any images from its parts -// 3. Remove the image parts (they can't be sent to the model) -// 4. Replace/update the text part with injection instructions - -export function findLastUserMessage( - messages: Array<{ info: Message; parts: Part[] }>, -): { message: { info: Message; parts: Part[] }; index: number } | null { - for (let i = messages.length - 1; i >= 0; i--) { - if (messages[i].info.role === "user") { - return { message: messages[i], index: i }; - } - } - return null; +export function normalizeAttachment( + part: ContentPart, +): ImageAttachment | undefined { + if (part.type !== "media" || !isImagePart(part)) return undefined; + return { + mime: normalizeMime(part.media.mediaType), + source: part.media.source, + requiresHeaders: Object.keys(part.media.headers ?? {}).length > 0, + }; } -export function getModelFromMessage(message: { - info: Message; -}): ModelInfo | undefined { - const info = message.info as { model?: ModelInfo }; - return info.model; +const marker = { easyVision: true }; + +// OpenCode flattens skills and attachments into plain text parts alongside the +// user's own text (core session/runner/to-llm-message.ts). Only the typed text +// belongs in {userText}; the rest stays in place but must not be duplicated. +const CORE_TEXT_PREFIXES = [" part.text.startsWith(prefix)); } -export function removeProcessedImageParts( - parts: Part[], - processedIds: Set, -): Part[] { - // Remove image parts that were successfully processed; they've been converted - // to file paths in the injection prompt and the model can't interpret raw images. - return parts.filter( - (part) => !(part.type === "file" && processedIds.has(part.id)), +export async function transformMessages( + messages: readonly Message[], + options: { + process: (attachment: ImageAttachment) => Promise; + prompt: ( + images: readonly SavedImage[], + text: string, + route: ToolRoute, + ) => string; + route: ToolRoute; + neutral?: boolean; + onFailure?: (reason: string, messageIndex: number) => void; + }, +): Promise { + let lastUser = -1; + messages.forEach((message, index) => { + if (message.role === "user") lastUser = index; + }); + return Promise.all( + messages.map(async (message, messageIndex) => { + if (message.role !== "user" || !message.content.some(isImagePart)) + return message; + const images: SavedImage[] = []; + const failures: string[] = []; + const content: ContentPart[] = []; + for (const part of message.content) { + const attachment = normalizeAttachment(part); + if (!attachment) { + // Discard only our previous generated guidance when a later plugin + // reintroduces media into an already transformed context. + if (part.type !== "text" || part.metadata?.easyVision !== true) + content.push(part); + continue; + } + let result: ImageResult; + try { + result = await options.process(attachment); + } catch { + result = { + ok: false, + reason: + "Unexpected image-processing failure; attach the image again or check server logs", + }; + } + if (result.ok) { + images.push(result.image); + content.push({ + type: "text", + text: `\nAttached image: ${result.image.path}\n`, + metadata: { + ...(part.type === "media" ? part.metadata : {}), + ...marker, + }, + }); + } else { + failures.push(result.reason); + options.onFailure?.(result.reason, messageIndex); + content.push({ + type: "text", + text: `\nEasy Vision attachment failure: ${result.reason}\n`, + metadata: { + ...(part.type === "media" ? part.metadata : {}), + ...marker, + }, + }); + } + } + if (images.length && messageIndex === lastUser && !options.neutral) { + const originalText = message.content + .filter(isUserTypedText) + .map((part) => (part.type === "text" ? part.text : "")) + .join("\n"); + content.push({ + type: "text", + text: options.prompt(images, originalText, options.route), + metadata: marker, + }); + } + if (failures.length) + content.push({ + type: "text", + metadata: marker, + text: `Easy Vision: ${failures.length} attachment(s) could not be processed${images.length ? `; ${images.length} image(s) remain available` : ""}. Do not infer the missing image contents.`, + }); + return { ...message, content }; + }), ); } -export function withUpdatedTextPart( - message: { info: Message; parts: Part[] }, - newText: string, -): Part[] { - const textPartIndex = message.parts.findIndex(isTextPart); - - if (textPartIndex !== -1) { - return message.parts.map((part, i) => - i === textPartIndex ? ({ ...part, text: newText } as TextPart) : part, - ); - } - - const newTextPart: TextPart = { - id: `transformed-${randomUUID()}`, - sessionID: message.info.sessionID, - messageID: message.info.id, - type: "text", - text: newText, - synthetic: true, // Tells OpenCode this part was generated by a transform, not typed by the user - }; - return [newTextPart, ...message.parts]; +export function stripImagesAfterFailure( + messages: readonly Message[], +): Message[] { + return messages.map((message) => + message.role !== "user" || !message.content.some(isImagePart) + ? message + : { + ...message, + content: message.content.map((part) => + isImagePart(part) + ? { + type: "text" as const, + text: "Easy Vision: attachment processing failed. Reattach the image or check server logs.", + metadata: marker, + } + : part, + ), + }, + ); } diff --git a/src/types.ts b/src/types.ts index a6ab1ed..f984353 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,21 +4,48 @@ export interface PluginConfig { promptTemplate?: string; tempDir?: string; cleanupAfterHours?: number; + disableCodeMode?: boolean; } export interface SavedImage { path: string; mime: string; - partId: string; // ID of the original FilePart, used to remove it from the message after injection } +export type ImageSource = + | { type: "bytes"; data: Uint8Array; mediaType: string } + | { type: "base64"; data: string; mediaType: string } + | { type: "url"; url: string; mediaType?: string; expiresAt?: number } + | { type: "ref"; provider: string; id: string; mediaType?: string }; + +export interface ImageAttachment { + mime: string; + source: ImageSource; + requiresHeaders?: boolean; +} + +export type ImageResult = + | { ok: true; image: SavedImage } + | { ok: false; reason: string }; + +export interface ConfigAccessors { + getConfiguredModels(): readonly string[]; + getImageAnalysisTool(): string; + getPromptTemplate(): string | undefined; + getTempDir(): string; + getTempDirsForCleanup(): readonly string[]; + getCleanupAfterHours(): number; + getDisableCodeMode(): boolean; +} + +export type ToolRoute = "direct" | "codemode" | "unavailable"; + export interface ModelInfo { providerID: string; modelID: string; } -// Logger writes to the app's structured log (silent to the user). -// Notifier surfaces visible toast messages for errors the user should act on. +// Server logging is UI-independent; request text also carries actionable failures. export type Logger = (msg: string) => void; export type Notifier = { diff --git a/src/v2.ts b/src/v2.ts new file mode 100644 index 0000000..5e6b58f --- /dev/null +++ b/src/v2.ts @@ -0,0 +1,17 @@ +import type { ToolRoute } from "./types.js"; + +export function resolveToolRoute( + toolName: string, + tools: Record, + registered: readonly { id: string; options?: { codemode?: boolean } }[], +): ToolRoute { + if (Object.hasOwn(tools, toolName)) return "direct"; + const tool = registered.find((entry) => entry.id === toolName); + if ( + tool && + tool.options?.codemode !== false && + Object.hasOwn(tools, "execute") + ) + return "codemode"; + return "unavailable"; +} diff --git a/test/cleanup.test.ts b/test/cleanup.test.ts new file mode 100644 index 0000000..9973beb --- /dev/null +++ b/test/cleanup.test.ts @@ -0,0 +1,111 @@ +import { readdir, readFile, utimes, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { cleanupOldTempFiles } from "../src/cleanup.js"; +import { createConfigStore } from "../src/config.js"; +import { PNG_BYTES, fixture, recorder, silent, tempDir } from "./helpers.js"; + +const HASH = "a".repeat(64); +const HOUR = 60 * 60 * 1000; + +async function writeAged(path: string, ageMs: number) { + await writeFile(path, PNG_BYTES); + const when = new Date(Date.now() - ageMs); + await utimes(path, when, when); +} + +function storeFor(dir: string, cleanupAfterHours = 24) { + return { + ...createConfigStore({ tempDir: dir, cleanupAfterHours }), + getTempDirsForCleanup: () => [dir], + }; +} + +describe("cleanupOldTempFiles", () => { + it("deletes only expired plugin-owned files", async () => { + const dir = await tempDir(); + await writeAged(join(dir, `${HASH}.png`), 25 * HOUR); + await writeAged(join(dir, `${"b".repeat(64)}.jpg`), 25 * HOUR); + await writeAged(join(dir, `${"c".repeat(64)}.webp`), 1 * HOUR); + await writeAged(join(dir, "user-owned.png"), 25 * HOUR); + await writeAged(join(dir, `${HASH}.gif`), 25 * HOUR); + await writeAged( + join(dir, "01234567-89ab-cdef-0123-456789abcdef.png"), + 25 * HOUR, + ); + const { log, messages } = recorder(); + await cleanupOldTempFiles(storeFor(dir), log); + expect((await readdir(dir)).sort()).toEqual( + [ + `${"c".repeat(64)}.webp`, + "user-owned.png", + `${HASH}.gif`, + "01234567-89ab-cdef-0123-456789abcdef.png", + ].sort(), + ); + expect(messages.info).toEqual([ + `Cleaned up 2 old temp file(s) from ${dir}`, + ]); + }); + + it("honors cleanupAfterHours", async () => { + const dir = await tempDir(); + await writeAged(join(dir, `${HASH}.png`), 3 * HOUR); + await cleanupOldTempFiles(storeFor(dir, 4), silent); + expect(await readdir(dir)).toHaveLength(1); + await cleanupOldTempFiles(storeFor(dir, 2), silent); + expect(await readdir(dir)).toHaveLength(0); + }); + + it("is silent when nothing was deleted or the directory is missing", async () => { + const dir = await tempDir(); + const { log, messages } = recorder(); + await cleanupOldTempFiles(storeFor(dir), log); + await cleanupOldTempFiles(storeFor(join(dir, "missing")), log); + expect(messages.info).toEqual([]); + }); + + it("keeps a file that a request reused after it expired", async () => { + const f = await fixture(); + const attachment = { + mime: "image/png", + source: { + type: "bytes" as const, + data: PNG_BYTES, + mediaType: "image/png", + }, + }; + const saved = await f.process(attachment); + if (!saved.ok) throw new Error(saved.reason); + const old = new Date(Date.now() - 25 * HOUR); + await utimes(saved.image.path, old, old); + await f.process(attachment); + await cleanupOldTempFiles(storeFor(f.tempDir), silent); + expect(await readdir(f.tempDir)).toEqual([ + saved.image.path.split("/").pop(), + ]); + }); + + it("does not delete a file that is being written concurrently", async () => { + const f = await fixture(); + const attachment = { + mime: "image/png", + source: { + type: "bytes" as const, + data: PNG_BYTES, + mediaType: "image/png", + }, + }; + const first = await f.process(attachment); + if (!first.ok) throw new Error(first.reason); + const old = new Date(Date.now() - 25 * HOUR); + await utimes(first.image.path, old, old); + // Whichever runs first under the per-path lock, the file must survive: + // cleanup-then-save recreates it, save-then-cleanup refreshes its mtime. + await Promise.all([ + cleanupOldTempFiles(storeFor(f.tempDir), silent), + f.process(attachment), + ]); + expect(await readFile(first.image.path)).toEqual(PNG_BYTES); + }); +}); diff --git a/test/codemode.test.ts b/test/codemode.test.ts new file mode 100644 index 0000000..2b72ec5 --- /dev/null +++ b/test/codemode.test.ts @@ -0,0 +1,119 @@ +import type { MCPEditor } from "@opencode/plugin/promise/mcp"; +import { describe, expect, it } from "vitest"; +import { createCodeModeTransform, findToolServers } from "../src/codemode.js"; +import { mcpEditor as editor, recorder } from "./helpers.js"; + +const codemodeOf = (e: MCPEditor, name: string) => e.get(name)?.codemode; + +describe("findToolServers", () => { + it.each([ + ["MiniMax_understand_image", ["Other", "MiniMax"], ["MiniMax"]], + ["my_server_tool", ["my.server"], ["my.server"]], + ["foo_bar_tool", ["foo", "foo_bar"], ["foo", "foo_bar"]], + ["a_b_tool", ["a.b", "a_b"], ["a.b", "a_b"]], + ["Missing_tool", ["MiniMax"], []], + ["minimax_understand_image", ["MiniMax"], []], + ["MiniMax", ["MiniMax"], []], + ])("%s in %j -> %j", (tool, servers, expected) => { + expect(findToolServers(tool, servers)).toEqual(expected); + }); +}); + +describe("createCodeModeTransform", () => { + it("disables Code Mode only on the server providing the tool", () => { + const e = editor({ MiniMax: {}, Other: {} }); + createCodeModeTransform( + () => "MiniMax_understand_image", + () => true, + () => {}, + )(e); + expect(codemodeOf(e, "MiniMax")).toBe(false); + expect(codemodeOf(e, "Other")).toBeUndefined(); + }); + + it.each([true, false])( + "respects an explicit codemode: %s on the server", + (codemode) => { + const e = editor({ MiniMax: { codemode } }); + createCodeModeTransform( + () => "MiniMax_understand_image", + () => true, + () => {}, + )(e); + expect(codemodeOf(e, "MiniMax")).toBe(codemode); + }, + ); + + it("does nothing when disabled in plugin config", () => { + const e = editor({ MiniMax: {} }); + createCodeModeTransform( + () => "MiniMax_understand_image", + () => false, + () => {}, + )(e); + expect(codemodeOf(e, "MiniMax")).toBeUndefined(); + }); + + it("does nothing when no server provides the tool", () => { + const e = editor({ Other: {} }); + createCodeModeTransform( + () => "MiniMax_understand_image", + () => true, + () => {}, + )(e); + expect(codemodeOf(e, "Other")).toBeUndefined(); + }); + + it("reads config on every run, so reloads take effect", () => { + let tool = "A_tool"; + const transform = createCodeModeTransform( + () => tool, + () => true, + () => {}, + ); + const first = editor({ A: {}, B: {} }); + transform(first); + tool = "B_tool"; + const second = editor({ A: {}, B: {} }); + transform(second); + expect(codemodeOf(first, "A")).toBe(false); + expect(codemodeOf(second, "A")).toBeUndefined(); + expect(codemodeOf(second, "B")).toBe(false); + }); + + it.each([ + ["a prefix of another server", { foo: {}, foo_bar: {} }, "foo_bar_tool"], + ["normalized to the same namespace", { "a.b": {}, a_b: {} }, "a_b_tool"], + ])( + "leaves Code Mode alone and explains why when servers are ambiguous (%s)", + (_name, servers, tool) => { + const { log, messages } = recorder(); + const e = editor(servers); + const transform = createCodeModeTransform( + () => tool, + () => true, + log, + ); + transform(e); + transform(e); + for (const name of Object.keys(servers)) + expect(codemodeOf(e, name)).toBeUndefined(); + expect(messages.info).toHaveLength(1); + expect(messages.info[0]).toContain("Left Code Mode unchanged"); + }, + ); + + it("logs once per server even though OpenCode reruns transforms", () => { + const { log, messages } = recorder(); + const transform = createCodeModeTransform( + () => "MiniMax_understand_image", + () => true, + log, + ); + transform(editor({ MiniMax: {} })); + transform(editor({ MiniMax: {} })); + expect(messages.info).toEqual([ + 'Disabled Code Mode for MCP server "MiniMax" so MiniMax_understand_image is directly callable', + ]); + }); +}); diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..aebf021 --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,245 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createConfigStore } from "../src/config.js"; +import { + DEFAULT_CLEANUP_AFTER_HOURS, + DEFAULT_IMAGE_ANALYSIS_TOOL, + DEFAULT_MODEL_PATTERNS, + TEMP_DIR_NAME, +} from "../src/constants.js"; +import { recorder, silent, tempDir } from "./helpers.js"; + +async function layout() { + const root = await tempDir("easy-vision-config-"); + const home = join(root, "home"); + const project = join(root, "project"); + await mkdir(home, { recursive: true }); + await mkdir(join(project, ".opencode"), { recursive: true }); + const writeUser = (name: string, body: unknown) => + writeFile( + join(home, name), + typeof body === "string" ? body : JSON.stringify(body), + ); + const writeProject = (name: string, body: unknown) => + writeFile( + join(project, ".opencode", name), + typeof body === "string" ? body : JSON.stringify(body), + ); + const load = async (log = silent, autoInit = false) => { + const store = createConfigStore({}, { userDirectory: home, autoInit }); + await store.load(project, log); + return store; + }; + return { home, project, writeUser, writeProject, load }; +} + +describe("defaults", () => { + it("applies when no config files exist", async () => { + const { load } = await layout(); + const store = await load(); + expect(store.getConfiguredModels()).toEqual(DEFAULT_MODEL_PATTERNS); + expect(store.getImageAnalysisTool()).toBe(DEFAULT_IMAGE_ANALYSIS_TOOL); + expect(store.getPromptTemplate()).toBeUndefined(); + expect(store.getTempDir()).toBe(join(tmpdir(), TEMP_DIR_NAME)); + expect(store.getCleanupAfterHours()).toBe(DEFAULT_CLEANUP_AFTER_HOURS); + expect(store.getDisableCodeMode()).toBe(true); + }); + + it("hands out copies so callers cannot mutate the stored models", async () => { + const store = createConfigStore({ models: ["a/*"] }); + const models = store.getConfiguredModels() as string[]; + models.push("b/*"); + expect(store.getConfiguredModels()).toEqual(["a/*"]); + }); +}); + +describe("precedence", () => { + it("prefers project over user, per field", async () => { + const { writeUser, writeProject, load } = await layout(); + await writeUser("opencode-easy-vision.json", { + models: ["user/*"], + imageAnalysisTool: "User_tool", + cleanupAfterHours: 48, + }); + await writeProject("opencode-easy-vision.json", { + models: ["project/*"], + disableCodeMode: false, + }); + const store = await load(); + expect(store.getConfiguredModels()).toEqual(["project/*"]); + expect(store.getImageAnalysisTool()).toBe("User_tool"); + expect(store.getCleanupAfterHours()).toBe(48); + expect(store.getDisableCodeMode()).toBe(false); + }); + + it("prefers .jsonc over .json at the same level", async () => { + const { writeProject, load } = await layout(); + await writeProject("opencode-easy-vision.json", { models: ["json/*"] }); + await writeProject( + "opencode-easy-vision.jsonc", + '{\n // comment\n "models": ["jsonc/*"],\n}', + ); + expect((await load()).getConfiguredModels()).toEqual(["jsonc/*"]); + }); + + it("falls back to .json and warns when .jsonc is invalid", async () => { + const { writeProject, load } = await layout(); + await writeProject("opencode-easy-vision.jsonc", "{ not json"); + await writeProject("opencode-easy-vision.json", { models: ["json/*"] }); + const { log, messages } = recorder(); + expect((await load(log)).getConfiguredModels()).toEqual(["json/*"]); + expect(messages.info.join("\n")).toMatch(/invalid JSON.*\.jsonc/); + }); + + it("ignores the removed pre-2.0 config filenames", async () => { + const { writeUser, writeProject, load } = await layout(); + await writeUser("opencode-minimax-easy-vision.json", { + models: ["legacy/*"], + }); + await writeProject("opencode-minimax-easy-vision.jsonc", { + imageAnalysisTool: "Legacy_tool", + }); + const store = await load(); + expect(store.getConfiguredModels()).toEqual(DEFAULT_MODEL_PATTERNS); + expect(store.getImageAnalysisTool()).toBe(DEFAULT_IMAGE_ANALYSIS_TOOL); + }); + + it("replaces the previous config on reload", async () => { + const { writeProject, project, home } = await layout(); + const store = createConfigStore( + {}, + { userDirectory: home, autoInit: false }, + ); + await writeProject("opencode-easy-vision.json", { models: ["first/*"] }); + await store.load(project, silent); + await writeProject("opencode-easy-vision.json", {}); + await store.load(project, silent); + expect(store.getConfiguredModels()).toEqual(DEFAULT_MODEL_PATTERNS); + }); +}); + +describe("validation", () => { + it.each([ + ["models", { models: "minimax/*" }], + ["models", { models: [] }], + ["models", { models: [1, 2] }], + ["imageAnalysisTool", { imageAnalysisTool: " " }], + ["imageAnalysisTool", { imageAnalysisTool: "x".repeat(257) }], + ["imageAnalysisTool", { imageAnalysisTool: 42 }], + ["promptTemplate", { promptTemplate: "no variables here" }], + ["promptTemplate", { promptTemplate: " " }], + ["tempDir", { tempDir: " " }], + ["cleanupAfterHours", { cleanupAfterHours: 0 }], + ["cleanupAfterHours", { cleanupAfterHours: -1 }], + ["cleanupAfterHours", { cleanupAfterHours: "24" }], + ["disableCodeMode", { disableCodeMode: "false" }], + ])("drops an invalid %s value and falls back", async (_field, body) => { + const { writeUser, writeProject, load } = await layout(); + const valid = { + models: ["user/*"], + imageAnalysisTool: "User_tool", + promptTemplate: "{imageList}", + tempDir: "/user-temp", + cleanupAfterHours: 12, + disableCodeMode: false, + }; + await writeUser("opencode-easy-vision.json", valid); + await writeProject("opencode-easy-vision.json", body); + const store = await load(); + expect(store.getConfiguredModels()).toEqual(valid.models); + expect(store.getImageAnalysisTool()).toBe(valid.imageAnalysisTool); + expect(store.getPromptTemplate()).toBe(valid.promptTemplate); + expect(store.getTempDir()).toBe(valid.tempDir); + expect(store.getCleanupAfterHours()).toBe(valid.cleanupAfterHours); + expect(store.getDisableCodeMode()).toBe(valid.disableCodeMode); + }); + + it("keeps only string entries in models", async () => { + const { writeProject, load } = await layout(); + await writeProject("opencode-easy-vision.json", { + models: ["a/*", 3, null, "b/*"], + }); + expect((await load()).getConfiguredModels()).toEqual(["a/*", "b/*"]); + }); + + it("trims promptTemplate and tempDir", async () => { + const { writeProject, load } = await layout(); + await writeProject("opencode-easy-vision.json", { + promptTemplate: " {imageList} ", + tempDir: " /trimmed ", + }); + const store = await load(); + expect(store.getPromptTemplate()).toBe("{imageList}"); + expect(store.getTempDir()).toBe("/trimmed"); + }); + + it("ignores a config file that is not an object", async () => { + const { writeProject, load } = await layout(); + await writeProject("opencode-easy-vision.json", "[1, 2]"); + expect((await load()).getConfiguredModels()).toEqual( + DEFAULT_MODEL_PATTERNS, + ); + }); +}); + +describe("isolation", () => { + it("keeps concurrently loaded stores independent", async () => { + const a = await layout(); + const b = await layout(); + await a.writeProject("opencode-easy-vision.json", { models: ["a/*"] }); + await b.writeProject("opencode-easy-vision.json", { models: ["b/*"] }); + const [storeA, storeB] = await Promise.all([a.load(), b.load()]); + expect(storeA.getConfiguredModels()).toEqual(["a/*"]); + expect(storeB.getConfiguredModels()).toEqual(["b/*"]); + }); +}); + +describe("example config auto-init", () => { + it("creates the user example once and never overwrites it", async () => { + const { home, load } = await layout(); + const target = join(home, "opencode-easy-vision.jsonc"); + await load(silent, true); + const example = await readFile( + new URL("../opencode-easy-vision.example.jsonc", import.meta.url), + "utf8", + ); + expect(await readFile(target, "utf8")).toBe(example); + await writeFile(target, '{ "models": ["mine/*"] }'); + const store = await load(silent, true); + expect(await readFile(target, "utf8")).toBe('{ "models": ["mine/*"] }'); + expect(store.getConfiguredModels()).toEqual(["mine/*"]); + }); + + it("does not create the example when a user .json config exists", async () => { + const { home, writeUser, load } = await layout(); + await writeUser("opencode-easy-vision.json", {}); + await load(silent, true); + await expect( + readFile(join(home, "opencode-easy-vision.jsonc")), + ).rejects.toThrow(); + }); + + it("ships an example that parses to exactly the defaults", async () => { + const { load } = await layout(); + await load(silent, true); + const store = await load(); + expect(store.getConfiguredModels()).toEqual(DEFAULT_MODEL_PATTERNS); + expect(store.getImageAnalysisTool()).toBe(DEFAULT_IMAGE_ANALYSIS_TOOL); + expect(store.getPromptTemplate()).toBeUndefined(); + expect(store.getTempDir()).toBe(join(tmpdir(), TEMP_DIR_NAME)); + expect(store.getCleanupAfterHours()).toBe(DEFAULT_CLEANUP_AFTER_HOURS); + expect(store.getDisableCodeMode()).toBe(true); + }); +}); + +describe("cleanup directories", () => { + it("covers the configured and default temp dirs without duplicates", () => { + const fallback = join(tmpdir(), TEMP_DIR_NAME); + expect(createConfigStore().getTempDirsForCleanup()).toEqual([fallback]); + expect( + createConfigStore({ tempDir: "/custom" }).getTempDirsForCleanup(), + ).toEqual(["/custom", fallback]); + }); +}); diff --git a/test/file-lock.test.ts b/test/file-lock.test.ts new file mode 100644 index 0000000..402ae2a --- /dev/null +++ b/test/file-lock.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { withFileLock } from "../src/file-lock.js"; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 5)); + +describe("withFileLock", () => { + it("runs actions on the same path one at a time, in order", async () => { + const events: string[] = []; + const action = (name: string) => async () => { + events.push(`${name}:start`); + await tick(); + events.push(`${name}:end`); + return name; + }; + const results = await Promise.all([ + withFileLock("/same", action("a")), + withFileLock("/same", action("b")), + withFileLock("/same", action("c")), + ]); + expect(results).toEqual(["a", "b", "c"]); + expect(events).toEqual([ + "a:start", + "a:end", + "b:start", + "b:end", + "c:start", + "c:end", + ]); + }); + + it("lets different paths run concurrently", async () => { + const events: string[] = []; + const action = (name: string) => async () => { + events.push(`${name}:start`); + await tick(); + events.push(`${name}:end`); + }; + await Promise.all([ + withFileLock("/one", action("a")), + withFileLock("/two", action("b")), + ]); + expect(events.slice(0, 2)).toEqual(["a:start", "b:start"]); + }); + + it("releases the lock when an action throws", async () => { + await expect( + withFileLock("/fails", async () => { + throw new Error("boom"); + }), + ).rejects.toThrow("boom"); + await expect(withFileLock("/fails", async () => "next")).resolves.toBe( + "next", + ); + }); +}); diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..0a6a4a4 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,141 @@ +import type { ContentPart, Message } from "@opencode/ai"; +import type { MCPEditor } from "@opencode/plugin/promise/mcp"; +import type { SessionRequest } from "@opencode/plugin/effect/session"; +import type { PluginConfig, Logger, Notifier } from "../src/types.js"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Media } from "@opencode/ai"; +import { onTestFinished } from "vitest"; +import { createConfigStore } from "../src/config.js"; +import { createImageProcessor } from "../src/images.js"; +import { createPromptGenerator } from "../src/prompt.js"; + +export type MediaPart = Extract; + +// A real PNG signature plus one byte: enough for MIME-agnostic byte handling. +export const PNG_BYTES = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 1]); + +export const silent: Logger = () => {}; + +export function image( + bytes: Uint8Array = PNG_BYTES, + mime = "image/png", +): MediaPart { + return { + type: "media", + media: Media.bytes(bytes, mime), + metadata: { attachment: "original" }, + }; +} + +export function media(mime: string): MediaPart { + return { type: "media", media: Media.bytes(PNG_BYTES, mime) }; +} + +export function text(value: string): ContentPart { + return { type: "text", text: value }; +} + +export function user( + content: ContentPart[], + extra: Partial> = {}, +): Message { + return { role: "user", content, ...extra }; +} + +export function assistant(content: ContentPart[]): Message { + return { role: "assistant", content }; +} + +export function hasMedia(message: Message): boolean { + return message.content.some((part) => part.type === "media"); +} + +export function textOf(message: Message): string { + return message.content + .map((part) => (part.type === "text" ? part.text : "")) + .join("\n"); +} + +/** Creates a temp directory that is removed when the current test finishes. */ +export async function tempDir(prefix = "easy-vision-test-"): Promise { + const dir = await mkdtemp(join(tmpdir(), prefix)); + onTestFinished(() => rm(dir, { recursive: true, force: true })); + return dir; +} + +/** Logger/Notifier pair that records every message by level. */ +export function recorder() { + const messages = { + info: [] as string[], + warn: [] as string[], + error: [] as string[], + }; + const log: Logger = (message) => messages.info.push(message); + const notify: Notifier = { + warn: (message) => messages.warn.push(message), + error: (message) => messages.error.push(message), + }; + return { log, notify, messages }; +} + +/** Config, image processor and prompt generator writing into a fresh temp dir. */ +export async function fixture(config: PluginConfig = {}) { + const dir = await tempDir(); + const store = createConfigStore({ + tempDir: dir, + imageAnalysisTool: "Vision_understand_image", + ...config, + }); + return { + tempDir: dir, + config: store, + process: createImageProcessor(store, silent), + prompt: createPromptGenerator(store), + route: "direct" as const, + }; +} + +// SessionRequest uses branded IDs; tests build plain strings and cast once here. +export function sessionRequest( + messages: Message[], + options: { sessionID?: string; providerID?: string; id?: string } = {}, +): SessionRequest { + const plain = { + sessionID: options.sessionID ?? "ses_test", + model: { + providerID: options.providerID ?? "test", + id: options.id ?? "model", + }, + system: [], + messages, + options: {}, + }; + return plain as unknown as SessionRequest; +} + +type ServerConfig = NonNullable>; + +/** In-memory MCP editor over local servers; `set`/`remove` must not be used. */ +export function mcpEditor( + servers: Record, +): MCPEditor { + const configs: Record = Object.fromEntries( + Object.entries(servers).map(([name, config]) => [ + name, + { type: "local", command: ["server"], ...config }, + ]), + ); + return { + list: () => Object.entries(configs), + get: (name) => configs[name], + set: () => { + throw new Error("set must not be used"); + }, + update: (name, update) => update(configs[name]), + remove: () => { + throw new Error("remove must not be used"); + }, + }; +} diff --git a/test/images.test.ts b/test/images.test.ts new file mode 100644 index 0000000..8fa69d4 --- /dev/null +++ b/test/images.test.ts @@ -0,0 +1,294 @@ +import type { ImageAttachment, ImageResult } from "../src/types.js"; +import { + lstat, + readdir, + readFile, + stat, + symlink, + unlink, + utimes, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; +import { createConfigStore } from "../src/config.js"; +import { + createImageProcessor, + decodeBase64, + normalizeMime, +} from "../src/images.js"; +import { PNG_BYTES, fixture, recorder } from "./helpers.js"; + +const bytesSource = ( + data: Uint8Array = PNG_BYTES, + mediaType = "image/png", +): ImageAttachment => ({ + mime: mediaType, + source: { type: "bytes", data, mediaType }, +}); + +function expectSaved(result: ImageResult): string { + if (!result.ok) throw new Error(`expected success, got: ${result.reason}`); + return result.image.path; +} + +function expectFailure(result: ImageResult): string { + if (result.ok) throw new Error(`expected failure, got ${result.image.path}`); + return result.reason; +} + +describe("normalizeMime", () => { + it("lowercases and drops parameters", () => { + expect(normalizeMime(" IMAGE/PNG; charset=binary")).toBe("image/png"); + }); +}); + +describe("decodeBase64", () => { + it("decodes padded, unpadded and whitespace-wrapped input", () => { + const encoded = PNG_BYTES.toString("base64"); + expect(decodeBase64(encoded)).toEqual(PNG_BYTES); + expect(decodeBase64(encoded.replace(/=+$/, ""))).toEqual(PNG_BYTES); + expect(decodeBase64(`${encoded.slice(0, 4)}\n${encoded.slice(4)}`)).toEqual( + PNG_BYTES, + ); + }); + + it.each(["", "%%%", "abcde", "ab=c", "a==="])("rejects %j", (value) => { + expect(() => decodeBase64(value)).toThrow("Malformed base64 image data"); + }); +}); + +describe("createImageProcessor", () => { + it("saves bytes, base64 and data URLs to one content-hashed file", async () => { + const f = await fixture(); + const encoded = PNG_BYTES.toString("base64"); + const results = await Promise.all( + [ + bytesSource(), + { + mime: "IMAGE/PNG", + source: { + type: "base64" as const, + data: encoded, + mediaType: "image/png", + }, + }, + { + mime: "image/png", + source: { + type: "url" as const, + url: `data:image/png;base64,${encoded}`, + }, + }, + ].map(f.process), + ); + const paths = results.map(expectSaved); + expect(new Set(paths).size).toBe(1); + expect(paths[0]).toMatch(/[0-9a-f]{64}\.png$/); + expect(await readFile(paths[0])).toEqual(PNG_BYTES); + expect(await readdir(f.tempDir)).toHaveLength(1); + }); + + it("writes files readable only by the owner", async () => { + const f = await fixture(); + const path = expectSaved(await f.process(bytesSource())); + expect((await stat(path)).mode & 0o777).toBe(0o600); + }); + + it("reuses an intact existing file and refreshes its mtime", async () => { + const f = await fixture(); + const path = expectSaved(await f.process(bytesSource())); + const old = new Date(Date.now() - 48 * 60 * 60 * 1000); + await utimes(path, old, old); + const before = await stat(path); + await f.process(bytesSource()); + const after = await stat(path); + expect(after.ino).toBe(before.ino); + expect(after.mtimeMs).toBeGreaterThan(old.getTime() + 60_000); + }); + + it("restores a missing or damaged file", async () => { + const f = await fixture(); + const path = expectSaved(await f.process(bytesSource())); + await writeFile(path, "damaged"); + await f.process(bytesSource()); + expect(await readFile(path)).toEqual(PNG_BYTES); + await unlink(path); + await f.process(bytesSource()); + expect(await readFile(path)).toEqual(PNG_BYTES); + }); + + it("replaces a symlink planted at the saved path instead of following it", async () => { + const f = await fixture(); + const path = expectSaved(await f.process(bytesSource())); + // A 9-character relative target gives the link the same lstat size as the + // 9-byte image, so only the regular-file check can reject it. + const secret = join(f.tempDir, "secret123"); + await writeFile(secret, "not an image"); + await unlink(path); + await symlink("secret123", path); + expect((await lstat(path)).size).toBe(PNG_BYTES.length); + expect(expectSaved(await f.process(bytesSource()))).toBe(path); + expect((await lstat(path)).isSymbolicLink()).toBe(false); + expect(await readFile(path)).toEqual(PNG_BYTES); + expect(await readFile(secret, "utf8")).toBe("not an image"); + }); + + it("separates identical bytes with different MIME types", async () => { + const f = await fixture(); + const png = expectSaved(await f.process(bytesSource())); + const jpeg = expectSaved( + await f.process(bytesSource(PNG_BYTES, "image/jpeg")), + ); + expect(png.replace(/\.png$/, "")).not.toBe(jpeg.replace(/\.jpg$/, "")); + }); + + it.each([ + ["image/png", "png"], + ["image/jpeg", "jpg"], + ["image/webp", "webp"], + ])("saves %s with a .%s extension", async (mime, extension) => { + const f = await fixture(); + const path = expectSaved(await f.process(bytesSource(PNG_BYTES, mime))); + expect(path.endsWith(`.${extension}`)).toBe(true); + }); + + it("passes file and HTTP(S) URLs through without creating files", async () => { + const f = await fixture(); + const own = join(f.tempDir, "user-owned.png"); + await writeFile(own, PNG_BYTES); + for (const [url, expected] of [ + [pathToFileURL(own).href, own], + ["https://example.test/a.png", "https://example.test/a.png"], + ["http://example.test/a.png", "http://example.test/a.png"], + ]) { + const result = await f.process({ + mime: "image/png", + source: { type: "url", url }, + }); + expect(result).toEqual({ + ok: true, + image: { path: expected, mime: "image/png" }, + }); + } + expect(await readdir(f.tempDir)).toEqual(["user-owned.png"]); + }); + + it.each<[string, ImageAttachment, RegExp]>([ + [ + "malformed base64", + { + mime: "image/png", + source: { type: "base64", data: "%%%", mediaType: "image/png" }, + }, + /Malformed base64/, + ], + [ + "empty bytes", + { + mime: "image/png", + source: { + type: "bytes", + data: new Uint8Array(), + mediaType: "image/png", + }, + }, + /Empty image data/, + ], + [ + "a provider reference", + { + mime: "image/png", + source: { type: "ref", provider: "test", id: "secret-reference" }, + }, + /Provider image reference/, + ], + [ + "an unsupported format", + bytesSource(PNG_BYTES, "image/gif"), + /Unsupported image format \(image\/gif\)/, + ], + [ + "an unsupported URL scheme", + { mime: "image/png", source: { type: "url", url: "ftp://x.test/a" } }, + /Unsupported image URL scheme/, + ], + [ + "a missing local file", + { + mime: "image/png", + source: { type: "url", url: "file:///nonexistent/easy-vision" }, + }, + /check the server temp directory/, + ], + [ + "a data URL with a different MIME type", + { + mime: "image/png", + source: { type: "url", url: "data:image/jpeg;base64,YQ==" }, + }, + /mismatched image data URL/, + ], + [ + "a URL that needs private headers", + { + mime: "image/png", + requiresHeaders: true, + source: { type: "url", url: "https://x.test/private" }, + }, + /requires private headers/, + ], + [ + "an expired URL", + { + mime: "image/png", + source: { type: "url", url: "https://x.test/old", expiresAt: 0 }, + }, + /expired/, + ], + ])("fails explicitly for %s", async (_name, attachment, reason) => { + const f = await fixture(); + expect(expectFailure(await f.process(attachment))).toMatch(reason); + }); + + it("never leaks secrets or I/O details into reasons or logs", async () => { + const f = await fixture(); + const blocked = join(f.tempDir, "not-a-directory"); + await writeFile(blocked, "fixture"); + const { log, messages } = recorder(); + const process = createImageProcessor( + createConfigStore({ tempDir: blocked }), + log, + ); + const reason = expectFailure(await process(bytesSource())); + expect(reason).not.toContain(blocked); + const secret = expectFailure( + await createImageProcessor( + createConfigStore({ tempDir: f.tempDir }), + log, + )({ + mime: "image/png", + source: { type: "ref", provider: "p", id: "secret-reference" }, + }), + ); + expect(secret).not.toContain("secret-reference"); + expect(messages.info.join("\n")).not.toMatch(/secret-reference|ENOTDIR/); + }); + + it("logs each failure", async () => { + const { log, messages } = recorder(); + const process = createImageProcessor(createConfigStore(), log); + await process(bytesSource(PNG_BYTES, "image/gif")); + expect(messages.info).toEqual([ + "Image processing failed: Unsupported image format (image/gif)", + ]); + }); + + it("does not log successful saves", async () => { + const f = await fixture(); + const { log, messages } = recorder(); + await createImageProcessor(f.config, log)(bytesSource()); + expect(messages.info).toEqual([]); + }); +}); diff --git a/test/package.test.ts b/test/package.test.ts new file mode 100644 index 0000000..194ea5f --- /dev/null +++ b/test/package.test.ts @@ -0,0 +1,62 @@ +import { readdir, readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; + +const read = (path: string) => + readFile(new URL(`../${path}`, import.meta.url), "utf8"); + +interface Manifest { + main: string; + types: string; + exports: Record; + files: string[]; + dependencies?: Record; + peerDependencies?: Record; + peerDependenciesMeta?: Record; +} + +describe("package contract", () => { + it("points main, types and exports at the compiled entry", async () => { + const manifest = JSON.parse(await read("package.json")) as Manifest; + expect(manifest.main).toBe("dist/index.js"); + expect(manifest.exports["."].import).toBe(`./${manifest.main}`); + expect(manifest.exports["."].types).toBe(`./${manifest.types}`); + }); + + it("ships the root index.js bridge, example config and docs", async () => { + // OpenCode's local-directory loader resolves /index, not package.main. + const manifest = JSON.parse(await read("package.json")) as Manifest; + expect(manifest.files).toEqual( + expect.arrayContaining([ + "index.js", + "dist", + "opencode-easy-vision.example.jsonc", + "CONFIGURATION.md", + "AGENT_SETUP.md", + ]), + ); + expect(await read("index.js")).toContain( + 'export { EasyVisionPlugin, default } from "./dist/index.js";', + ); + }); + + it("has no OpenCode runtime dependencies; effect is an optional peer", async () => { + // The host supplies effect; installing it would duplicate a 50 MB package. + const manifest = JSON.parse(await read("package.json")) as Manifest; + expect(Object.keys(manifest.dependencies ?? {})).toEqual([ + "strip-json-comments", + ]); + expect(Object.keys(manifest.peerDependencies ?? {})).toEqual(["effect"]); + expect(manifest.peerDependenciesMeta?.effect?.optional).toBe(true); + }); + + it("imports @opencode packages for types only", async () => { + const files = (await readdir(new URL("../src", import.meta.url))).filter( + (name) => name.endsWith(".ts"), + ); + for (const file of files) { + const source = await read(`src/${file}`); + const runtime = source.match(/^import (?!type\b)[^;]*from "@opencode\//m); + expect(runtime, `${file} has a runtime @opencode import`).toBeNull(); + } + }); +}); diff --git a/test/patterns.test.ts b/test/patterns.test.ts new file mode 100644 index 0000000..4aab58c --- /dev/null +++ b/test/patterns.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { createConfigStore } from "../src/config.js"; +import { DEFAULT_MODEL_PATTERNS } from "../src/constants.js"; +import { createModelMatcher } from "../src/patterns.js"; + +const matcher = (models?: string[]) => + createModelMatcher(createConfigStore({ models })); +const model = (providerID: string, modelID: string) => ({ + providerID, + modelID, +}); + +describe("createModelMatcher", () => { + it("never matches an unknown model", () => { + expect(matcher(["*"])(undefined)).toBe(false); + }); + + it("matches everything with a bare *", () => { + expect(matcher(["*"])(model("any", "thing"))).toBe(true); + }); + + it.each([ + ["minimax/*", model("minimax", "m2"), true], + ["minimax/*", model("minimax-cn", "m2"), false], + ["*/m2", model("anyone", "m2"), true], + ["*/m2", model("anyone", "m2.5"), false], + ["minimax/m2", model("minimax", "m2"), true], + ["minimax/m2", model("minimax", "m2.5"), false], + ["minimax/*m2*", model("minimax", "abab-m2-chat"), true], + ["minimax/*-chat", model("minimax", "abab-m2-chat"), true], + ["minimax/*-chat", model("minimax", "abab-m2-chat-x"), false], + ])("provider/model pattern %s", (pattern, input, expected) => { + expect(matcher([pattern])(input)).toBe(expected); + }); + + it("matches a slash-less pattern against provider or model ID", () => { + const matches = matcher(["minimax*"]); + expect(matches(model("minimax", "x"))).toBe(true); + expect(matches(model("openrouter", "minimax-m2"))).toBe(true); + expect(matches(model("openrouter", "deepseek"))).toBe(false); + }); + + it("is case-insensitive", () => { + expect(matcher(["MiniMax/*"])(model("MINIMAX", "m2"))).toBe(true); + }); + + it("splits on the first slash so nested model IDs work", () => { + const matches = matcher(["openrouter/deepseek/*"]); + expect(matches(model("OPENROUTER", "DeepSeek/model"))).toBe(true); + expect(matches(model("other", "deepseek/model"))).toBe(false); + }); + + it("matches any configured pattern", () => { + const matches = matcher(["a/*", "b/*"]); + expect(matches(model("b", "x"))).toBe(true); + expect(matches(model("c", "x"))).toBe(false); + }); + + it("defaults to the MiniMax provider patterns only", () => { + const matches = matcher(); + for (const pattern of DEFAULT_MODEL_PATTERNS) { + expect(matches(model(pattern.slice(0, -2), "anything"))).toBe(true); + } + expect(matches(model("openrouter", "minimax/minimax-m2"))).toBe(false); + }); +}); diff --git a/test/plugin.test.ts b/test/plugin.test.ts new file mode 100644 index 0000000..3780584 --- /dev/null +++ b/test/plugin.test.ts @@ -0,0 +1,249 @@ +import type { MCPEditor } from "@opencode/plugin/promise/mcp"; +import type { Plugin } from "@opencode/plugin/effect"; +import type { SessionRequest } from "@opencode/plugin/effect/session"; +import { mkdir, readdir, readFile, utimes, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { Effect, Exit, Logger, References, Scope } from "effect"; +import { describe, expect, it, vi } from "vitest"; +import EasyVisionPlugin from "../src/index.js"; +import { + PNG_BYTES, + hasMedia, + image, + mcpEditor, + sessionRequest, + tempDir, + text, + textOf, + user, +} from "./helpers.js"; + +const TOOL = "Vision_understand_image"; +type Hook = (event: SessionRequest) => Effect.Effect; + +interface LogEntry { + message: string; + level: string; + annotations: Record; +} + +/** + * Runs the real plugin effect against fake OpenCode domains, with a capturing + * logger installed the way the host provides its own. + */ +async function start( + options: { config?: object; tools?: { id: string; options?: object }[] } = {}, +) { + const root = await tempDir("easy-vision-plugin-"); + const directory = join(root, "project"); + const home = join(root, "home"); + vi.stubEnv("HOME", home); + await mkdir(join(directory, ".opencode"), { recursive: true }); + await writeFile( + join(directory, ".opencode/opencode-easy-vision.json"), + JSON.stringify({ + models: ["test/*"], + imageAnalysisTool: TOOL, + tempDir: join(directory, "images"), + ...options.config, + }), + ); + + const hooks = new Map(); + const mcpTransforms: ((editor: MCPEditor) => void)[] = []; + const session = { + directory, + toolList: Effect.succeed(options.tools ?? []) as Effect.Effect< + unknown[], + Error + >, + }; + const registration = Effect.succeed({ dispose: Effect.void }); + const fake = { + location: { directory }, + options: {}, + mcp: { + transform: (callback: (editor: MCPEditor) => void) => { + mcpTransforms.push(callback); + return registration; + }, + }, + tool: { list: () => session.toolList }, + session: { + get: () => Effect.succeed({ location: { directory: session.directory } }), + hook: (name: string, callback: Hook) => { + hooks.set(name, callback); + return registration; + }, + }, + }; + // The full Context has ~25 domains; the plugin only touches these. + const ctx = fake as unknown as Plugin.Context; + + const logs: LogEntry[] = []; + const capture = Logger.make((entry) => + logs.push({ + message: [entry.message].flat().join(" "), + level: entry.logLevel, + annotations: entry.fiber.getRef(References.CurrentLogAnnotations), + }), + ); + const scope = Effect.runSync(Scope.make()); + await Effect.runPromise( + EasyVisionPlugin.effect(ctx).pipe( + Scope.provide(scope), + Effect.provide(Logger.layer([capture])), + ), + ); + + const run = async (name: string, event: SessionRequest) => { + const hook = hooks.get(name); + if (!hook) throw new Error(`hook ${name} was not registered`); + await Effect.runPromise(hook(event)); + return event; + }; + const close = () => Effect.runPromise(Scope.close(scope, Exit.void)); + return { directory, home, hooks, mcpTransforms, session, logs, run, close }; +} + +describe("EasyVisionPlugin", () => { + it("has a stable id", () => { + expect(EasyVisionPlugin.id).toBe("opencode-easy-vision"); + }); + + it("registers the MCP transform and the four request hooks", async () => { + const p = await start(); + expect(p.mcpTransforms).toHaveLength(1); + expect([...p.hooks.keys()]).toEqual([ + "context", + "compaction", + "title", + "generate", + ]); + await p.close(); + }); + + it("logs through the host logger, tagged with the plugin name", async () => { + // Regression: OpenCode discards plugin console output, so logs must go + // through the Effect logger the host provides. + const p = await start(); + await vi.waitFor(() => + expect(p.logs).toContainEqual({ + message: "Plugin initialized", + level: "Info", + annotations: { plugin: "easy-vision" }, + }), + ); + expect( + p.logs.every((log) => log.annotations.plugin === "easy-vision"), + ).toBe(true); + await p.close(); + }); + + it("rewrites images in the context hook using the request's tools", async () => { + const p = await start(); + const event = await p.run( + "context", + sessionRequest([user([text("what is it?"), image()])], {}), + ); + expect(hasMedia(event.messages[0])).toBe(false); + expect(textOf(event.messages[0])).toContain("is not available"); + const direct = sessionRequest([user([image()])]); + Object.assign(direct, { tools: { [TOOL]: {} } }); + await p.run("context", direct); + expect(textOf(direct.messages[0])).toContain(`Use the \`${TOOL}\` tool`); + await p.close(); + }); + + it("uses the tool registry to detect Code Mode", async () => { + const p = await start({ tools: [{ id: TOOL }] }); + const event = sessionRequest([user([image()])]); + Object.assign(event, { tools: { execute: {} } }); + await p.run("context", event); + expect(textOf(event.messages[0])).toContain("exposed through Code Mode"); + await p.close(); + }); + + it.each(["compaction", "title", "generate"])( + "keeps the %s hook neutral", + async (name) => { + const p = await start(); + const event = await p.run(name, sessionRequest([user([image()])])); + expect(hasMedia(event.messages[0])).toBe(false); + expect(textOf(event.messages[0])).toContain("Attached image:"); + expect(textOf(event.messages[0])).not.toContain("Use the"); + await p.close(); + }, + ); + + it("leaves requests from another location untouched", async () => { + const p = await start(); + p.session.directory = "/somewhere/else"; + const event = sessionRequest([user([image()])]); + const original = event.messages; + await p.run("context", event); + expect(event.messages).toBe(original); + await p.close(); + }); + + it("logs a warning and strips images when a host call fails", async () => { + const p = await start(); + p.session.toolList = Effect.fail(new Error("private provider response")); + const event = await p.run("context", sessionRequest([user([image()])])); + expect(hasMedia(event.messages[0])).toBe(false); + await vi.waitFor(() => + expect(p.logs).toContainEqual( + expect.objectContaining({ + level: "Warn", + message: expect.stringContaining("Image processing failed"), + }), + ), + ); + expect(JSON.stringify(p.logs)).not.toContain("private provider response"); + await p.close(); + }); + + it("clears warning state when its scope closes", async () => { + const p = await start(); + const warnings = () => p.logs.filter((log) => log.level === "Warn").length; + await p.run("context", sessionRequest([user([image()])])); + await p.run("context", sessionRequest([user([image()])])); + await vi.waitFor(() => expect(warnings()).toBe(1)); + await p.close(); + await p.run("context", sessionRequest([user([image()])])); + await vi.waitFor(() => expect(warnings()).toBe(2)); + }); + + it("wires the MCP transform to the loaded config", async () => { + const p = await start({ config: { imageAnalysisTool: "Mine_tool" } }); + const editor = mcpEditor({ Mine: {}, Vision: {} }); + p.mcpTransforms[0](editor); + expect(editor.get("Mine")?.codemode).toBe(false); + expect(editor.get("Vision")?.codemode).toBeUndefined(); + await p.close(); + }); + + it("cleans expired images from the configured temp dir on startup", async () => { + const root = await tempDir(); + const images = join(root, "images"); + await mkdir(images); + const expired = join(images, `${"a".repeat(64)}.png`); + await writeFile(expired, PNG_BYTES); + await utimes(expired, new Date(0), new Date(0)); + const p = await start({ config: { tempDir: images } }); + expect(await readdir(images)).toEqual([]); + await p.close(); + }); + + it("creates the example user config on first start", async () => { + const p = await start(); + const created = await readFile( + join(p.home, ".config/opencode/opencode-easy-vision.jsonc"), + "utf8", + ); + expect(created).toContain( + `"imageAnalysisTool": "MiniMax_understand_image"`, + ); + await p.close(); + }); +}); diff --git a/test/prompt.test.ts b/test/prompt.test.ts new file mode 100644 index 0000000..5463716 --- /dev/null +++ b/test/prompt.test.ts @@ -0,0 +1,80 @@ +import type { SavedImage } from "../src/types.js"; +import { describe, expect, it } from "vitest"; +import { createConfigStore } from "../src/config.js"; +import { createPromptGenerator } from "../src/prompt.js"; + +const one: SavedImage[] = [{ path: "/tmp/a.png", mime: "image/png" }]; +const two: SavedImage[] = [...one, { path: "/tmp/b.jpg", mime: "image/jpeg" }]; +const generator = (promptTemplate?: string) => + createPromptGenerator( + createConfigStore({ imageAnalysisTool: "Vision_tool", promptTemplate }), + ); + +describe("default templates", () => { + it("uses the single-image wording for one image", () => { + const prompt = generator()(one, "what is this?", "direct"); + expect(prompt).toContain("The user has shared an image."); + expect(prompt).toContain("- Image 1: /tmp/a.png"); + expect(prompt).toContain("Use the `Vision_tool` tool"); + expect(prompt).toContain("User's request: what is this?"); + }); + + it("uses the multi-image wording and numbers every image", () => { + const prompt = generator()(two, "compare", "direct"); + expect(prompt).toContain("The user has shared 2 images."); + expect(prompt).toContain("- Image 1: /tmp/a.png\n- Image 2: /tmp/b.jpg"); + }); + + it("asks for analysis when the user typed nothing", () => { + expect(generator()(one, "", "direct")).toContain( + "User's request: (analyze the image)", + ); + }); + + it("asks the model not to repeat completed analysis", () => { + expect(generator()(one, "x", "direct")).toMatch( + /do not repeat completed analysis\.$/, + ); + }); +}); + +describe("custom templates", () => { + it("substitutes every variable, every time", () => { + const prompt = generator( + "{imageCount}|{imageList}|{toolName}|{userText}|{toolName}", + )(one, "hi", "direct"); + expect(prompt.split("\n")[0]).toBe( + "1|- Image 1: /tmp/a.png|Vision_tool|hi|Vision_tool", + ); + }); + + it("inserts values literally, without replacement patterns or re-expansion", () => { + const images = [{ path: "/path/$&.png", mime: "image/png" }]; + const prompt = generator("{imageList}|{userText}")( + images, + "$& {imageCount} $1", + "direct", + ); + expect(prompt).toContain("- Image 1: /path/$&.png|$& {imageCount} $1"); + }); +}); + +describe("tool routes", () => { + it("adds execute guidance when the tool is behind Code Mode", () => { + const prompt = generator()(one, "x", "codemode"); + expect(prompt).toContain("exposed through Code Mode"); + expect(prompt).toContain("discover the exact Vision_tool"); + }); + + it("adds no Code Mode guidance for direct tools", () => { + expect(generator()(one, "x", "direct")).not.toContain("Code Mode"); + }); + + it("lists paths but forbids claiming analysis when the tool is unavailable", () => { + const prompt = generator("{toolName} custom")(one, "x", "unavailable"); + expect(prompt).toContain("- Image 1: /tmp/a.png"); + expect(prompt).toContain("Vision_tool is not available"); + expect(prompt).toContain("Do not claim to have analyzed the images."); + expect(prompt).not.toContain("custom"); + }); +}); diff --git a/test/request.test.ts b/test/request.test.ts new file mode 100644 index 0000000..9b614a0 --- /dev/null +++ b/test/request.test.ts @@ -0,0 +1,167 @@ +import type { RequestHost } from "../src/request.js"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { createConfigStore } from "../src/config.js"; +import { createRequestRewriter } from "../src/request.js"; +import { + hasMedia, + image, + recorder, + sessionRequest as request, + tempDir, + text, + textOf, + user, +} from "./helpers.js"; + +const TOOL = "Vision_understand_image"; + +async function setup( + overrides: (directory: string) => Partial = () => ({}), +) { + const directory = await tempDir(); + const host = overrides(directory); + const config = createConfigStore({ + models: ["test/*"], + imageAnalysisTool: TOOL, + tempDir: join(directory, "images"), + }); + const getSessionDirectory = vi.fn( + host.getSessionDirectory ?? (async () => directory), + ); + const listTools = vi.fn(host.listTools ?? (async () => [])); + const { log, notify, messages } = recorder(); + const rewriter = createRequestRewriter( + config, + { directory, getSessionDirectory, listTools }, + log, + notify, + ); + return { rewriter, getSessionDirectory, listTools, warnings: messages.warn }; +} + +describe("createRequestRewriter", () => { + it("ignores requests for models that do not match", async () => { + const s = await setup(); + const event = request([user([image()])], { providerID: "other" }); + const original = event.messages; + await s.rewriter.rewrite(event, false, { [TOOL]: {} }); + expect(event.messages).toBe(original); + expect(s.getSessionDirectory).not.toHaveBeenCalled(); + }); + + it("ignores requests without user images", async () => { + const s = await setup(); + const event = request([user([text("hi")])]); + const original = event.messages; + await s.rewriter.rewrite(event, false, { [TOOL]: {} }); + expect(event.messages).toBe(original); + expect(s.getSessionDirectory).not.toHaveBeenCalled(); + }); + + it("ignores sessions that belong to another project location", async () => { + const s = await setup(() => ({ + getSessionDirectory: async () => "/elsewhere", + })); + const event = request([user([image()])]); + const original = event.messages; + await s.rewriter.rewrite(event, false, { [TOOL]: {} }); + expect(event.messages).toBe(original); + expect(s.listTools).not.toHaveBeenCalled(); + }); + + it("treats equivalent location paths as the same location", async () => { + const s = await setup((directory) => ({ + getSessionDirectory: async () => `${directory}/./`, + })); + const event = request([user([image()])]); + await s.rewriter.rewrite(event, false, { [TOOL]: {} }); + expect(hasMedia(event.messages[0])).toBe(false); + }); + + it("rewrites with direct-tool guidance when the request exposes the tool", async () => { + const s = await setup(); + const event = request([user([text("what is it?"), image()])]); + await s.rewriter.rewrite(event, false, { [TOOL]: {} }); + expect(hasMedia(event.messages[0])).toBe(false); + expect(textOf(event.messages[0])).toContain(`Use the \`${TOOL}\` tool`); + expect(textOf(event.messages[0])).not.toContain("Code Mode"); + expect(s.warnings).toEqual([]); + }); + + it("routes through execute when the tool is behind Code Mode", async () => { + const s = await setup(() => ({ listTools: async () => [{ id: TOOL }] })); + const event = request([user([image()])]); + await s.rewriter.rewrite(event, false, { execute: {} }); + expect(textOf(event.messages[0])).toContain("exposed through Code Mode"); + }); + + it("warns once per session when the tool is unavailable", async () => { + const s = await setup(); + for (const sessionID of ["ses_a", "ses_a", "ses_b"]) { + const event = request([user([image()])], { sessionID }); + await s.rewriter.rewrite(event, false, {}); + expect(textOf(event.messages[0])).toContain(`${TOOL} is not available`); + } + expect(s.warnings).toHaveLength(2); + expect(s.warnings[0]).toContain(`Vision tool ${TOOL} is unavailable`); + }); + + it("neither looks up tools nor warns for neutral requests", async () => { + const s = await setup(); + const event = request([user([image()])]); + await s.rewriter.rewrite(event, true); + expect(s.listTools).not.toHaveBeenCalled(); + expect(s.warnings).toEqual([]); + expect(textOf(event.messages[0])).not.toContain("Use the"); + }); + + it("warns once per session for repeated image failures", async () => { + const s = await setup(); + const gif = () => image(undefined, "image/gif"); + await s.rewriter.rewrite(request([user([gif()])]), false, { [TOOL]: {} }); + await s.rewriter.rewrite(request([user([gif()])]), false, { [TOOL]: {} }); + expect(s.warnings).toEqual(["Unsupported image format (image/gif)"]); + }); + + it("strips images and warns when a host lookup fails", async () => { + const s = await setup(() => ({ + listTools: async () => { + throw new Error("private provider response"); + }, + })); + const event = request([user([text("keep"), image()])]); + await s.rewriter.rewrite(event, false, {}); + expect(hasMedia(event.messages[0])).toBe(false); + expect(textOf(event.messages[0])).toContain("attachment processing failed"); + expect(JSON.stringify(event.messages)).not.toContain("private provider"); + expect(s.warnings).toEqual([ + "Image processing failed; see the attachment failure note in model context.", + ]); + }); + + it("forgets the oldest warnings beyond 256 so memory stays bounded", async () => { + const s = await setup(); + for (let i = 0; i <= 256; i++) + await s.rewriter.rewrite( + request([user([image()])], { sessionID: `ses_${i}` }), + false, + {}, + ); + expect(s.warnings).toHaveLength(257); + await s.rewriter.rewrite( + request([user([image()])], { sessionID: "ses_0" }), + false, + {}, + ); + expect(s.warnings).toHaveLength(258); + }); + + it("warns again after dispose", async () => { + const s = await setup(); + await s.rewriter.rewrite(request([user([image()])]), false, {}); + s.rewriter.dispose(); + await s.rewriter.rewrite(request([user([image()])]), false, {}); + expect(s.warnings).toHaveLength(2); + }); +}); diff --git a/test/transform.test.ts b/test/transform.test.ts new file mode 100644 index 0000000..e79e65d --- /dev/null +++ b/test/transform.test.ts @@ -0,0 +1,274 @@ +import type { ContentPart, Message } from "@opencode/ai"; +import type { ImageResult } from "../src/types.js"; +import { Media } from "@opencode/ai"; +import { describe, expect, it } from "vitest"; +import { + isImagePart, + normalizeAttachment, + stripImagesAfterFailure, + transformMessages, +} from "../src/transform.js"; +import { + PNG_BYTES, + assistant, + fixture, + hasMedia, + image, + media, + text, + textOf, + user, +} from "./helpers.js"; + +const lastText = (message: Message) => { + const part = message.content.at(-1); + if (part?.type !== "text") throw new Error("last part is not text"); + return part.text; +}; + +describe("isImagePart", () => { + it.each<[string, ContentPart, boolean]>([ + ["png media", image(), true], + ["uppercase image MIME", media("IMAGE/WEBP"), true], + ["unsupported image MIME", media("image/gif"), true], + ["pdf media", media("application/pdf"), false], + ["audio media", media("audio/wav"), false], + ["text", text("image/png"), false], + ])("%s", (_name, part, expected) => { + expect(isImagePart(part)).toBe(expected); + }); +}); + +describe("normalizeAttachment", () => { + it("returns undefined for non-image parts", () => { + expect(normalizeAttachment(text("x"))).toBeUndefined(); + expect(normalizeAttachment(media("application/pdf"))).toBeUndefined(); + }); + + it("normalizes the MIME type and flags private headers", () => { + const remote: ContentPart = { + type: "media", + media: Media.url("https://x.test/a.png", { + mediaType: "Image/PNG", + headers: { authorization: "secret" }, + }), + }; + expect(normalizeAttachment(remote)).toMatchObject({ + mime: "image/png", + requiresHeaders: true, + source: { type: "url", url: "https://x.test/a.png" }, + }); + expect(normalizeAttachment(image())).toMatchObject({ + requiresHeaders: false, + source: { type: "bytes" }, + }); + }); +}); + +describe("transformMessages", () => { + it("returns messages without images unchanged, by reference", async () => { + const f = await fixture(); + const messages = [user([text("hi")]), assistant([text("hello")])]; + const result = await transformMessages(messages, f); + expect(result[0]).toBe(messages[0]); + expect(result[1]).toBe(messages[1]); + }); + + it("never touches images outside user messages", async () => { + const f = await fixture(); + const reply = assistant([image()]); + const [result] = await transformMessages([reply], f); + expect(result).toBe(reply); + }); + + it("replaces images in place and keeps every other part, its order and metadata", async () => { + const f = await fixture(); + const pdf = media("application/pdf"); + const audio = media("audio/wav"); + const original = user( + [text("first"), image(), pdf, text("second"), audio, image()], + { id: "msg_1", metadata: { original: true } }, + ); + const [result] = await transformMessages([original], f); + + expect(original.content[1].type).toBe("media"); + expect(result).not.toBe(original); + expect(result.id).toBe("msg_1"); + expect(result.metadata).toEqual({ original: true }); + expect(result.content[0]).toBe(original.content[0]); + expect(result.content[2]).toBe(pdf); + expect(result.content[3]).toBe(original.content[3]); + expect(result.content[4]).toBe(audio); + expect(result.content[1]).toMatchObject({ + type: "text", + text: expect.stringMatching(/^\nAttached image: .+[0-9a-f]{64}\.png\n$/), + metadata: { attachment: "original", easyVision: true }, + }); + expect(result.content.filter(isImagePart)).toEqual([]); + }); + + it("gives guidance once, on the latest user message, with all of its images", async () => { + const f = await fixture(); + const [result] = await transformMessages( + [user([text("compare these"), image(), image(PNG_BYTES, "image/jpeg")])], + f, + ); + const guidance = lastText(result); + expect(guidance).toContain("The user has shared 2 images."); + expect(guidance).toContain("Use the `Vision_understand_image` tool"); + expect(guidance).toContain("User's request: compare these"); + expect(textOf(result).match(/Use the/g)).toHaveLength(1); + }); + + it("keeps older image turns neutral: paths only, no instructions", async () => { + const f = await fixture(); + const result = await transformMessages( + [ + user([image(), text("old request")]), + assistant([text("old answer")]), + user([image()]), + ], + f, + ); + expect(result[0].content).toHaveLength(2); + expect(textOf(result[0])).toContain("Attached image:"); + expect(textOf(result[0])).not.toContain("Use the"); + expect(lastText(result[2])).toContain("(analyze the image)"); + }); + + it("only adds paths in neutral mode", async () => { + const f = await fixture(); + const [result] = await transformMessages([user([image()])], { + ...f, + neutral: true, + }); + expect(result.content).toHaveLength(1); + expect(textOf(result)).toContain("Attached image:"); + expect(textOf(result)).not.toContain("Use the"); + }); + + it("passes only the user's typed text to {userText}", async () => { + const f = await fixture(); + const [result] = await transformMessages( + [ + user([ + text('\nSKILL BODY\n'), + text("describe this"), + { + type: "text", + text: "\n\nAttached file: notes.txt\n\nFILE BODY", + metadata: { attachment: { name: "notes.txt" } }, + }, + text("and this"), + text("Attached file: /tmp/shot.png"), + image(), + ]), + ], + f, + ); + const guidance = lastText(result); + expect(guidance).toContain("User's request: describe this\nand this"); + for (const leaked of ["SKILL BODY", "FILE BODY", "/tmp/shot.png"]) + expect(guidance).not.toContain(leaked); + expect(textOf(result)).toContain("SKILL BODY"); + expect(textOf(result)).toContain("FILE BODY"); + }); + + it("is stable when applied to its own output", async () => { + const f = await fixture(); + const once = await transformMessages([user([text("x"), image()])], f); + expect(await transformMessages(once, f)).toEqual(once); + }); + + it("drops its own earlier guidance if media is reintroduced", async () => { + const f = await fixture(); + const [once] = await transformMessages([user([text("x"), image()])], f); + const [twice] = await transformMessages( + [{ ...once, content: [...once.content, image()] }], + f, + ); + expect(textOf(twice).match(/Use the/g)).toHaveLength(1); + expect(textOf(twice).match(/Attached image:/g)).toHaveLength(1); + }); + + it("produces the same path on every pass", async () => { + const f = await fixture(); + const first = await transformMessages([user([image()])], f); + const second = await transformMessages([user([image()])], f); + expect(textOf(second[0])).toBe(textOf(first[0])); + }); + + describe("failures", () => { + it("replaces a failed image with its reason and reports the count", async () => { + const f = await fixture(); + let calls = 0; + const failures: [string, number][] = []; + const [result] = await transformMessages( + [user([text("keep me"), image(), image()])], + { + ...f, + process: async (attachment): Promise => + ++calls === 1 + ? f.process(attachment) + : { ok: false, reason: "Cannot save" }, + onFailure: (reason, index) => failures.push([reason, index]), + }, + ); + expect(result.content[0]).toEqual(text("keep me")); + expect(hasMedia(result)).toBe(false); + expect(textOf(result)).toContain( + "Easy Vision attachment failure: Cannot save", + ); + expect(textOf(result)).toContain( + "1 attachment(s) could not be processed; 1 image(s) remain available", + ); + expect(failures).toEqual([["Cannot save", 0]]); + }); + + it("gives no analysis instructions when every image failed", async () => { + const f = await fixture(); + const [result] = await transformMessages([user([image()])], { + ...f, + process: async () => ({ ok: false, reason: "nope" }), + }); + expect(textOf(result)).not.toContain("Use the"); + expect(lastText(result)).toContain( + "1 attachment(s) could not be processed. Do not infer", + ); + }); + + it("hides the details of an unexpected processor error", async () => { + const f = await fixture(); + const [result] = await transformMessages([user([image()])], { + ...f, + process: async () => { + throw new Error("private bytes/URL"); + }, + }); + expect(hasMedia(result)).toBe(false); + expect(textOf(result)).toContain("Unexpected image-processing failure"); + expect(textOf(result)).not.toContain("private bytes/URL"); + }); + }); +}); + +describe("stripImagesAfterFailure", () => { + it("replaces images in user messages and leaves everything else alone", () => { + const plain = user([text("no images")]); + const reply = assistant([image()]); + const pdf = media("application/pdf"); + const [stripped, untouched, replyOut] = stripImagesAfterFailure([ + user([text("keep"), image(), pdf]), + plain, + reply, + ]); + expect(stripped.content[0]).toEqual(text("keep")); + expect(stripped.content[1]).toMatchObject({ + type: "text", + text: expect.stringContaining("attachment processing failed"), + }); + expect(stripped.content[2]).toBe(pdf); + expect(untouched).toBe(plain); + expect(replyOut).toBe(reply); + }); +}); diff --git a/test/v2.test.ts b/test/v2.test.ts new file mode 100644 index 0000000..444d6f5 --- /dev/null +++ b/test/v2.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { resolveToolRoute } from "../src/v2.js"; + +const TOOL = "Vision_understand_image"; + +describe("resolveToolRoute", () => { + it("is direct when the request exposes the tool", () => { + expect(resolveToolRoute(TOOL, { [TOOL]: {} }, [])).toBe("direct"); + }); + + it("is Code Mode when the tool is registered behind execute", () => { + expect( + resolveToolRoute(TOOL, { execute: {} }, [ + { id: TOOL, options: { codemode: true } }, + ]), + ).toBe("codemode"); + expect(resolveToolRoute(TOOL, { execute: {} }, [{ id: TOOL }])).toBe( + "codemode", + ); + }); + + it.each([ + [ + "the tool is registered but opted out of Code Mode", + { execute: {} }, + [{ id: TOOL, options: { codemode: false } }], + ], + ["execute is not exposed", {}, [{ id: TOOL }]], + ["the tool is not registered", { execute: {} }, []], + ["only a differently-cased tool exists", { [TOOL.toLowerCase()]: {} }, []], + ])("is unavailable when %s", (_name, tools, registered) => { + expect(resolveToolRoute(TOOL, tools, registered)).toBe("unavailable"); + }); + + it("ignores inherited object properties", () => { + expect(resolveToolRoute("toString", {}, [])).toBe("unavailable"); + }); +}); diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..b918d49 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "noEmit": true, + "declaration": false, + "declarationMap": false + }, + "include": ["src/**/*", "test/**/*", "vitest.config.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..32beb23 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,26 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["test/**/*.test.ts"], + environment: "node", + // Each file runs in its own process, so HOME/env stubs cannot leak between files. + pool: "forks", + unstubEnvs: true, + restoreMocks: true, + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + // types.ts has no runtime code. + exclude: ["src/types.ts"], + reporter: ["text", "html"], + // Floors, not targets: CI fails if coverage regresses below these. + thresholds: { + lines: 95, + functions: 95, + statements: 95, + branches: 90, + }, + }, + }, +});