diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml new file mode 100644 index 0000000..afcaf22 --- /dev/null +++ b/.github/actions/setup/action.yml @@ -0,0 +1,50 @@ +# MIT License +# +# Copyright (c) 2026 PostHog Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +name: Setup +description: Setup Node.js and install dependencies + +inputs: + install: + description: 'Whether to install dependencies' + required: false + default: 'true' + +runs: + using: composite + steps: + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: '**/node_modules' + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}-${{ hashFiles('**/package.json', '!node_modules/**') }} + restore-keys: | + ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + ${{ runner.os }}-bun- + + - name: Install dependencies + if: inputs.install == 'true' + run: bun install --frozen-lockfile + shell: bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9e7b640 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +# MIT License +# +# Copyright (c) 2026 PostHog Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup environment + uses: ./.github/actions/setup + + - name: Typecheck + run: bun run typecheck + + - name: Lint + run: bun run lint + + - name: Test + run: bun run test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fb5861 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +.context/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..6b73352 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,9 @@ +{ + "trailingComma": "es5", + "tabWidth": 4, + "semi": false, + "singleQuote": true, + "printWidth": 120, + "sortPackageJson": false, + "ignorePatterns": ["node_modules", "dist", "pnpm-lock.yaml"] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..6eb6e2d --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,11 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "import", "promise", "vitest"], + "categories": { + "correctness": "error", + "suspicious": "warn", + "perf": "warn" + }, + "rules": {}, + "ignorePatterns": ["node_modules", "dist"] +} diff --git a/README.md b/README.md index 4fbaa87..78d4d64 100644 --- a/README.md +++ b/README.md @@ -1 +1,90 @@ -# opencode-posthog \ No newline at end of file +# opencode-posthog + +PostHog LLM Analytics plugin for [OpenCode](https://opencode.ai). Captures LLM generations, tool executions, and conversation traces, sending them to PostHog as structured `$ai_*` events for the LLM Analytics dashboard. + +## Installation + +Add `opencode-posthog` to your `opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["opencode-posthog"] +} +``` + +The package is installed automatically at startup and cached in `~/.cache/opencode/node_modules/`. + +### Local development + +Place the plugin source in your project's `.opencode/plugins/` directory (or `~/.config/opencode/plugins/` for global use). Add `posthog-node` to `.opencode/package.json` so OpenCode installs it at startup: + +```json +{ + "dependencies": { + "posthog-node": "^5.0.0" + } +} +``` + +## Configuration + +All configuration is via environment variables: + +| Variable | Default | Description | +| ------------------------------ | -------------------------- | ----------------------------------------------- | +| `POSTHOG_API_KEY` | _(required)_ | PostHog project API key | +| `POSTHOG_HOST` | `https://us.i.posthog.com` | PostHog instance URL | +| `POSTHOG_PRIVACY_MODE` | `false` | Redact all LLM input/output content when `true` | +| `POSTHOG_ENABLED` | `true` | Set `false` to disable | +| `POSTHOG_DISTINCT_ID` | machine hostname | The `distinct_id` for all events | +| `POSTHOG_PROJECT_NAME` | cwd basename | Project name in all events | +| `POSTHOG_TAGS` | _(none)_ | Custom tags: `key1:val1,key2:val2` | +| `POSTHOG_MAX_ATTRIBUTE_LENGTH` | `12000` | Max length for serialized tool input/output | + +If `POSTHOG_API_KEY` is not set, the plugin is a no-op. + +## Events + +### `$ai_generation` — per LLM call + +Emitted for each LLM roundtrip (step-finish part). Properties include: + +- `$ai_model`, `$ai_provider` — model and provider identifiers +- `$ai_input_tokens`, `$ai_output_tokens`, `$ai_reasoning_tokens` — token counts +- `$ai_cache_read_input_tokens`, `$ai_cache_creation_input_tokens` — cache token counts +- `$ai_total_cost_usd` — cost in USD +- `$ai_latency` — not available per-step (use trace-level latency) +- `$ai_stop_reason` — `stop`, `tool_calls`, `error`, etc. +- `$ai_input`, `$ai_output_choices` — message content (null in privacy mode) +- `$ai_trace_id`, `$ai_span_id`, `$ai_session_id` — correlation IDs + +### `$ai_span` — per tool execution + +Emitted when a tool call completes or errors. Properties include: + +- `$ai_span_name` — tool name (`read`, `write`, `bash`, `edit`, etc.) +- `$ai_latency` — execution time in seconds +- `$ai_input_state`, `$ai_output_state` — tool input/output (null in privacy mode) +- `$ai_parent_id` — span ID of the generation that triggered this tool +- `$ai_is_error`, `$ai_error` — error status + +### `$ai_trace` — per user prompt + +Emitted on `session.idle` (agent finished responding). Properties include: + +- `$ai_trace_id`, `$ai_session_id` — correlation IDs +- `$ai_latency` — total trace time in seconds +- `$ai_total_input_tokens`, `$ai_total_output_tokens` — accumulated token counts +- `$ai_input_state`, `$ai_output_state` — user prompt and final response +- `$ai_is_error` — whether any step/tool errored + +## Privacy + +When `POSTHOG_PRIVACY_MODE=true`, all content fields (`$ai_input`, `$ai_output_choices`, `$ai_input_state`, `$ai_output_state`) are set to `null`. Token counts, costs, latency, and model metadata still flow. + +Sensitive keys (matching `api_key`, `token`, `secret`, `password`, `authorization`, `credential`, `private_key`) are always redacted in tool inputs/outputs regardless of privacy mode. + +## License + +MIT diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..8b82351 --- /dev/null +++ b/bun.lock @@ -0,0 +1,339 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "opencode-posthog", + "dependencies": { + "posthog-node": "^5.0.0", + }, + "devDependencies": { + "@opencode-ai/plugin": "*", + "@opencode-ai/sdk": "*", + "@types/bun": "^1.3.0", + "oxfmt": "^0.40.0", + "oxlint": "^1.55.0", + "typescript": "^5.8.0", + "vitest": "^3.0.0", + }, + "peerDependencies": { + "@opencode-ai/plugin": "*", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.3.15", "", { "dependencies": { "@opencode-ai/sdk": "1.3.15", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.1.96", "@opentui/solid": ">=0.1.96" }, "optionalPeers": ["@opentui/core", "@opentui/solid"] }, "sha512-jZJbuvUXc5Limz8pacQl+ffATjjKGlq+xaA4wTUeW+/spwOf7Yr5Ryyvan8eNlYM8wy6h5SLfznl1rlFpjYC8w=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.15", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Uk59C7wsK20wpdr277yx7Xz7TqG5jGqlZUpSW3wDH/7a2K2iBg0lXc2wskHuCXLRXMhXpPZtb4a3SOpPENkkbg=="], + + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.40.0", "", { "os": "android", "cpu": "arm" }, "sha512-S6zd5r1w/HmqR8t0CTnGjFTBLDq2QKORPwriCHxo4xFNuhmOTABGjPaNvCJJVnrKBLsohOeiDX3YqQfJPF+FXw=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.40.0", "", { "os": "android", "cpu": "arm64" }, "sha512-/mbS9UUP/5Vbl2D6osIdcYiP0oie63LKMoTyGj5hyMCK/SFkl3EhtyRAfdjPvuvHC0SXdW6ePaTKkBSq1SNcIw=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.40.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wRt8fRdfLiEhnRMBonlIbKrJWixoEmn6KCjKE9PElnrSDSXETGZfPb8ee+nQNTobXkCVvVLytp2o0obAsxl78Q=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.40.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fzowhqbOE/NRy+AE5ob0+Y4X243WbWzDb00W+pKwD7d9tOqsAFbtWUwIyqqCoCLxj791m2xXIEeLH/3uz7zCCg=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.40.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-agZ9ITaqdBjcerRRFEHB8s0OyVcQW8F9ZxsszjxzeSthQ4fcN2MuOtQFWec1ed8/lDa50jSLHVE2/xPmTgtCfQ=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.40.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ZM2oQ47p28TP1DVIp7HL1QoMUgqlBFHey0ksHct7tMXoU5BqjNvPWw7888azzMt25lnyPODVuye1wvNbvVUFOA=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.40.0", "", { "os": "linux", "cpu": "arm" }, "sha512-RBFPAxRAIsMisKM47Oe6Lwdv6agZYLz02CUhVCD1sOv5ajAcRMrnwCFBPWwGXpazToW2mjnZxFos8TuFjTU15A=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nb2XbQ+wV3W2jSIihXdPj7k83eOxeSgYP3N/SRXvQ6ZYPIk6Q86qEh5Gl/7OitX3bQoQrESqm1yMLvZV8/J7dA=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tGmWhLD/0YMotCdfezlT6tC/MJG/wKpo4vnQ3Cq+4eBk/BwNv7EmkD0VkD5F/dYkT3b8FNU01X2e8vvJuWoM1w=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.40.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-rVbFyM3e7YhkVnp0IVYjaSHfrBWcTRWb60LEcdNAJcE2mbhTpbqKufx0FrhWfoxOrW/+7UJonAOShoFFLigDqQ=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.40.0", "", { "os": "linux", "cpu": "none" }, "sha512-3ZqBw14JtWeEoLiioJcXSJz8RQyPE+3jLARnYM1HdPzZG4vk+Ua8CUupt2+d+vSAvMyaQBTN2dZK+kbBS/j5mA=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.40.0", "", { "os": "linux", "cpu": "none" }, "sha512-JJ4PPSdcbGBjPvb+O7xYm2FmAsKCyuEMYhqatBAHMp/6TA6rVlf9Z/sYPa4/3Bommb+8nndm15SPFRHEPU5qFA=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.40.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Kp0zNJoX9Ik77wUya2tpBY3W9f40VUoMQLWVaob5SgCrblH/t2xr/9B2bWHfs0WCefuGmqXcB+t0Lq77sbBmZw=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-7YTCNzleWTaQTqNGUNQ66qVjpoV6DjbCOea+RnpMBly2bpzrI/uu7Rr+2zcgRfNxyjXaFTVQKaRKjqVdeUfeVA=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-hWnSzJ0oegeOwfOEeejYXfBqmnRGHusgtHfCPzmvJvHTwy1s3Neo59UKc1CmpE3zxvrCzJoVHos0rr97GHMNPw=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.40.0", "", { "os": "none", "cpu": "arm64" }, "sha512-28sJC1lR4qtBJGzSRRbPnSW3GxU2+4YyQFE6rCmsUYqZ5XYH8jg0/w+CvEzQ8TuAQz5zLkcA25nFQGwoU0PT3Q=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.40.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-cDkRnyT0dqwF5oIX1Cv59HKCeZQFbWWdUpXa3uvnHFT2iwYSSZspkhgjXjU6iDp5pFPaAEAe9FIbMoTgkTmKPg=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.40.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-7rPemBJjqm5Gkv6ZRCPvK8lE6AqQ/2z31DRdWazyx2ZvaSgL7QGofHXHNouRpPvNsT9yxRNQJgigsWkc+0qg4w=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-/Zmj0yTYSvmha6TG1QnoLqVT7ZMRDqXvFXXBQpIjteEwx9qvUYMBH2xbiOFhDeMUJkGwC3D6fdKsFtaqUvkwNA=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.58.0", "", { "os": "android", "cpu": "arm" }, "sha512-1T7UN3SsWWxpWyWGn1cT3ASNJOo+pI3eUkmEl7HgtowapcV8kslYpFQcYn431VuxghXakPNlbjRwhqmR37PFOg=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.58.0", "", { "os": "android", "cpu": "arm64" }, "sha512-GryzujxuiRv2YFF7bRy8mKcxlbuAN+euVUtGJt9KKbLT8JBUIosamVhcthLh+VEr6KE6cjeVMAQxKAzJcoN7dg=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.58.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7/bRSJIwl4GxeZL9rPZ11anNTyUO9epZrfEJH/ZMla3+/gbQ6xZixh9nOhsZ0QwsTW7/5J2A/fHbD1udC5DQQA=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.58.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-EqdtJSiHweS2vfILNrpyJ6HUwpEq2g7+4Zx1FPi4hu3Hu7tC3znF6ufbXO8Ub2LD4mGgznjI7kSdku9NDD1Mkg=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.58.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-VQt5TH4M42mY20F545G637RKxV/yjwVtKk2vfXuazfReSIiuvWBnv+FVSvIV5fKVTJNjt3GSJibh6JecbhGdBw=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.58.0", "", { "os": "linux", "cpu": "arm" }, "sha512-fBYcj4ucwpAtjJT3oeBdFBYKvNyjRSK+cyuvBOTQjh0jvKp4yeA4S/D0IsCHus/VPaNG5L48qQkh+Vjy3HL2/Q=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.58.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0BeuFfwlUHlJ1xpEdSD1YO3vByEFGPg36uLjK1JgFaxFb4W6w17F8ET8sz5cheZ4+x5f2xzdnRrrWv83E3Yd8g=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.58.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-TXlZgnPTlxrQzxG9ZXU7BNwx1Ilrr17P3GwZY0If2EzrinqRH3zXPc3HrRcBJgcsoZNMuNL5YivtkJYgp467UQ=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.58.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-zSoYRo5dxHLcUx93Stl2hW3hSNjPt99O70eRVWt5A1zwJ+FPjeCCANCD2a9R4JbHsdcl11TIQOjyigcRVOH2mw=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.58.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NQ0U/lqxH2/VxBYeAIvMNUK1y0a1bJ3ZicqkF2c6wfakbEciP9jvIE4yNzCFpZaqeIeRYaV7AVGqEO1yrfVPjA=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.58.0", "", { "os": "linux", "cpu": "none" }, "sha512-X9J+kr3gIC9FT8GuZt0ekzpNUtkBVzMVU4KiKDSlocyQuEgi3gBbXYN8UkQiV77FTusLDPsovjo95YedHr+3yg=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.58.0", "", { "os": "linux", "cpu": "none" }, "sha512-CDze3pi1OO3Wvb/QsXjmLEY4XPKGM6kIo82ssNOgmcl1IdndF9VSGAE38YLhADWmOac7fjqhBw82LozuUVxD0Q=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.58.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-b/89glbxFaEAcA6Uf1FvCNecBJEgcUTsV1quzrqXM/o4R1M4u+2KCVuyGCayN2UpsRWtGGLb+Ver0tBBpxaPog=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.58.0", "", { "os": "linux", "cpu": "x64" }, "sha512-0/yYpkq9VJFCEcuRlrViGj8pJUFFvNS4EkEREaN7CB1EcLXJIaVSSa5eCihwBGXtOZxhnblWgxks9juRdNQI7w=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.58.0", "", { "os": "linux", "cpu": "x64" }, "sha512-hr6FNvmcAXiH+JxSvaJ4SJ1HofkdqEElXICW9sm3/Rd5eC3t7kzvmLyRAB3NngKO2wzXRCAm4Z/mGWfrsS4X8w=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.58.0", "", { "os": "none", "cpu": "arm64" }, "sha512-R+O368VXgRql1K6Xar+FEo7NEwfo13EibPMoTv3sesYQedRXd6m30Dh/7lZMxnrQVFfeo4EOfYIP4FpcgWQNHg=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.58.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q0FZiAY/3c4YRj4z3h9K1PgaByrifrfbBoODSeX7gy97UtB7pySPUQfC2B/GbxWU6k7CzQrRy5gME10PltLAFQ=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.58.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-Y8FKBABrSPp9H0QkRLHDHOSUgM/309a3IvOVgPcVxYcX70wxJrk608CuTg7w+C6vEd724X5wJoNkBcGYfH7nNQ=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.58.0", "", { "os": "win32", "cpu": "x64" }, "sha512-bCn5rbiz5My+Bj7M09sDcnqW0QJyINRVxdZ65x1/Y2tGrMwherwK/lpk+HRQCKvXa8pcaQdF5KY5j54VGZLwNg=="], + + "@posthog/core": ["@posthog/core@1.24.6", "", {}, "sha512-9WkcRKqmXSWIJcca6m3VwA9YbFd4HiG2hKEtDq6FcwEHlvfDhQQUZ5/sJZ47Fw8OtyNMHQ6rW4+COttk4Bg5NQ=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@22.19.17", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q=="], + + "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], + + "@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], + + "@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="], + + "@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="], + + "@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], + + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "oxfmt": ["oxfmt@0.40.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.40.0", "@oxfmt/binding-android-arm64": "0.40.0", "@oxfmt/binding-darwin-arm64": "0.40.0", "@oxfmt/binding-darwin-x64": "0.40.0", "@oxfmt/binding-freebsd-x64": "0.40.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.40.0", "@oxfmt/binding-linux-arm-musleabihf": "0.40.0", "@oxfmt/binding-linux-arm64-gnu": "0.40.0", "@oxfmt/binding-linux-arm64-musl": "0.40.0", "@oxfmt/binding-linux-ppc64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-gnu": "0.40.0", "@oxfmt/binding-linux-riscv64-musl": "0.40.0", "@oxfmt/binding-linux-s390x-gnu": "0.40.0", "@oxfmt/binding-linux-x64-gnu": "0.40.0", "@oxfmt/binding-linux-x64-musl": "0.40.0", "@oxfmt/binding-openharmony-arm64": "0.40.0", "@oxfmt/binding-win32-arm64-msvc": "0.40.0", "@oxfmt/binding-win32-ia32-msvc": "0.40.0", "@oxfmt/binding-win32-x64-msvc": "0.40.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-g0C3I7xUj4b4DcagevM9kgH6+pUHytikxUcn3/VUkvzTNaaXBeyZqb7IBsHwojeXm4mTBEC/aBjBTMVUkZwWUQ=="], + + "oxlint": ["oxlint@1.58.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.58.0", "@oxlint/binding-android-arm64": "1.58.0", "@oxlint/binding-darwin-arm64": "1.58.0", "@oxlint/binding-darwin-x64": "1.58.0", "@oxlint/binding-freebsd-x64": "1.58.0", "@oxlint/binding-linux-arm-gnueabihf": "1.58.0", "@oxlint/binding-linux-arm-musleabihf": "1.58.0", "@oxlint/binding-linux-arm64-gnu": "1.58.0", "@oxlint/binding-linux-arm64-musl": "1.58.0", "@oxlint/binding-linux-ppc64-gnu": "1.58.0", "@oxlint/binding-linux-riscv64-gnu": "1.58.0", "@oxlint/binding-linux-riscv64-musl": "1.58.0", "@oxlint/binding-linux-s390x-gnu": "1.58.0", "@oxlint/binding-linux-x64-gnu": "1.58.0", "@oxlint/binding-linux-x64-musl": "1.58.0", "@oxlint/binding-openharmony-arm64": "1.58.0", "@oxlint/binding-win32-arm64-msvc": "1.58.0", "@oxlint/binding-win32-ia32-msvc": "1.58.0", "@oxlint/binding-win32-x64-msvc": "1.58.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-t4s9leczDMqlvOSjnbCQe7gtoLkWgBGZ7sBdCJ9EOj5IXFSG/X7OAzK4yuH4iW+4cAYe8kLFbC8tuYMwWZm+Cg=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "posthog-node": ["posthog-node@5.28.11", "", { "dependencies": { "@posthog/core": "1.24.6" }, "peerDependencies": { "rxjs": "^7.0.0" }, "optionalPeers": ["rxjs"] }, "sha512-H4FOiqKUBO8SVXyXlU5tyifeS11hyTGVwBirFPR5rPtw8X6OFs5xVLx38YL7ZBLjaa9u8is+nIWXKBwWsZ2vlw=="], + + "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.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" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + + "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + + "vitest/tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ac628e4 --- /dev/null +++ b/package.json @@ -0,0 +1,62 @@ +{ + "name": "opencode-posthog", + "version": "0.0.1", + "description": "PostHog LLM Analytics plugin for OpenCode", + "author": "Nejc Drobnič ", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "keywords": [ + "opencode-plugin", + "posthog", + "ai", + "llm", + "observability", + "tracing", + "analytics" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/Quantumlyy/opencode-posthog.git" + }, + "bugs": { + "url": "https://github.com/Quantumlyy/opencode-posthog/issues" + }, + "homepage": "https://github.com/Quantumlyy/opencode-posthog#readme", + "scripts": { + "build": "bun build src/index.ts --outdir dist --target node", + "prepublishOnly": "bun run build", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "lint": "oxlint src/ && oxfmt --check .", + "lint:fix": "oxlint --fix src/ && oxfmt --write ." + }, + "dependencies": { + "posthog-node": "^5.0.0" + }, + "peerDependencies": { + "@opencode-ai/plugin": "*" + }, + "devDependencies": { + "@opencode-ai/plugin": "*", + "@opencode-ai/sdk": "*", + "@types/bun": "^1.3.0", + "oxfmt": "^0.40.0", + "oxlint": "^1.55.0", + "typescript": "^5.8.0", + "vitest": "^3.0.0" + } +} diff --git a/src/events.test.ts b/src/events.test.ts new file mode 100644 index 0000000..ee88adf --- /dev/null +++ b/src/events.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect } from 'vitest' +import { buildAiGeneration, buildAiSpan, buildAiTrace, mapStopReason } from './events.js' +import type { PluginConfig, TraceState, AssistantInfo } from './types.js' +import type { StepFinishPart, ToolStateCompleted, ToolStateError } from '@opencode-ai/sdk' + +const defaultConfig: PluginConfig = { + apiKey: 'test-key', + host: 'https://us.i.posthog.com', + privacyMode: false, + enabled: true, + distinctId: 'test-host', + projectName: 'my-project', + tags: {}, + maxAttributeLength: 12000, +} + +const privacyConfig: PluginConfig = { + ...defaultConfig, + privacyMode: true, +} + +const configWithTags: PluginConfig = { + ...defaultConfig, + tags: { team: 'platform', env: 'staging' }, +} + +function makeStepFinish(overrides?: Partial): StepFinishPart { + return { + id: 'part-1', + sessionID: 'session-1', + messageID: 'msg-1', + type: 'step-finish', + reason: 'stop', + cost: 0.003, + tokens: { + input: 100, + output: 50, + reasoning: 10, + cache: { read: 5, write: 3 }, + }, + ...overrides, + } +} + +function makeTrace(overrides?: Partial): TraceState { + return { + traceId: 'trace-123', + sessionId: 'session-1', + startTime: Date.now() - 5000, + totalInputTokens: 500, + totalOutputTokens: 200, + totalCost: 0.01, + hadError: false, + agentName: 'my-project', + currentGenerationSpanId: 'gen-span-1', + userPrompt: 'Hello', + lastAssistantText: 'Hi there!', + stepInputMessages: [{ role: 'user', content: 'Hello' }], + stepInputSnapshot: [{ role: 'user', content: 'Hello' }], + stepAssistantText: 'Hi there!', + messageIds: new Set(), + ...overrides, + } +} + +function makeAssistantInfo(overrides?: Partial): AssistantInfo { + return { + messageID: 'msg-1', + modelID: 'claude-sonnet-4-20250514', + providerID: 'anthropic', + ...overrides, + } +} + +describe('mapStopReason', () => { + it('maps known stop reasons', () => { + expect(mapStopReason('stop')).toBe('stop') + expect(mapStopReason('length')).toBe('length') + expect(mapStopReason('tool-calls')).toBe('tool_calls') + expect(mapStopReason('error')).toBe('error') + }) + + it('returns null for undefined', () => { + expect(mapStopReason(undefined)).toBeNull() + }) + + it('passes through unknown reasons', () => { + expect(mapStopReason('custom_reason')).toBe('custom_reason') + }) +}) + +describe('buildAiGeneration', () => { + it('builds generation event with all fields', () => { + const part = makeStepFinish() + const trace = makeTrace() + const assistant = makeAssistantInfo() + + const result = buildAiGeneration(part, assistant, trace, defaultConfig) + + expect(result.event).toBe('$ai_generation') + expect(result.distinctId).toBe('test-host') + expect(result.properties.$ai_model).toBe('claude-sonnet-4-20250514') + expect(result.properties.$ai_provider).toBe('anthropic') + expect(result.properties.$ai_input_tokens).toBe(100) + expect(result.properties.$ai_output_tokens).toBe(50) + expect(result.properties.$ai_reasoning_tokens).toBe(10) + expect(result.properties.cache_read_input_tokens).toBe(5) + expect(result.properties.cache_creation_input_tokens).toBe(3) + expect(result.properties.$ai_total_cost_usd).toBe(0.003) + expect(result.properties.$ai_stop_reason).toBe('stop') + expect(result.properties.$ai_is_error).toBe(false) + expect(result.properties.$ai_trace_id).toBe('trace-123') + expect(result.properties.$ai_span_id).toBe('gen-span-1') + expect(result.properties.$ai_session_id).toBe('session-1') + expect(result.properties.$ai_lib).toBe('opencode-posthog') + expect(result.properties.$ai_framework).toBe('opencode') + expect(result.properties.$ai_project_name).toBe('my-project') + expect(result.properties.$ai_agent_name).toBe('my-project') + }) + + it('uses pre-allocated span ID from trace', () => { + const trace = makeTrace({ currentGenerationSpanId: 'pre-allocated-id' }) + const result = buildAiGeneration(makeStepFinish(), makeAssistantInfo(), trace, defaultConfig) + expect(result.properties.$ai_span_id).toBe('pre-allocated-id') + }) + + it('falls back to random UUID when no pre-allocated span ID', () => { + const trace = makeTrace({ currentGenerationSpanId: undefined }) + const result = buildAiGeneration(makeStepFinish(), makeAssistantInfo(), trace, defaultConfig) + expect(result.properties.$ai_span_id).toBeDefined() + expect(typeof result.properties.$ai_span_id).toBe('string') + }) + + it('includes input and output content from step snapshot', () => { + const trace = makeTrace({ + userPrompt: 'What is 2+2?', + stepInputSnapshot: [{ role: 'user', content: 'What is 2+2?' }], + stepAssistantText: '4', + lastAssistantText: '4', + }) + const result = buildAiGeneration(makeStepFinish(), makeAssistantInfo(), trace, defaultConfig) + expect(result.properties.$ai_input).toEqual([{ role: 'user', content: 'What is 2+2?' }]) + expect(result.properties.$ai_output_choices).toEqual([{ role: 'assistant', content: '4' }]) + }) + + it('includes prior tool results in input for multi-step generations', () => { + const trace = makeTrace({ + // Snapshot was taken at step-start, before current-step tools ran + stepInputSnapshot: [ + { role: 'user', content: 'Read the file' }, + { role: 'tool', content: '[read] file contents here' }, + ], + stepAssistantText: 'I read the file.', + }) + const result = buildAiGeneration(makeStepFinish(), makeAssistantInfo(), trace, defaultConfig) + expect(result.properties.$ai_input).toEqual([ + { role: 'user', content: 'Read the file' }, + { role: 'tool', content: '[read] file contents here' }, + ]) + expect(result.properties.$ai_output_choices).toEqual([{ role: 'assistant', content: 'I read the file.' }]) + }) + + it('redacts content in privacy mode', () => { + const trace = makeTrace() + const result = buildAiGeneration(makeStepFinish(), makeAssistantInfo(), trace, privacyConfig) + expect(result.properties.$ai_input).toBeNull() + expect(result.properties.$ai_output_choices).toBeNull() + }) + + it('marks error generations', () => { + const assistant = makeAssistantInfo({ + error: { name: 'UnknownError', data: { message: 'Rate limited' } }, + }) + const result = buildAiGeneration(makeStepFinish(), assistant, makeTrace(), defaultConfig) + expect(result.properties.$ai_is_error).toBe(true) + expect(result.properties.$ai_error).toContain('Rate limited') + }) + + it('falls back to unknown model when no assistant info', () => { + const result = buildAiGeneration(makeStepFinish(), undefined, makeTrace(), defaultConfig) + expect(result.properties.$ai_model).toBe('unknown') + expect(result.properties.$ai_provider).toBe('unknown') + }) + + it('includes custom tags', () => { + const result = buildAiGeneration(makeStepFinish(), makeAssistantInfo(), makeTrace(), configWithTags) + expect(result.properties.team).toBe('platform') + expect(result.properties.env).toBe('staging') + }) +}) + +describe('buildAiSpan', () => { + const completedState: ToolStateCompleted = { + status: 'completed', + input: { command: 'ls -la' }, + output: 'file1.txt\nfile2.txt', + title: 'bash', + metadata: {}, + time: { start: 1000, end: 1250 }, + } + + const errorState: ToolStateError = { + status: 'error', + input: { command: 'bad-cmd' }, + error: 'command not found', + time: { start: 1000, end: 1050 }, + } + + it('builds span event for completed tool', () => { + const trace = makeTrace() + const result = buildAiSpan('bash', completedState, trace, defaultConfig) + + expect(result.event).toBe('$ai_span') + expect(result.distinctId).toBe('test-host') + expect(result.properties.$ai_trace_id).toBe('trace-123') + expect(result.properties.$ai_parent_id).toBe('gen-span-1') + expect(result.properties.$ai_span_name).toBe('bash') + expect(result.properties.$ai_latency).toBe(0.25) + expect(result.properties.$ai_is_error).toBe(false) + expect(result.properties.$ai_error).toBeNull() + expect(result.properties.$ai_input_state).toBe('{"command":"ls -la"}') + expect(result.properties.$ai_output_state).toBe('file1.txt\nfile2.txt') + expect(result.properties.$ai_lib).toBe('opencode-posthog') + expect(result.properties.$ai_framework).toBe('opencode') + }) + + it('uses currentGenerationSpanId as parent', () => { + const trace = makeTrace({ currentGenerationSpanId: 'parent-gen-42' }) + const result = buildAiSpan('read', completedState, trace, defaultConfig) + expect(result.properties.$ai_parent_id).toBe('parent-gen-42') + }) + + it('sets parent to null when no generation span ID', () => { + const trace = makeTrace({ currentGenerationSpanId: undefined }) + const result = buildAiSpan('read', completedState, trace, defaultConfig) + expect(result.properties.$ai_parent_id).toBeNull() + }) + + it('redacts tool input/output in privacy mode', () => { + const result = buildAiSpan('read', completedState, makeTrace(), privacyConfig) + expect(result.properties.$ai_input_state).toBeNull() + expect(result.properties.$ai_output_state).toBeNull() + }) + + it('redacts sensitive keys in tool input', () => { + const stateWithSecrets: ToolStateCompleted = { + ...completedState, + input: { + command: 'curl', + api_key: 'sk-secret-123', + headers: { authorization: 'Bearer tok' }, + }, + } + const result = buildAiSpan('bash', stateWithSecrets, makeTrace(), defaultConfig) + const inputState = result.properties.$ai_input_state as string + expect(inputState).toContain('[REDACTED]') + expect(inputState).not.toContain('sk-secret-123') + expect(inputState).not.toContain('Bearer tok') + expect(inputState).toContain('curl') + }) + + it('captures error info', () => { + const result = buildAiSpan('bash', errorState, makeTrace(), defaultConfig) + expect(result.properties.$ai_is_error).toBe(true) + expect(result.properties.$ai_error).toBe('command not found') + expect(result.properties.$ai_latency).toBe(0.05) + }) + + it('includes custom tags', () => { + const result = buildAiSpan('read', completedState, makeTrace(), configWithTags) + expect(result.properties.team).toBe('platform') + expect(result.properties.env).toBe('staging') + }) +}) + +describe('buildAiTrace', () => { + it('builds trace event with accumulated totals', () => { + const trace = makeTrace() + const result = buildAiTrace(trace, defaultConfig) + + expect(result.event).toBe('$ai_trace') + expect(result.distinctId).toBe('test-host') + expect(result.properties.$ai_trace_id).toBe('trace-123') + expect(result.properties.$ai_session_id).toBe('session-1') + expect(result.properties.$ai_latency).toBeGreaterThan(0) + expect(result.properties.$ai_total_input_tokens).toBe(500) + expect(result.properties.$ai_total_output_tokens).toBe(200) + expect(result.properties.$ai_is_error).toBe(false) + expect(result.properties.$ai_span_name).toBe('my-project') + expect(result.properties.$ai_lib).toBe('opencode-posthog') + expect(result.properties.$ai_framework).toBe('opencode') + }) + + it('includes user prompt and assistant text', () => { + const trace = makeTrace({ + userPrompt: 'Explain X', + lastAssistantText: 'X is...', + }) + const result = buildAiTrace(trace, defaultConfig) + expect(result.properties.$ai_input_state).toBe('Explain X') + expect(result.properties.$ai_output_state).toBe('X is...') + }) + + it('redacts content in privacy mode', () => { + const trace = makeTrace({ + userPrompt: 'secret prompt', + lastAssistantText: 'secret response', + }) + const result = buildAiTrace(trace, privacyConfig) + expect(result.properties.$ai_input_state).toBeNull() + expect(result.properties.$ai_output_state).toBeNull() + // Metrics still flow + expect(result.properties.$ai_total_input_tokens).toBe(500) + expect(result.properties.$ai_total_output_tokens).toBe(200) + }) + + it('captures error traces', () => { + const trace = makeTrace({ + hadError: true, + lastError: 'Context overflow', + }) + const result = buildAiTrace(trace, defaultConfig) + expect(result.properties.$ai_is_error).toBe(true) + expect(result.properties.$ai_error).toBe('Context overflow') + }) + + it('includes custom tags', () => { + const result = buildAiTrace(makeTrace(), configWithTags) + expect(result.properties.team).toBe('platform') + expect(result.properties.env).toBe('staging') + }) +}) diff --git a/src/events.ts b/src/events.ts new file mode 100644 index 0000000..6d681bf --- /dev/null +++ b/src/events.ts @@ -0,0 +1,165 @@ +import { randomUUID } from 'node:crypto' +import type { StepFinishPart, ToolStateCompleted, ToolStateError } from '@opencode-ai/sdk' +import type { AssistantInfo, PluginConfig, TraceState } from './types.js' +import { redactForPrivacy, serializeAttribute, serializeError } from './utils.js' +import { VERSION } from './version.js' + +export interface CaptureEvent { + event: string + distinctId: string + properties: Record +} + +const STOP_REASON_MAP: Record = { + stop: 'stop', + length: 'length', + 'tool-calls': 'tool_calls', + error: 'error', +} + +export function mapStopReason(reason: string | undefined): string | null { + if (!reason) return null + return STOP_REASON_MAP[reason] ?? reason +} + +export function buildAiGeneration( + part: StepFinishPart, + assistantInfo: AssistantInfo | undefined, + trace: TraceState, + config: PluginConfig +): CaptureEvent { + // Use the span ID allocated at step-start so tool spans emitted + // during this step already reference the correct parent. + const spanId = trace.currentGenerationSpanId ?? randomUUID() + + // Use the snapshot taken at step-start, which contains context the model + // actually saw (user prompt + prior tool results), not current-step tools. + const inputMessages = redactForPrivacy( + trace.stepInputSnapshot.length > 0 ? trace.stepInputSnapshot : null, + config.privacyMode + ) + + const outputChoices = redactForPrivacy( + trace.stepAssistantText ? [{ role: 'assistant', content: trace.stepAssistantText }] : null, + config.privacyMode + ) + + return { + event: '$ai_generation', + distinctId: config.distinctId, + properties: { + $ai_trace_id: trace.traceId, + $ai_session_id: trace.sessionId, + $ai_span_id: spanId, + $ai_model: assistantInfo?.modelID ?? 'unknown', + $ai_provider: assistantInfo?.providerID ?? 'unknown', + + $ai_input_tokens: part.tokens.input, + $ai_output_tokens: part.tokens.output, + $ai_reasoning_tokens: part.tokens.reasoning, + cache_read_input_tokens: part.tokens.cache.read, + cache_creation_input_tokens: part.tokens.cache.write, + + $ai_total_cost_usd: part.cost, + $ai_stop_reason: mapStopReason(part.reason), + + $ai_input: inputMessages, + $ai_output_choices: outputChoices, + + $ai_is_error: !!assistantInfo?.error, + $ai_error: serializeError(assistantInfo?.error, config.maxAttributeLength), + + $ai_lib: 'opencode-posthog', + $ai_lib_version: VERSION, + $ai_framework: 'opencode', + $ai_project_name: config.projectName, + $ai_agent_name: trace.agentName ?? config.projectName, + ...config.tags, + }, + } +} + +export function buildAiSpan( + toolName: string, + toolState: ToolStateCompleted | ToolStateError, + trace: TraceState, + config: PluginConfig +): CaptureEvent { + const spanId = randomUUID() + const latency = (toolState.time.end - toolState.time.start) / 1000 + const isError = toolState.status === 'error' + + const inputState = redactForPrivacy( + serializeAttribute(toolState.input, config.maxAttributeLength), + config.privacyMode + ) + + let outputState: string | null = null + if (!config.privacyMode) { + if (toolState.status === 'completed') { + outputState = serializeAttribute(toolState.output, config.maxAttributeLength) + } else { + outputState = serializeAttribute(toolState.error, config.maxAttributeLength) + } + } + + return { + event: '$ai_span', + distinctId: config.distinctId, + properties: { + $ai_trace_id: trace.traceId, + $ai_session_id: trace.sessionId, + $ai_span_id: spanId, + $ai_parent_id: trace.currentGenerationSpanId ?? null, + $ai_span_name: toolName, + + $ai_latency: latency, + + $ai_input_state: inputState, + $ai_output_state: outputState, + + $ai_is_error: isError, + $ai_error: isError + ? serializeAttribute((toolState as ToolStateError).error, config.maxAttributeLength) + : null, + + $ai_lib: 'opencode-posthog', + $ai_lib_version: VERSION, + $ai_framework: 'opencode', + $ai_project_name: config.projectName, + $ai_agent_name: trace.agentName ?? config.projectName, + ...config.tags, + }, + } +} + +export function buildAiTrace(trace: TraceState, config: PluginConfig): CaptureEvent { + const latency = (Date.now() - trace.startTime) / 1000 + + return { + event: '$ai_trace', + distinctId: config.distinctId, + properties: { + $ai_trace_id: trace.traceId, + $ai_session_id: trace.sessionId, + $ai_latency: latency, + $ai_span_name: config.projectName, + + $ai_input_state: redactForPrivacy(trace.userPrompt ?? null, config.privacyMode), + $ai_output_state: redactForPrivacy(trace.lastAssistantText ?? null, config.privacyMode), + + $ai_total_input_tokens: trace.totalInputTokens, + $ai_total_output_tokens: trace.totalOutputTokens, + + $ai_is_error: trace.hadError, + $ai_error: trace.lastError ? serializeAttribute(trace.lastError, config.maxAttributeLength) : null, + + $ai_lib: 'opencode-posthog', + $ai_lib_version: VERSION, + $ai_framework: 'opencode', + $ai_project_name: config.projectName, + $ai_agent_name: trace.agentName ?? config.projectName, + ...config.tags, + }, + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..16e2dd5 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,270 @@ +import type { Plugin } from '@opencode-ai/plugin' +import type { + Event, + AssistantMessage, + StepStartPart, + StepFinishPart, + ToolPart, + TextPart, + ToolStateCompleted, + ToolStateError, +} from '@opencode-ai/sdk' +import { PostHog } from 'posthog-node' +import { randomUUID } from 'node:crypto' +import { loadConfig, serializeAttribute } from './utils.js' +import { buildAiGeneration, buildAiSpan, buildAiTrace } from './events.js' +import type { AssistantInfo, TraceState } from './types.js' +import type { CaptureEvent } from './events.js' + +export const PostHogPlugin: Plugin = async () => { + const config = loadConfig() + + if (!config.enabled || !config.apiKey) return {} + + const client = new PostHog(config.apiKey, { + host: config.host, + flushAt: 20, + flushInterval: 10_000, + }) + + function safeCapture(event: CaptureEvent) { + try { + client.capture({ + distinctId: event.distinctId, + event: event.event, + properties: event.properties, + }) + } catch { + // never crash the host + } + } + + // State: sessionID -> trace state + const traces = new Map() + // State: messageID -> role for correlating parts to messages + const messageRoles = new Map() + // State: messageID -> assistant info + const assistantMessages = new Map() + + function getOrCreateTrace(sessionId: string): TraceState { + let trace = traces.get(sessionId) + if (!trace) { + trace = { + traceId: randomUUID(), + sessionId, + startTime: Date.now(), + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + hadError: false, + stepInputMessages: [], + stepInputSnapshot: [], + messageIds: new Set(), + } + traces.set(sessionId, trace) + } + return trace + } + + function handleMessageUpdated(event: Event) { + if (event.type !== 'message.updated') return + const msg = event.properties.info + + if (msg.role === 'user') { + // New user message → new trace + const trace: TraceState = { + traceId: randomUUID(), + sessionId: msg.sessionID, + startTime: msg.time.created, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + hadError: false, + agentName: msg.agent, + stepInputMessages: [], + stepInputSnapshot: [], + messageIds: new Set([msg.id]), + } + traces.set(msg.sessionID, trace) + messageRoles.set(msg.id, 'user') + } else if (msg.role === 'assistant') { + const assistant = msg as AssistantMessage + messageRoles.set(assistant.id, 'assistant') + + const info: AssistantInfo = { + messageID: assistant.id, + modelID: assistant.modelID, + providerID: assistant.providerID, + error: assistant.error, + } + assistantMessages.set(assistant.id, info) + + // Update trace with current assistant info + const trace = getOrCreateTrace(assistant.sessionID) + trace.messageIds.add(assistant.id) + trace.currentAssistantMsg = info + if (assistant.error) { + trace.hadError = true + trace.lastError = serializeAttribute(assistant.error, config.maxAttributeLength) ?? assistant.error.name + } + } + } + + function handlePartUpdated(event: Event) { + if (event.type !== 'message.part.updated') return + const part = event.properties.part + + switch (part.type) { + case 'text': + handleTextPart(part) + break + case 'step-start': + handleStepStart(part) + break + case 'step-finish': + handleStepFinish(part) + break + case 'tool': + handleToolPart(part) + break + } + } + + function handleTextPart(part: TextPart) { + const role = messageRoles.get(part.messageID) + if (!role) return + + const trace = traces.get(part.sessionID) + if (!trace) return + + if (role === 'user') { + trace.userPrompt = part.text + trace.stepInputMessages.push({ role: 'user', content: part.text }) + } else if (role === 'assistant') { + trace.lastAssistantText = part.text + trace.stepAssistantText = part.text + } + } + + function handleStepStart(part: StepStartPart) { + const trace = traces.get(part.sessionID) + if (!trace) return + // Allocate the generation span ID eagerly so that tool spans + // emitted during this step can reference it as their parent. + trace.currentGenerationSpanId = randomUUID() + // Snapshot current input messages before tools run, so the + // generation reports only what the model saw as input, not + // the tool results from the current step. + trace.stepInputSnapshot = [...trace.stepInputMessages] + // Reset per-step assistant text for the new generation + trace.stepAssistantText = undefined + } + + function handleStepFinish(part: StepFinishPart) { + const trace = traces.get(part.sessionID) + if (!trace) return + + const assistantInfo = trace.currentAssistantMsg + + // Accumulate tokens and cost + trace.totalInputTokens += part.tokens.input + trace.totalOutputTokens += part.tokens.output + trace.totalCost += part.cost + + const generation = buildAiGeneration(part, assistantInfo, trace, config) + safeCapture(generation) + } + + function handleToolPart(part: ToolPart) { + if (part.state.status !== 'completed' && part.state.status !== 'error') return + + const trace = traces.get(part.sessionID) + if (!trace) return + + const toolState = part.state as ToolStateCompleted | ToolStateError + const span = buildAiSpan(part.tool, toolState, trace, config) + safeCapture(span) + + // Feed tool result into step input so subsequent generations include + // the tool context the model actually saw. Redact and truncate to + // match the treatment applied to $ai_span fields. + if (toolState.status === 'completed') { + const redacted = serializeAttribute(toolState.output, config.maxAttributeLength) ?? '' + trace.stepInputMessages.push({ + role: 'tool', + content: `[${part.tool}] ${redacted}`, + }) + } else { + const redacted = serializeAttribute(toolState.error, config.maxAttributeLength) ?? '' + trace.stepInputMessages.push({ + role: 'tool', + content: `[${part.tool}] ERROR: ${redacted}`, + }) + trace.hadError = true + trace.lastError = toolState.error + } + } + + async function handleSessionIdle(event: Event) { + if (event.type !== 'session.idle') return + + const sessionId = event.properties.sessionID + const trace = traces.get(sessionId) + if (!trace) return + + const traceEvent = buildAiTrace(trace, config) + safeCapture(traceEvent) + + try { + await client.flush() + } catch { + // ignore flush errors + } + + // Clean up per-message state for this trace + for (const msgId of trace.messageIds) { + messageRoles.delete(msgId) + assistantMessages.delete(msgId) + } + traces.delete(sessionId) + } + + function handleSessionError(event: Event) { + if (event.type !== 'session.error') return + + const sessionId = event.properties.sessionID + if (!sessionId) return + + const trace = traces.get(sessionId) + if (trace) { + trace.hadError = true + if (event.properties.error) { + trace.lastError = + serializeAttribute(event.properties.error, config.maxAttributeLength) ?? 'unknown error' + } + } + } + + return { + event: async ({ event }) => { + try { + switch (event.type) { + case 'message.updated': + handleMessageUpdated(event) + break + case 'message.part.updated': + handlePartUpdated(event) + break + case 'session.idle': + await handleSessionIdle(event) + break + case 'session.error': + handleSessionError(event) + break + } + } catch { + // never crash OpenCode + } + }, + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..0d9d611 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,46 @@ +export interface PluginConfig { + apiKey: string + host: string + privacyMode: boolean + enabled: boolean + distinctId: string + projectName: string + tags: Record + maxAttributeLength: number +} + +export interface InputMessage { + role: string + content: string +} + +export interface TraceState { + traceId: string + sessionId: string + startTime: number + totalInputTokens: number + totalOutputTokens: number + totalCost: number + hadError: boolean + lastError?: string + userPrompt?: string + lastAssistantText?: string + /** Accumulated input context across steps (user prompt + tool results from prior steps). */ + stepInputMessages: InputMessage[] + /** Snapshot of stepInputMessages taken at step-start, used as $ai_input for the generation. */ + stepInputSnapshot: InputMessage[] + /** Assistant text accumulated during the current step, reset on each step-start. */ + stepAssistantText?: string + currentAssistantMsg?: AssistantInfo + currentGenerationSpanId?: string + agentName?: string + /** Message IDs belonging to this trace, for cleanup. */ + messageIds: Set +} + +export interface AssistantInfo { + messageID: string + modelID: string + providerID: string + error?: { name: string; data?: Record } +} diff --git a/src/utils.test.ts b/src/utils.test.ts new file mode 100644 index 0000000..830ff30 --- /dev/null +++ b/src/utils.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect } from 'vitest' +import { redactForPrivacy, serializeAttribute, serializeError } from './utils.js' + +describe('redactForPrivacy', () => { + it('returns value when privacy mode is off', () => { + expect(redactForPrivacy('hello', false)).toBe('hello') + expect(redactForPrivacy({ key: 'val' }, false)).toEqual({ key: 'val' }) + }) + + it('returns null when privacy mode is on', () => { + expect(redactForPrivacy('hello', true)).toBeNull() + expect(redactForPrivacy({ key: 'val' }, true)).toBeNull() + }) +}) + +describe('serializeAttribute', () => { + it('serializes simple values', () => { + expect(serializeAttribute({ a: 1 }, 1000)).toBe('{"a":1}') + expect(serializeAttribute('hello', 1000)).toBe('hello') + }) + + it('redacts sensitive keys in objects', () => { + const input = { + command: 'curl', + api_key: 'sk-secret-123', + apiKey: 'another-secret', + token: 'my-token', + password: 'pass123', + authorization: 'Bearer xyz', + normal_field: 'visible', + } + const result = serializeAttribute(input, 10000) + expect(result).toContain('[REDACTED]') + expect(result).not.toContain('sk-secret-123') + expect(result).not.toContain('another-secret') + expect(result).not.toContain('my-token') + expect(result).not.toContain('pass123') + expect(result).not.toContain('Bearer xyz') + expect(result).toContain('curl') + expect(result).toContain('visible') + }) + + it('redacts nested sensitive keys', () => { + const input = { + headers: { Authorization: 'Bearer secret' }, + config: { api_key: 'hidden' }, + } + const result = serializeAttribute(input, 10000) + expect(result).not.toContain('secret') + expect(result).not.toContain('hidden') + }) + + it('redacts sensitive values embedded in strings', () => { + const jsonStr = '{"api_key":"sk-secret-123","name":"test"}' + const result = serializeAttribute(jsonStr, 10000) + expect(result).not.toContain('sk-secret-123') + expect(result).toContain('[REDACTED]') + }) + + it('redacts key=value patterns in strings', () => { + const cmdOutput = 'config loaded: password=hunter2 host=localhost' + const result = serializeAttribute(cmdOutput, 10000) + expect(result).not.toContain('hunter2') + expect(result).toContain('[REDACTED]') + }) + + it('redacts multi-word bearer token values', () => { + const header = 'Authorization: Bearer secret-token' + const result = serializeAttribute(header, 10000) + expect(result).not.toContain('secret-token') + expect(result).not.toContain('Bearer') + expect(result).toContain('[REDACTED]') + }) + + it('redacts header-style secrets with colons in tool output', () => { + const output = 'HTTP/1.1 200 OK\nAuthorization: Bearer sk-abc123\nContent-Type: text/plain' + const result = serializeAttribute(output, 10000) + expect(result).not.toContain('sk-abc123') + expect(result).toContain('[REDACTED]') + expect(result).toContain('Content-Type') + }) + + it('truncates long output', () => { + const longStr = 'a'.repeat(200) + const result = serializeAttribute(longStr, 50) + expect(result).not.toBeNull() + expect(result!.length).toBeLessThan(200) + expect(result).toContain('...[truncated') + }) + + it('handles circular references', () => { + const obj: Record = { name: 'test' } + obj.self = obj + const result = serializeAttribute(obj, 1000) + expect(result).toContain('[Circular]') + expect(result).toContain('test') + }) + + it('handles deep nesting', () => { + let obj: Record = { value: 'deep' } + for (let i = 0; i < 20; i++) { + obj = { nested: obj } + } + const result = serializeAttribute(obj, 10000) + expect(result).toContain('[DepthLimit]') + }) + + it('returns null for undefined and null', () => { + expect(serializeAttribute(undefined, 1000)).toBeNull() + expect(serializeAttribute(null, 1000)).toBeNull() + }) +}) + +describe('serializeError', () => { + it('serializes error objects to JSON', () => { + const error = { name: 'UnknownError', data: { message: 'boom' } } + const result = serializeError(error) + expect(result).toBe('{"name":"UnknownError","data":{"message":"boom"}}') + }) + + it('returns null for undefined', () => { + expect(serializeError(undefined)).toBeNull() + }) + + it('handles circular references via redaction', () => { + const circular: Record = { name: 'BadError' } + circular.self = circular + const result = serializeError(circular as { name: string; data?: Record }) + expect(result).toContain('BadError') + expect(result).toContain('[Circular]') + }) + + it('redacts sensitive keys in error data', () => { + const error = { name: 'AuthError', data: { api_key: 'sk-secret-123', message: 'failed' } } + const result = serializeError(error) + expect(result).not.toContain('sk-secret-123') + expect(result).toContain('[REDACTED]') + expect(result).toContain('failed') + }) +}) diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..0065ff4 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,127 @@ +import { hostname } from 'node:os' +import { basename } from 'node:path' +import type { PluginConfig } from './types.js' + +export function loadConfig(): PluginConfig { + const tags: Record = {} + const tagsEnv = process.env.POSTHOG_TAGS + if (tagsEnv) { + for (const pair of tagsEnv.split(',')) { + const colonIdx = pair.indexOf(':') + if (colonIdx > 0) { + const key = pair.slice(0, colonIdx).trim() + const val = pair.slice(colonIdx + 1).trim() + if (key.length > 0 && val.length > 0) { + tags[key] = val + } + } + } + } + + let distinctId = process.env.POSTHOG_DISTINCT_ID + if (!distinctId) { + try { + distinctId = hostname() + } catch { + distinctId = 'opencode-user' + } + } + + return { + apiKey: process.env.POSTHOG_API_KEY ?? '', + host: process.env.POSTHOG_HOST ?? 'https://us.i.posthog.com', + privacyMode: process.env.POSTHOG_PRIVACY_MODE === 'true', + enabled: process.env.POSTHOG_ENABLED !== 'false', + distinctId, + projectName: process.env.POSTHOG_PROJECT_NAME || basename(process.cwd()) || 'opencode-project', + tags, + maxAttributeLength: parseInt(process.env.POSTHOG_MAX_ATTRIBUTE_LENGTH ?? '12000', 10) || 12000, + } +} + +const SENSITIVE_KEY_PATTERN = /api[-_]?key|token|secret|password|authorization|credential|private[-_]?key/i + +/** + * Patterns to detect sensitive values embedded in strings. + * + * JSON-style: `"api_key": "sk-secret-123"` or `"token":"abc"` + * Matches the full `"key":"value"` including the quoted value. + */ +const SENSITIVE_JSON_PATTERN = + /"(?:api[-_]?key|token|secret|password|authorization|credential|private[-_]?key)"\s*:\s*"[^"]*"/gi + +/** + * Header/env-style: `Authorization: Bearer secret-token` or `password=hunter2` + * Matches the key and everything to the next comma, semicolon, newline, or + * end-of-string so multi-word values like `Bearer xyz` are fully consumed. + */ +const SENSITIVE_KV_PATTERN = + /(?:api[-_]?key|token|secret|password|authorization|credential|private[-_]?key)\s*[=:]\s*[^\n,;]*/gi + +/** + * Redact sensitive values found inline in a string. Handles both JSON-like + * `"key":"value"` patterns and `key=value` / `key: value` patterns. + */ +function redactStringValues(str: string): string { + return str.replace(SENSITIVE_JSON_PATTERN, '[REDACTED]').replace(SENSITIVE_KV_PATTERN, '[REDACTED]') +} + +function redactSensitive(value: unknown, seen: WeakSet, depth: number): unknown { + if (depth > 8) return '[DepthLimit]' + if (value === null || value === undefined) return value + if (typeof value === 'string') return redactStringValues(value) + if (typeof value !== 'object') return value + if (seen.has(value)) return '[Circular]' + seen.add(value) + + if (Array.isArray(value)) { + return value.map((item) => redactSensitive(item, seen, depth + 1)) + } + + const output: Record = {} + for (const [key, nested] of Object.entries(value)) { + if (SENSITIVE_KEY_PATTERN.test(key)) { + output[key] = '[REDACTED]' + } else { + output[key] = redactSensitive(nested, seen, depth + 1) + } + } + return output +} + +function truncate(value: string, maxLength: number): string { + if (maxLength <= 0) return '' + if (value.length <= maxLength) return value + const omitted = value.length - maxLength + return `${value.slice(0, maxLength)}...[truncated ${omitted} chars]` +} + +export function serializeAttribute(value: unknown, maxLength: number): string | null { + if (value === undefined || value === null) return null + + const redacted = redactSensitive(value, new WeakSet(), 0) + + if (typeof redacted === 'string') { + return truncate(redacted, maxLength) + } + + try { + const json = JSON.stringify(redacted) + if (json === undefined) return null + return truncate(json, maxLength) + } catch { + return '[Unserializable]' + } +} + +export function redactForPrivacy(value: T, privacyMode: boolean): T | null { + return privacyMode ? null : value +} + +export function serializeError( + error: { name: string; data?: Record } | undefined, + maxLength: number = 12000 +): string | null { + if (!error) return null + return serializeAttribute(error, maxLength) +} diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..3c6253f --- /dev/null +++ b/src/version.ts @@ -0,0 +1,3 @@ +import pkg from '../package.json' + +export const VERSION: string = pkg.version diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a65b2ab --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "declaration": true, + "declarationDir": "./dist", + "outDir": "./dist", + "rootDir": "./src", + "isolatedModules": true, + "resolveJsonModule": true, + "types": ["bun-types"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +}