diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3aeef82d62f..c6dfb0ebda4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,3 @@ # web + desktop packages -packages/app/ @adamdotdevin -packages/tauri/ @adamdotdevin -packages/desktop/src-tauri/ @brendonovich -packages/desktop/ @adamdotdevin +packages/app/ @Hona @Brendonovich +packages/desktop/ @Hona @Brendonovich diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f74216cb33..4f07f6b4cf9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,14 +3,14 @@ name: test on: push: branches: - - main + - main # kilocode_change pull_request: workflow_dispatch: concurrency: - # Keep every run on main so cancelled checks do not pollute the default branch + # Keep every run on main so cancelled checks do not pollute the default branch # kilocode_change # commit history. PRs and other branches still share a group and cancel stale runs. - group: ${{ case(github.ref == 'refs/heads/main', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }} + group: ${{ case(github.ref == 'refs/heads/main', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }} # kilocode_change cancel-in-progress: true permissions: @@ -65,26 +65,31 @@ jobs: fi echo 'general=true' >> "$GITHUB_OUTPUT" echo 'settings=[{"os":"linux","index":1,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":true},{"os":"linux","index":2,"total":2,"host":"blacksmith-4vcpu-ubuntu-2404","run":true,"packages":false},{"os":"macos","index":1,"total":1,"host":"macos-15","run":true,"packages":true},{"os":"windows","index":1,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":true},{"os":"windows","index":2,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":3,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false},{"os":"windows","index":4,"total":4,"host":"blacksmith-4vcpu-windows-2025","run":true,"packages":false}]' >> "$GITHUB_OUTPUT" + # kilocode_change end unit: + # kilocode_change start name: ${{ !matrix.settings.run && 'unit (unchanged)' || matrix.settings.total > 1 && format('unit ({0}, {1}/{2})', matrix.settings.os, matrix.settings.index, matrix.settings.total) || format('unit ({0})', matrix.settings.os) }} needs: changes - strategy: # kilocode_change + # kilocode_change end + strategy: fail-fast: false matrix: - settings: ${{ fromJSON(needs.changes.outputs.settings) }} + settings: ${{ fromJSON(needs.changes.outputs.settings) }} # kilocode_change runs-on: ${{ matrix.settings.host }} timeout-minutes: 45 # kilocode_change defaults: run: shell: bash steps: + # kilocode_change start - name: Skip unchanged general unit tests if: ${{ !matrix.settings.run }} run: echo "Only isolated product, documentation, or metadata files changed; general unit tests are unchanged." + # kilocode_change end - name: Checkout repository - if: matrix.settings.run + if: matrix.settings.run # kilocode_change uses: actions/checkout@v6 # kilocode_change with: token: ${{ secrets.GITHUB_TOKEN }} @@ -94,10 +99,12 @@ jobs: if: matrix.settings.run id: setup-node continue-on-error: ${{ runner.os == 'Windows' }} - uses: actions/setup-node@v6 # kilocode_change + uses: actions/setup-node@v6 with: node-version: "24" + # kilocode_change end + # kilocode_change start - name: Retry Setup Node on Windows if: matrix.settings.run && runner.os == 'Windows' && steps.setup-node.outcome == 'failure' uses: actions/setup-node@v6 @@ -106,7 +113,7 @@ jobs: # kilocode_change end - name: Setup Bun - if: matrix.settings.run + if: matrix.settings.run # kilocode_change uses: ./.github/actions/setup-bun # kilocode_change start @@ -115,35 +122,51 @@ jobs: uses: ./.github/actions/setup-linux-sandbox # kilocode_change end - name: Configure git identity - if: matrix.settings.run + if: matrix.settings.run # kilocode_change + # kilocode_change start run: | git config --global user.email "kilo-maintainer[bot]@users.noreply.github.com" git config --global user.name "kilo-maintainer[bot]" + # kilocode_change end - name: Cache Turbo - if: matrix.settings.run + if: matrix.settings.run # kilocode_change uses: actions/cache@v5 # kilocode_change with: path: .turbo/cache # kilocode_change - key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', 'bun.lock') }}-${{ matrix.settings.os }}-${{ matrix.settings.index }}-${{ github.sha }} + key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', 'bun.lock') }}-${{ matrix.settings.os }}-${{ matrix.settings.index }}-${{ github.sha }} # kilocode_change + # kilocode_change start restore-keys: | turbo-${{ runner.os }}-${{ hashFiles('turbo.json', 'bun.lock') }}-${{ matrix.settings.os }}-${{ matrix.settings.index }}- turbo-${{ runner.os }}-${{ hashFiles('turbo.json', 'bun.lock') }}- turbo-${{ runner.os }}- + # kilocode_change end + # kilocode_change start - test non-CLI packages separately from sharded CLI tests - name: Run non-CLI unit tests if: matrix.settings.run && matrix.settings.packages - run: bun turbo test:ci --filter='!@kilocode/cli' --filter='!@kilocode/kilo-jetbrains' + run: bun turbo test:ci --output-logs=errors-only --log-order=grouped --log-prefix=task --filter='!@kilocode/cli' --filter='!@kilocode/kilo-jetbrains' + # kilocode_change end + + # kilocode_change start - ensure the Darwin profile cannot suppress its own validation + - name: Validate Darwin CLI test profile + if: matrix.settings.run && matrix.settings.os == 'macos' + working-directory: packages/opencode + run: bun test test/kilocode/test-profile.test.ts + # kilocode_change end + # kilocode_change start - run Kilo CLI tests through platform-specific shards - name: Run CLI unit tests if: matrix.settings.run - run: bun turbo test:ci --filter='@kilocode/cli' + run: bun turbo test:ci --output-logs=errors-only --log-order=grouped --log-prefix=task --filter='@kilocode/cli' env: KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} KILO_TEST_PROFILE: ${{ matrix.settings.os == 'macos' && 'darwin' || '' }} KILO_TEST_SHARD: ${{ format('{0}/{1}', matrix.settings.index, matrix.settings.total) }} + # kilocode_change end - - name: Publish unit reports # kilocode_change + # kilocode_change start + - name: Publish unit reports if: always() && matrix.settings.run uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0 with: @@ -155,14 +178,14 @@ jobs: - name: Upload unit artifacts if: always() && matrix.settings.run - uses: actions/upload-artifact@v7 # kilocode_change + uses: actions/upload-artifact@v7 with: name: unit-${{ matrix.settings.os }}-${{ matrix.settings.index }}-${{ github.run_attempt }} include-hidden-files: true if-no-files-found: ignore retention-days: 7 path: packages/*/.artifacts/unit/junit.xml - # kilocode_change end + # kilocode_change end # kilocode_change start httpapi: @@ -225,7 +248,9 @@ jobs: run: | echo "unit=${{ needs.unit.result }}" test "${{ needs.unit.result }}" = "success" + # kilocode_change end + # kilocode_change start required: name: test (linux) runs-on: blacksmith-4vcpu-ubuntu-2404 diff --git a/.opencode-version b/.opencode-version index 2d1081d670c..67afc371e2a 100644 --- a/.opencode-version +++ b/.opencode-version @@ -1 +1 @@ -v1.16.2 +v1.17.4 diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index bda4f4f6aa5..aa075219e45 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -13,8 +13,11 @@ "packages/opencode/migration/*": "deny", }, }, - "reference": { - "effect": "github.com/Effect-TS/effect-smol", + "references": { + "effect": { + "repository": "github.com/Effect-TS/effect-smol", + "description": "Use for Effect v4 and effect-smol implementation details", + }, }, "mcp": {}, "tools": { diff --git a/CONTEXT.md b/CONTEXT.md index 30f4765e74a..bb0e319018f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -39,6 +39,19 @@ An expected temporary inability to observe a **Context Source** value; the runti **Safe Provider-Turn Boundary**: The point immediately before a provider call, after durable input promotion and any required tool settlement, where context changes may be admitted chronologically. +**Model Tool Output**: +The bounded projection of a Core-executed tool result persisted in Session history and replayed to the model. A tool may shape this projection semantically, but the Tool Registry enforces the final size limit. + +**Managed Tool Output File**: +A temporary file created under Kilo's shared tool-output directory to retain complete output that was too large for Session history. + +**Model Request Options**: +Provider-semantic model settings selected from the Catalog and active Session variant before the LLM protocol adapter encodes them for a provider request. +_Avoid_: Request body, wire options + +**Generation Controls**: +Provider-neutral sampling and output controls, partitioned from provider semantics and compatibility wire fields when model metadata enters the Catalog. + ## Relationships - A **System Context** is an opaque carrier composed from zero or more **Context Sources**. @@ -84,9 +97,22 @@ The point immediately before a provider call, after durable input promotion and - A **Baseline System Context** durably preserves the exact joined text used for the active provider-cache prefix. - Compaction or a model/provider switch starts a new **Context Epoch** because the baseline can be replaced without preserving the prior provider cache. - A model/provider switch always starts a new **Context Epoch** while preserving chronological conversation history. +- **Model Request Options** remain provider-semantic through Catalog resolution. The Session runner maps them into the LLM package's provider-option namespace; the selected protocol adapter alone owns provider wire encoding. +- **Generation Controls**, protocol-semantic **Model Request Options**, and compatibility request body fields are separate Catalog domains. A shared ingestion adapter partitions legacy and models.dev AI-SDK-shaped options before routing. - A **Mid-Conversation System Message** lowers to the provider's native chronological instruction role when supported and to a wrapped chronological fallback otherwise. - When the effective aggregate instruction set changes, its **Mid-Conversation System Message** includes the complete current ordered set and supersedes the prior aggregate value; when no ambient instructions remain, the message states that previously loaded instructions no longer apply. - Ambient project instruction discovery honors `KILO_DISABLE_PROJECT_CONFIG`; global instructions remain eligible. +- Oversized textual **Model Tool Output** retains a bounded preview in Session history while its complete text moves to managed tool-output storage. Arbitrary structured-result size is a separate concern. +- One tool settlement receives one aggregate textual limit, using the configured maximum lines or UTF-8 bytes, whichever is reached first. The limit is provider-independent; token pressure belongs to context assembly and compaction. +- Generic truncation preserves the beginning and end of textual output. Tools may apply a more meaningful strategy before the Tool Registry enforces the final limit. +- A truncated **Model Tool Output** identifies its complete text both in the bounded model-visible preview and as a typed managed output path. Managed output paths do not modify the tool's validated structured result. +- A **Managed Tool Output File** is temporary and may expire after its retention period. The bounded **Model Tool Output**, not the file, is the durable replayable record. +- Failure to retain a **Managed Tool Output File** does not change a successful tool operation into a failed one. The Session records an explicitly lossy bounded output without a path, while operators receive diagnostics for the storage failure. +- Once a tool operation succeeds, bounding its **Model Tool Output** and publishing its one durable settlement form an interruption-safe completion region. Raw oversized success is never published before a later correction. +- When a structured-only result would exceed the **Model Tool Output** limit, its validated structured value remains unchanged for Session consumers while model replay uses a bounded textual JSON preview and optional managed output path. +- Existing tool-managed output paths survive generic bounding. A fallback file retains exactly the complete projected text received by the Tool Registry and never claims to reconstruct output already discarded by tool-specific shaping. +- **Managed Tool Output Files** use globally unique names in one shared flat directory. Their absolute paths are readable and searchable by ordinary tools; other absolute paths remain outside Location-scoped filesystem authority. +- Provider-executed tool results remain provider-native transcript facts outside generic Tool Registry bounding. Their context control requires provider-aware pruning or compaction because some providers require exact structured round-trip payloads. ## Example dialogue diff --git a/bun.lock b/bun.lock index 44a2d204727..a62d4842d04 100644 --- a/bun.lock +++ b/bun.lock @@ -39,7 +39,7 @@ "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", - "@ai-sdk/anthropic": "3.0.71", + "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.54", "@ai-sdk/cohere": "3.0.27", @@ -61,6 +61,7 @@ "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", + "@ff-labs/fff-bun": "0.9.4", "@kilocode/kilo-gateway": "workspace:*", "@kilocode/kilo-indexing": "workspace:*", "@kilocode/sandbox": "workspace:*", @@ -76,13 +77,14 @@ "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@parcel/watcher": "2.5.1", + "@silvia-odwyer/photon-node": "0.3.4", "ai-gateway-provider": "3.1.2", "bun-pty": "0.4.8", "cross-spawn": "catalog:", "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.8.0", + "gitlab-ai-provider": "6.9.3", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -153,13 +155,21 @@ "name": "@opencode-ai/http-recorder", "version": "7.4.11", "dependencies": { - "@effect/platform-node": "catalog:", + "@effect/platform-node": "4.0.0-beta.74", + "@effect/platform-node-shared": "4.0.0-beta.74", "effect": "catalog:", }, "devDependencies": { "@tsconfig/bun": "catalog:", + "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", + "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "effect": "catalog:", + "typescript": "catalog:", + }, + "peerDependencies": { + "effect": "4.0.0-beta.74", }, }, "packages/kilo-console": { @@ -494,7 +504,7 @@ "@agentclientprotocol/sdk": "0.21.0", "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", - "@ai-sdk/anthropic": "3.0.71", + "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.54", "@ai-sdk/cohere": "3.0.27", @@ -516,6 +526,7 @@ "@clack/prompts": "1.0.0-alpha.1", "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", + "@ff-labs/fff-bun": "0.9.4", "@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/opencode-gitlab-auth": "1.3.3", "@kilocode/kilo-gateway": "workspace:*", @@ -536,6 +547,7 @@ "@opencode-ai/llm": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/server": "workspace:*", + "@opencode-ai/tui": "workspace:*", "@opencode-ai/ui": "workspace:*", "@openrouter/ai-sdk-provider": "2.9.0", "@opentelemetry/api": "1.9.0", @@ -552,6 +564,7 @@ "@secretlint/secretlint-rule-preset-recommend": "10.2.2", "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/event-bus": "1.1.2", + "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", "@types/ws": "8.18.1", "@zip.js/zip.js": "2.7.62", @@ -569,7 +582,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.8.0", + "gitlab-ai-provider": "6.9.3", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -583,7 +596,7 @@ "minimatch": "10.0.3", "npm-package-arg": "13.0.2", "open": "10.1.2", - "opencode-gitlab-auth": "2.0.1", + "opencode-gitlab-auth": "2.1.0", "opencode-poe-auth": "0.0.1", "opentui-spinner": "catalog:", "partial-json": "0.1.7", @@ -663,9 +676,9 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.3.2", - "@opentui/keymap": ">=0.3.2", - "@opentui/solid": ">=0.3.2", + "@opentui/core": ">=0.3.4", + "@opentui/keymap": ">=0.3.4", + "@opentui/solid": ">=0.3.4", }, "optionalPeers": [ "@opentui/core", @@ -750,6 +763,33 @@ "vite": "catalog:", }, }, + "packages/tui": { + "name": "@opencode-ai/tui", + "version": "7.4.8", + "dependencies": { + "@kilocode/plugin": "workspace:*", + "@kilocode/sdk": "workspace:*", + "@opencode-ai/core": "workspace:*", + "@opencode-ai/ui": "workspace:*", + "@opentui/core": "catalog:", + "@opentui/keymap": "catalog:", + "@opentui/solid": "catalog:", + "clipboardy": "4.0.0", + "diff": "catalog:", + "effect": "catalog:", + "fuzzysort": "catalog:", + "open": "10.1.2", + "opentui-spinner": "catalog:", + "remeda": "catalog:", + "solid-js": "catalog:", + "strip-ansi": "7.1.2", + }, + "devDependencies": { + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@typescript/native-preview": "catalog:", + }, + }, "packages/ui": { "name": "@opencode-ai/ui", "version": "7.4.11", @@ -815,8 +855,10 @@ "@ai-sdk/xai@3.0.92": "patches/@ai-sdk%2Fxai@3.0.92.patch", "virtua@0.49.1": "patches/virtua@0.49.1.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", "pacote@21.5.1": "patches/pacote@21.5.1.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", }, "overrides": { @@ -853,9 +895,9 @@ "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@opentui/core": "0.3.2", - "@opentui/keymap": "0.3.2", - "@opentui/solid": "0.3.2", + "@opentui/core": "0.3.4", + "@opentui/keymap": "0.3.4", + "@opentui/solid": "0.3.4", "@pierre/diffs": "1.1.22", "@playwright/test": "1.59.1", "@solid-primitives/storage": "4.3.3", @@ -885,7 +927,7 @@ "luxon": "3.6.1", "marked": "17.0.1", "marked-shiki": "1.2.1", - "opentui-spinner": "0.0.6", + "opentui-spinner": "0.0.7", "remeda": "2.26.0", "remend": "1.3.0", "semver": "7.7.4", @@ -922,7 +964,7 @@ "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/openai": "3.0.67", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-PsSh7a6qW+3kQXPs1kD4wDwuZby0t1PIaB6j/1aMKmPFJ5LxcIcULLMF/bjITLt5o/8lc0t6TXIwG0zlhH7uZw=="], - "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="], + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.82", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WKKou2wbhGGYV8PSALAPyV2YY4nfCqCPkyBzYtJtDA9yCcIFwsbtkTNgg7bqtLCVzeEsY7wwxRoCWy+EMfrw/A=="], "@ai-sdk/azure": ["@ai-sdk/azure@3.0.49", "", { "dependencies": { "@ai-sdk/openai": "3.0.48", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wskgAL+OmrHG7by/iWIxEBQCEdc1mDudha/UZav46i0auzdFfsDB/k2rXZaC4/3nWSgMZkxr0W3ncyouEGX/eg=="], @@ -1336,6 +1378,24 @@ "@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="], + "@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xyivu2xB++O5xXDx5Qm50JsU2aXt8YgXlGVhH/HE7UMYDrE6L6f1RYdYs8Y0bn0D3D0+bFBrN5ELPszK9E4Wbw=="], + + "@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xLooAhCnTDCipPSMMZz7kGF3lhRHx6aP5fb6DJ0Ipyw/w/UWJb+xITJFszUl/QnIBoJ/qjDc93/FZMo1dk6gVA=="], + + "@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw=="], + + "@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA=="], + + "@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q=="], + + "@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w=="], + + "@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ=="], + + "@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Yd2Eyxj+slWv+0QDW9/xBpu9FXq+hwD0rXQD5184/88d+xwWCLKhEP2w8I6OO9XCg+kLT79UJb+k0WwXUtBtMw=="], + + "@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.4", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", "@ff-labs/fff-bin-darwin-x64": "0.9.4", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-x64-musl": "0.9.4", "@ff-labs/fff-bin-win32-arm64": "0.9.4", "@ff-labs/fff-bin-win32-x64": "0.9.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-7HUraaK/g5dStAnuKAuzsXVOQvqqX0ylo5G+DxYwsCjCDc42bjoEAAHqz/3Sn3raUNw97KMoz87XR9QyrLEfVw=="], + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], @@ -1674,6 +1734,8 @@ "@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"], + "@opencode-ai/tui": ["@opencode-ai/tui@workspace:packages/tui"], + "@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"], "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.9.0", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Seva+NCa0WUQnJIUE5GzHsUv1WTIeyqwz0ELl2VtS6NP+eF+77yCXGFVOMbvoCM7QMjlnhv7931e89R+8pJdcQ=="], @@ -1704,27 +1766,27 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], - "@opentui/core": ["@opentui/core@0.3.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.2", "@opentui/core-darwin-x64": "0.3.2", "@opentui/core-linux-arm64": "0.3.2", "@opentui/core-linux-arm64-musl": "0.3.2", "@opentui/core-linux-x64": "0.3.2", "@opentui/core-linux-x64-musl": "0.3.2", "@opentui/core-win32-arm64": "0.3.2", "@opentui/core-win32-x64": "0.3.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-5rCVS/3Obb3iLqg/egLCRArt7hAu3lX/9PWVHqUlnJylCT6b5NYDFljt0r3x8v3VG98LB71UzpnDv7DgmGKATw=="], + "@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rFnGfqqEOGiUTbxglpiDA500KeRqcI1ukemhNfDrEzx3imAArS8mFZSuUG7ib31P5EpX+PXuvg0G9/3YXfgWeQ=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z+/GxKvB3NzMDSwuyWR7HDStbaNRf5a09lt5W9b4BGmCAFW/mbX0Tuh3kloubcMgiq5vLnVaNzY19hrIgJrjGQ=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-0TRuGNR+2GGk0rQuDaIxkIa/3Ty/XySfeOQLAUX4Jqaifky04As69fYT17yOhHqg5viCJUAGG/SdW8IH6C2osg=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="], - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-noDKfwYUjutQUx5rtoyKrBIYaeSCmAmtxOSJdnKecyWEhMygtdHp4ssPtxzsZMuQAliHogCmD7vUG/pGMgcXMQ=="], + "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EZp+Lg9eZwzwNny4l2ACHdC95JbEKYbor1WImnm6IEo1e2Fgl9mltYv2J+i+Ea+dXQbrkK/MRIw7CRusxfqFFA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="], - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-/FNGeJYhCHcIE3qBIo+nl8014NZ8u/XXUwQY2RjuWhMnzK9kQUfZ3cW4FP6FkOA/k4jGvByya47193II6djFtw=="], + "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-/zb5nCZKDgBS1UEQXrzTYUbbTPFMygbiZ/BfNWjEDIbm63gVzQ7pVouYbGP+88CRXIJtwT6LeoVPgp9nmOrDWQ=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-q8xqMhW1jlJVzos+A5+FXRquH01j1ZHmrNi/9++W1Ebz3LQYn+8Z8j7rcV/meIiDuo9nyRHigQQT+cy9xV4N2g=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="], - "@opentui/keymap": ["@opentui/keymap@0.3.2", "", { "dependencies": { "@opentui/core": "0.3.2" }, "peerDependencies": { "@opentui/react": "0.3.2", "@opentui/solid": "0.3.2", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-y+IPBagxPvVrKIISmlfvO7ScvVIXGMmV92x0O+WzQ2vPf6fi5xgJc4YNmgEACfGdnM3ub14MkgEXqMOeUN7ZQA=="], + "@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="], - "@opentui/solid": ["@opentui/solid@0.3.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-Yff0gSwIY/o0XeMciYeAUkQtea8bWzR0UjjVglmcBe13hWuJZt/GfjbDMdNNQ8zCrLubLEh04an5fYXCd7NMYQ=="], + "@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], @@ -2158,6 +2220,8 @@ "@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9HULb0QAzL2r47CCad0M+NKFtQ+LrGGNHZfteX/ThdGvKIg2o2GYhBooZubTCd/RTu2l2+Nw4s+dEfiDGvdrrQ=="], + "@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="], + "@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-uxez7SXnr5GiRnzqO2IEDjOJRIXaG+0LZLBizmUA1FwSi+hrpuMzVBwyk70m4prcl8X6FDDXUl9O8hSq8wHbBQ=="], "@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="], @@ -3318,7 +3382,7 @@ "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.8.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-KwHASXkHtDcgrzTXZVp9Dyx6t8m9nK0R2fCm47MWcxxQ1kOBt3f2LZugtu1kOby8i4Sbd+kvBSYM66PGkDclng=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.9.3", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-lWo6b6es5+k9iXaDIvE9ECzyK4zfEza4+dQ5FN8SJpEuVRi3ZBCpHIOTa32QoYEDCBaiPh+tcyca86PfNodmlg=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -3884,13 +3948,13 @@ "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], - "opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.1", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-1EMZHdbADLMVaTVLQ6C/V8uVMDr6MP++osj2lmOecowtn46AafP/w6ADkV4AN/ddjA1rob5cWpMuf/iME6DI6A=="], + "opencode-gitlab-auth": ["opencode-gitlab-auth@2.1.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-ZCDYaY0V8Se6hOH2tqZqqcskrd0xLTgfiGhU0J1igkUP52oFtN9eSwxOPLT0ctvNXUq8b+zOmJ4sskAQoC/IUA=="], "opencode-poe-auth": ["opencode-poe-auth@0.0.1", "", { "dependencies": { "open": "^10.0.0", "poe-oauth": "*" }, "peerDependencies": { "@opencode-ai/plugin": "*" } }, "sha512-cXqTlS6AXHzo1oBdosnxbT47ZJEZ9WXn050X8Re6wZ1vaNnTpB/l2fMQt90evT7RBK0fB8UjXQUDMKyd7bbiqg=="], "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], - "opentui-spinner": ["opentui-spinner@0.0.6", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.1.49", "@opentui/react": "^0.1.49", "@opentui/solid": "^0.1.49", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-xupLOeVQEAXEvVJCvHkfX6fChDWmJIPHe5jyUrVb8+n4XVTX8mBNhitFfB9v2ZbkC1H2UwPab/ElePHoW37NcA=="], + "opentui-spinner": ["opentui-spinner@0.0.7", "", { "dependencies": { "cli-spinners": "^3.3.0" }, "peerDependencies": { "@opentui/core": "^0.3.4", "@opentui/react": "^0.3.4", "@opentui/solid": "^0.3.4", "typescript": "^5" }, "optionalPeers": ["@opentui/react", "@opentui/solid"] }, "sha512-nPzwAvJG+y9rVEwwHLHqbsMzLnIk2zw+F9LqwA7aYJvpM5gsrKC2rrGi36A+tZpA+1RnWxXeWEgVZMchnaH18Q=="], "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], @@ -4686,6 +4750,10 @@ "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + + "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], @@ -4992,6 +5060,8 @@ "@kilocode/kilo-docs/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@kilocode/kilo-gateway/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="], + "@kilocode/kilo-indexing/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "@kilocode/kilo-indexing/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -5252,6 +5322,8 @@ "ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.96", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Mc4Ias2jRMD1jOB6xWtKNPdhECeuCZyIlbr9EAGfBnyBt++sS13ziZh9qv9TdyMCAZJ7xoQcpbchoRJcKwPdpA=="], + "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="], + "ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="], "ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cSfHCkM+9ZrFtQWIN1WlV93JPD+isGSdFxKj7u1L9m2aLVZajlXdcE41GL9hMt7ld7bZYE4NnZ+4VLxBAHE+Eg=="], @@ -5460,6 +5532,8 @@ "nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], + "opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], @@ -5606,6 +5680,8 @@ "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@ai-sdk/cerebras/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], diff --git a/bunfig.toml b/bunfig.toml index 3c42bc993e0..f36a00828ca 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Keep Kilo's longer supply-chain quarantine while allowing packages that must track coordinated releases. minimumReleaseAge = 410520 # seconds (~4.75 days / ~114 hours) -minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid"] +minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] [test] root = "./do-not-run-tests-from-root" diff --git a/package.json b/package.json index 00d626649a3..aa0e85c19b7 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,8 @@ "@types/bun": "1.3.14", "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", - "@opentui/core": "0.3.2", - "@opentui/solid": "0.3.2", + "@opentui/core": "0.3.4", + "@opentui/solid": "0.3.4", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", @@ -46,7 +46,7 @@ "@cloudflare/workers-types": "4.20251008.0", "@openauthjs/openauth": "0.0.0-20250322224806", "@pierre/diffs": "1.1.22", - "opentui-spinner": "0.0.6", + "opentui-spinner": "0.0.7", "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", "diff": "8.0.4", @@ -81,7 +81,7 @@ "solid-js": "1.9.12", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.12", - "@opentui/keymap": "0.3.2", + "@opentui/keymap": "0.3.4", "@effect/sql-sqlite-bun": "4.0.0-beta.74", "@hono/standard-validator": "0.2.0", "@hono/zod-validator": "0.4.2", @@ -154,14 +154,15 @@ "@opentui/keymap": "catalog:" }, "patchedDependencies": { - "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", + "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@ai-sdk/xai@3.0.92": "patches/@ai-sdk%2Fxai@3.0.92.patch", "pacote@21.5.1": "patches/pacote@21.5.1.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "mammoth@1.12.0": "patches/mammoth@1.12.0.patch" }, "version": "7.4.11", diff --git a/packages/core/migration/20260605042240_add_context_epoch_agent/snapshot.json b/packages/core/migration/20260605042240_add_context_epoch_agent/snapshot.json index 5fe721e5531..0c8fb75d725 100644 --- a/packages/core/migration/20260605042240_add_context_epoch_agent/snapshot.json +++ b/packages/core/migration/20260605042240_add_context_epoch_agent/snapshot.json @@ -2,9 +2,7 @@ "version": "7", "dialect": "sqlite", "id": "d1bfa125-b81e-4c61-9b6e-e74abf6e488f", - "prevIds": [ - "40f7b9b8-83b4-4ea0-a59f-76a489679d88" - ], + "prevIds": ["40f7b9b8-83b4-4ea0-a59f-76a489679d88"], "ddl": [ { "name": "workspace", @@ -1409,13 +1407,9 @@ "table": "session_share" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1424,13 +1418,9 @@ "table": "workspace" }, { - "columns": [ - "active_account_id" - ], + "columns": ["active_account_id"], "tableTo": "account", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "SET NULL", "nameExplicit": false, @@ -1439,13 +1429,9 @@ "table": "account_state" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "tableTo": "event_sequence", - "columnsTo": [ - "aggregate_id" - ], + "columnsTo": ["aggregate_id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1454,13 +1440,9 @@ "table": "event" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1469,13 +1451,9 @@ "table": "permission" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1484,13 +1462,9 @@ "table": "project_directory" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1499,13 +1473,9 @@ "table": "message" }, { - "columns": [ - "message_id" - ], + "columns": ["message_id"], "tableTo": "message", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1514,13 +1484,9 @@ "table": "part" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1529,13 +1495,9 @@ "table": "session_context_epoch" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1544,13 +1506,9 @@ "table": "session_input" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1559,13 +1517,9 @@ "table": "session_message" }, { - "columns": [ - "project_id" - ], + "columns": ["project_id"], "tableTo": "project", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1574,13 +1528,9 @@ "table": "session" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1589,13 +1539,9 @@ "table": "todo" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "tableTo": "session", - "columnsTo": [ - "id" - ], + "columnsTo": ["id"], "onUpdate": "NO ACTION", "onDelete": "CASCADE", "nameExplicit": false, @@ -1604,165 +1550,126 @@ "table": "session_share" }, { - "columns": [ - "email", - "url" - ], + "columns": ["email", "url"], "nameExplicit": false, "name": "control_account_pk", "entityType": "pks", "table": "control_account" }, { - "columns": [ - "project_id", - "directory" - ], + "columns": ["project_id", "directory"], "nameExplicit": false, "name": "project_directory_pk", "entityType": "pks", "table": "project_directory" }, { - "columns": [ - "session_id", - "position" - ], + "columns": ["session_id", "position"], "nameExplicit": false, "name": "todo_pk", "entityType": "pks", "table": "todo" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "workspace_pk", "table": "workspace", "entityType": "pks" }, { - "columns": [ - "name" - ], + "columns": ["name"], "nameExplicit": false, "name": "data_migration_pk", "table": "data_migration", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_state_pk", "table": "account_state", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "account_pk", "table": "account", "entityType": "pks" }, { - "columns": [ - "aggregate_id" - ], + "columns": ["aggregate_id"], "nameExplicit": false, "name": "event_sequence_pk", "table": "event_sequence", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "event_pk", "table": "event", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "permission_pk", "table": "permission", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "project_pk", "table": "project", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "message_pk", "table": "message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "part_pk", "table": "part", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_context_epoch_pk", "table": "session_context_epoch", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_input_pk", "table": "session_input", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_message_pk", "table": "session_message", "entityType": "pks" }, { - "columns": [ - "id" - ], + "columns": ["id"], "nameExplicit": false, "name": "session_pk", "table": "session", "entityType": "pks" }, { - "columns": [ - "session_id" - ], + "columns": ["session_id"], "nameExplicit": false, "name": "session_share_pk", "table": "session_share", diff --git a/packages/core/migration/20260611035744_credential/migration.sql b/packages/core/migration/20260611035744_credential/migration.sql new file mode 100644 index 00000000000..c950306e465 --- /dev/null +++ b/packages/core/migration/20260611035744_credential/migration.sql @@ -0,0 +1,12 @@ +CREATE TABLE `credential` ( + `id` text PRIMARY KEY, + `connector_id` text NOT NULL, + `method_id` text NOT NULL, + `label` text NOT NULL, + `value` text NOT NULL, + `active` integer DEFAULT false NOT NULL, + `time_created` integer NOT NULL, + `time_updated` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `credential_connector_active_idx` ON `credential` (`connector_id`) WHERE "credential"."active" = 1; diff --git a/packages/core/migration/20260611035744_credential/snapshot.json b/packages/core/migration/20260611035744_credential/snapshot.json new file mode 100644 index 00000000000..cfc3930305c --- /dev/null +++ b/packages/core/migration/20260611035744_credential/snapshot.json @@ -0,0 +1,2095 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "f25f9126-c7dc-4882-9ff4-af27e11d2da1", + "prevIds": ["d1bfa125-b81e-4c61-9b6e-e74abf6e488f"], + "ddl": [ + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "data_migration", + "entityType": "tables" + }, + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "credential", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "project_directory", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "session_context_epoch", + "entityType": "tables" + }, + { + "name": "session_input", + "entityType": "tables" + }, + { + "name": "session_message", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_completed", + "entityType": "columns", + "table": "data_migration" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "credential" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "connector_id", + "entityType": "columns", + "table": "credential" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "method_id", + "entityType": "columns", + "table": "credential" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "label", + "entityType": "columns", + "table": "credential" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "credential" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "active", + "entityType": "columns", + "table": "credential" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "credential" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "credential" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "owner_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "action", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "resource", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project_directory" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'build'", + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "snapshot", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "baseline_seq", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "replacement_seq", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "revision", + "entityType": "columns", + "table": "session_context_epoch" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "prompt", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "delivery", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "admitted_seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "promoted_seq", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_input" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "path", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "metadata", + "entityType": "columns", + "table": "session" + }, + { + "type": "real", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_input", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_output", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_reasoning", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_read", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "tokens_cache_write", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_project_directory_project_id_project_id_fk", + "entityType": "fks", + "table": "project_directory" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_context_epoch_session_id_session_id_fk", + "entityType": "fks", + "table": "session_context_epoch" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_input_session_id_session_id_fk", + "entityType": "fks", + "table": "session_input" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_message_session_id_session_id_fk", + "entityType": "fks", + "table": "session_message" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["project_id", "directory"], + "nameExplicit": false, + "name": "project_directory_pk", + "entityType": "pks", + "table": "project_directory" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["name"], + "nameExplicit": false, + "name": "data_migration_pk", + "table": "data_migration", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "credential_pk", + "table": "credential", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_context_epoch_pk", + "table": "session_context_epoch", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_input_pk", + "table": "session_input", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_message_pk", + "table": "session_message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "connector_id", + "isExpression": false + } + ], + "isUnique": true, + "where": "\"credential\".\"active\" = 1", + "origin": "manual", + "name": "credential_connector_active_idx", + "entityType": "indexes", + "table": "credential" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "event_aggregate_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "aggregate_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "event_aggregate_type_seq_idx", + "entityType": "indexes", + "table": "event" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + }, + { + "value": "action", + "isExpression": false + }, + { + "value": "resource", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "permission_project_action_resource_idx", + "entityType": "indexes", + "table": "permission" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "promoted_seq", + "isExpression": false + }, + { + "value": "delivery", + "isExpression": false + }, + { + "value": "admitted_seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_input_session_pending_delivery_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "admitted_seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_input_session_admitted_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "promoted_seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_input_session_promoted_seq_idx", + "entityType": "indexes", + "table": "session_input" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": true, + "where": null, + "origin": "manual", + "name": "session_message_session_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + }, + { + "value": "seq", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_type_seq_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_session_time_created_id_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_message_time_created_idx", + "entityType": "indexes", + "table": "session_message" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/core/migration/20260714141136_session-message-legacy-writer-compat/snapshot.json b/packages/core/migration/20260714141136_session-message-legacy-writer-compat/snapshot.json index 6e404909a40..ca05d7d5945 100644 --- a/packages/core/migration/20260714141136_session-message-legacy-writer-compat/snapshot.json +++ b/packages/core/migration/20260714141136_session-message-legacy-writer-compat/snapshot.json @@ -3,7 +3,7 @@ "dialect": "sqlite", "id": "6c030252-8b68-4107-b18a-f64f99b76895", "prevIds": [ - "d1bfa125-b81e-4c61-9b6e-e74abf6e488f" + "f25f9126-c7dc-4882-9ff4-af27e11d2da1" ], "ddl": [ { @@ -26,6 +26,10 @@ "name": "control_account", "entityType": "tables" }, + { + "name": "credential", + "entityType": "tables" + }, { "name": "event_sequence", "entityType": "tables" @@ -368,6 +372,86 @@ "entityType": "columns", "table": "control_account" }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "credential" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "connector_id", + "entityType": "columns", + "table": "credential" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "method_id", + "entityType": "columns", + "table": "credential" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "label", + "entityType": "columns", + "table": "credential" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "value", + "entityType": "columns", + "table": "credential" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "false", + "generated": null, + "name": "active", + "entityType": "columns", + "table": "credential" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "credential" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "credential" + }, { "type": "text", "notNull": false, @@ -1669,6 +1753,15 @@ "table": "account", "entityType": "pks" }, + { + "columns": [ + "id" + ], + "nameExplicit": false, + "name": "credential_pk", + "table": "credential", + "entityType": "pks" + }, { "columns": [ "aggregate_id" @@ -1768,6 +1861,20 @@ "table": "session_share", "entityType": "pks" }, + { + "columns": [ + { + "value": "connector_id", + "isExpression": false + } + ], + "isUnique": true, + "where": "\"credential\".\"active\" = 1", + "origin": "manual", + "name": "credential_connector_active_idx", + "entityType": "indexes", + "table": "credential" + }, { "columns": [ { @@ -2080,4 +2187,4 @@ } ], "renames": [] -} \ No newline at end of file +} diff --git a/packages/core/package.json b/packages/core/package.json index af410efcd13..bcf809158b1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -9,7 +9,7 @@ "db": "bun drizzle-kit", "migration": "bun run script/migration.ts", "fix-node-pty": "bun run script/fix-node-pty.ts", - "test": "bun test", + "test": "bun test --only-failures", "test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml", "typecheck": "tsgo --noEmit" }, @@ -32,6 +32,11 @@ "bun": "./src/pty/pty.bun.ts", "node": "./src/pty/pty.node.ts", "default": "./src/pty/pty.bun.ts" + }, + "#fff": { + "bun": "./src/filesystem/fff.bun.ts", + "node": "./src/filesystem/fff.node.ts", + "default": "./src/filesystem/fff.bun.ts" } }, "devDependencies": { @@ -79,7 +84,7 @@ "zod": "catalog:", "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/amazon-bedrock": "4.0.112", - "@ai-sdk/anthropic": "3.0.71", + "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/azure": "3.0.49", "@ai-sdk/cerebras": "2.0.54", "@ai-sdk/cohere": "3.0.27", @@ -100,7 +105,7 @@ "@aws-sdk/credential-providers": "3.1057.0", "@openrouter/ai-sdk-provider": "2.9.0", "ai-gateway-provider": "3.1.2", - "gitlab-ai-provider": "6.8.0", + "gitlab-ai-provider": "6.9.3", "google-auth-library": "10.5.0", "immer": "11.1.4", "venice-ai-sdk-provider": "2.0.2", @@ -118,7 +123,9 @@ "htmlparser2": "8.0.2", "ignore": "7.0.5", "turndown": "7.2.0", - "which": "6.0.1" + "which": "6.0.1", + "@ff-labs/fff-bun": "0.9.4", + "@silvia-odwyer/photon-node": "0.3.4" }, "overrides": { "drizzle-orm": "catalog:" diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 3e598729735..fabf7477d68 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -63,7 +63,7 @@ export type Editor = { export interface Interface { readonly transform: State.Interface["transform"] - readonly update: (update: State.Transform) => Effect.Effect + readonly update: State.Interface["update"] readonly get: (id: ID) => Effect.Effect readonly default: () => Effect.Effect readonly resolve: (id?: ID | string) => Effect.Effect @@ -113,10 +113,7 @@ export const layer = Layer.effect( return Service.of({ transform: state.transform, - update: Effect.fn("AgentV2.update")(function* (update) { - const transform = yield* state.transform() - yield* transform(update) - }), + update: state.update, get: Effect.fn("AgentV2.get")(function* (id) { return state.get().agents.get(id) }), diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts deleted file mode 100644 index 51e1c254d51..00000000000 --- a/packages/core/src/auth.ts +++ /dev/null @@ -1,352 +0,0 @@ -export * as Auth from "./auth" - -import path from "path" -import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect" -import { Identifier } from "./util/identifier" -import { NonNegativeInt, withStatics } from "./schema" -import { Global } from "./global" -import { FSUtil } from "./fs-util" -import { EventV2 } from "./event" - -export const ID = Schema.String.pipe( - Schema.brand("Auth.ID"), - withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })), -) -export type ID = typeof ID.Type - -export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID")) -export type ServiceID = typeof ServiceID.Type - -export const OrgID = Schema.String.pipe(Schema.brand("OrgID")) -export type OrgID = typeof OrgID.Type -export const AccessToken = Schema.String.pipe(Schema.brand("AccessToken")) -export type AccessToken = typeof AccessToken.Type -export const RefreshToken = Schema.String.pipe(Schema.brand("RefreshToken")) -export type RefreshToken = typeof RefreshToken.Type - -export class OAuthCredential extends Schema.Class("Auth.OAuthCredential")({ - type: Schema.Literal("oauth"), - refresh: Schema.String, - access: Schema.String, - expires: NonNegativeInt, - accountId: Schema.optional(Schema.String), // kilocode_change - preserve Kilo organization during v1 migration -}) {} - -export class ApiKeyCredential extends Schema.Class("Auth.ApiKeyCredential")({ - type: Schema.Literal("api"), - key: Schema.String, - metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), -}) {} - -export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential]) - .pipe(Schema.toTaggedUnion("type")) - .annotate({ - identifier: "Auth.Credential", - }) -export type Credential = Schema.Schema.Type - -export class Info extends Schema.Class("Auth.Info")({ - id: ID, - serviceID: ServiceID, - description: Schema.String, - credential: Credential, -}) {} - -export class FileWriteError extends Schema.TaggedErrorClass()("Auth.FileWriteError", { - operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]), - cause: Schema.Defect, -}) {} - -export type Error = FileWriteError - -export const Event = { - Added: EventV2.define({ - type: "account.added", - schema: { - account: Info, - }, - }), - Removed: EventV2.define({ - type: "account.removed", - schema: { - account: Info, - }, - }), - Switched: EventV2.define({ - type: "account.switched", - schema: { - serviceID: ServiceID, - from: Schema.optional(ID), - to: Schema.optional(ID), - }, - }), -} - -interface Writable { - version: 2 - accounts: Record - active: Record -} - -const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential)) - -function migrate(old: Record): Writable { - const accounts: Record = {} - const active: Record = {} - for (const [serviceID, value] of Object.entries(old)) { - const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({})) - const parsed = (decoded as Record)[serviceID] - if (!parsed) continue - const id = Identifier.ascending() - const account = ID.make(id) - const brandedServiceID = ServiceID.make(serviceID) - accounts[id] = new Info({ - id: account, - serviceID: brandedServiceID, - description: "default", - credential: parsed, - }) - active[brandedServiceID] = account - } - return { version: 2, accounts, active } -} - -export interface Interface { - readonly get: (id: ID) => Effect.Effect - readonly all: () => Effect.Effect - readonly create: (input: { - serviceID: ServiceID - credential: Credential - description?: string - }) => Effect.Effect - readonly update: (id: ID, updates: Partial>) => Effect.Effect - readonly remove: (id: ID) => Effect.Effect - readonly activate: (id: ID) => Effect.Effect - readonly active: (serviceID: ServiceID) => Effect.Effect - readonly activeAll: () => Effect.Effect, Error> - readonly forService: (serviceID: ServiceID) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/Account") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fsys = yield* FSUtil.Service - const global = yield* Global.Service - const events = yield* EventV2.Service - const file = path.join(global.data, "account.json") - const legacyFile = path.join(global.data, "auth.json") - const prior = path.join(global.data, "auth-v2.json") // kilocode_change - - const writeMigrated = Effect.fnUntraced(function* (raw: Record) { - const migrated = migrate(raw) - yield* fsys - .writeJson(file, migrated, 0o600) - .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause }))) - return migrated - }) - - const parseAuthContent = () => { - try { - return JSON.parse(process.env.KILO_AUTH_CONTENT ?? "") - } catch {} - } - - const load: () => Effect.Effect = Effect.fnUntraced(function* () { - if (process.env.KILO_AUTH_CONTENT) { - const raw = parseAuthContent() - if (raw && typeof raw === "object") { - if ("version" in raw && raw.version === 2) return raw as Writable - return yield* writeMigrated(raw as Record) - } - return { version: 2, accounts: {}, active: {} } - } - - const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null)) - if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record) - - const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) - - if (raw && typeof raw === "object") { - if ("version" in raw && raw.version === 2) return raw as Writable - return yield* writeMigrated(raw as Record) - } - - // kilocode_change start - migrate the previous Kilo multi-account store after the current store - const previous = yield* fsys.readJson(prior).pipe(Effect.orElseSucceed(() => null)) - if (previous && typeof previous === "object" && "version" in previous && previous.version === 2) { - yield* fsys - .writeJson(file, previous, 0o600) - .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause }))) - return previous as Writable - } - // kilocode_change end - - return { version: 2, accounts: {}, active: {} } - }) - - const write = (data: Writable) => - fsys - .writeJson(file, data, 0o600) - .pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause }))) - - const state = SynchronizedRef.makeUnsafe( - yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))), - ) - - const activate = Effect.fn("Auth.activate")(function* (id: ID) { - const data = yield* SynchronizedRef.get(state) - const account = data.accounts[id] - if (!account) return - const activated = yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - const nextAccount = data.accounts[id] - if (!nextAccount) return [undefined, data] as const - - const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } } - yield* write(next) - return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const - }), - ) - if (activated) yield* events.publish(Event.Switched, activated) - }) - - const result: Interface = { - get: Effect.fn("Auth.get")(function* (id) { - return (yield* SynchronizedRef.get(state)).accounts[id] - }), - - all: Effect.fn("Auth.all")(function* () { - return Object.values((yield* SynchronizedRef.get(state)).accounts) - }), - - active: Effect.fn("Auth.active")(function* (serviceID) { - const data = yield* SynchronizedRef.get(state) - return ( - data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID) - ) - }), - - activeAll: Effect.fn("Auth.activeAll")(function* () { - const data = yield* SynchronizedRef.get(state) - const result = new Map() - for (const account of Object.values(data.accounts)) { - if (!result.has(account.serviceID)) result.set(account.serviceID, account) - } - for (const [serviceID, id] of Object.entries(data.active)) { - const account = data.accounts[id] - if (account) result.set(ServiceID.make(serviceID), account) - } - return result - }), - - forService: Effect.fn("Auth.list")(function* (serviceID) { - return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID) - }), - - create: Effect.fn("Auth.add")(function* (input) { - const id = ID.make(Identifier.ascending()) - const account = new Info({ - id, - serviceID: input.serviceID, - description: input.description ?? "default", - credential: input.credential, - }) - const added = yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - const next = { - ...data, - accounts: { ...data.accounts, [account.id]: account }, - active: { ...data.active, [account.serviceID]: account.id }, - } - - yield* write(next) - return [ - { - account, - switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id }, - }, - next, - ] as const - }), - ) - yield* events.publish(Event.Added, { account: added.account }) - yield* events.publish(Event.Switched, added.switched) - return added.account - }), - - update: Effect.fn("Auth.update")(function* (id, updates) { - const existing = (yield* SynchronizedRef.get(state)).accounts[id] - if (!existing) return - yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - if (!data.accounts[id]) return [undefined, data] as const - - const next = { - ...data, - accounts: { - ...data.accounts, - [id]: new Info({ - id, - serviceID: existing.serviceID, - description: updates.description ?? existing.description, - credential: updates.credential ?? existing.credential, - }), - }, - } - - yield* write(next) - return [undefined, next] as const - }), - ) - }), - - remove: Effect.fn("Auth.remove")(function* (id) { - const removed = yield* SynchronizedRef.modifyEffect( - state, - Effect.fnUntraced(function* (data) { - const accounts = { ...data.accounts } - const active = { ...data.active } - const removed = accounts[id] - if (!removed) return [undefined, data] as const - const wasActive = active[removed.serviceID] === id - delete accounts[id] - const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID) - if (wasActive) { - if (replacement) active[removed.serviceID] = replacement.id - else delete active[removed.serviceID] - } - - const next = { ...data, accounts, active } - yield* write(next) - return [ - { - account: removed, - switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined, - }, - next, - ] as const - }), - ) - if (removed) { - yield* events.publish(Event.Removed, { account: removed.account }) - if (removed.switched) yield* events.publish(Event.Switched, removed.switched) - } - }), - - activate, - } - - return Service.of(result) - }), -) - -export const defaultLayer = layer.pipe( - Layer.provide(FSUtil.defaultLayer), - Layer.provide(Global.defaultLayer), - Layer.provide(EventV2.defaultLayer), -) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index e7103627a49..d219c763898 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -3,12 +3,15 @@ export * as Catalog from "./catalog" import { Context, Effect, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect" import { castDraft, enableMapSet, type Draft } from "immer" import { ModelV2 } from "./model" +import { ModelRequest } from "./model-request" import { PluginV2 } from "./plugin" import { ProviderV2 } from "./provider" import { Location } from "./location" import { EventV2 } from "./event" import { Policy } from "./policy" import { State } from "./state" +import { Credential } from "./credential" +import { ConnectorSchema } from "./connector/schema" export type ProviderRecord = { provider: ProviderV2.Info @@ -93,10 +96,31 @@ export const layer = Layer.effect( const plugin = yield* PluginV2.Service const events = yield* EventV2.Service const policy = yield* Policy.Service + const credentials = yield* Credential.Service const scope = yield* Scope.Scope - const resolve = (model: ModelV2.Info) => { - const provider = state.get().providers.get(model.providerID)!.provider + const project = (provider: ProviderV2.Info, active: Map) => { + const credential = active.get(ConnectorSchema.ID.make(provider.id)) + if (!credential) return provider + const body = { ...provider.request.body } + if (credential.value.type === "key") { + body.apiKey = credential.value.key + Object.assign(body, credential.value.metadata ?? {}) + } + // kilocode_change start - preserve Kilo organization routing from migrated OAuth credentials + if (credential.value.type === "oauth") { + body.apiKey = credential.value.access + if (credential.value.metadata?.accountID) body.kilocodeOrganizationId = credential.value.metadata.accountID + } + // kilocode_change end + return new ProviderV2.Info({ + ...provider, + enabled: { via: "credential", credentialID: credential.id }, + request: { ...provider.request, body }, + }) + } + + const resolve = (model: ModelV2.Info, provider: ProviderV2.Info) => { const api = model.api.type === "native" && !model.api.url && Object.keys(model.api.settings).length === 0 ? { ...provider.api, id: model.api.id } @@ -106,14 +130,7 @@ export const layer = Layer.effect( ? { ...model.api, settings: { ...provider.api.settings, ...model.api.settings } } : model.api const request = { - headers: { - ...provider.request.headers, - ...model.request.headers, - }, - body: { - ...provider.request.body, - ...model.request.body, - }, + ...ModelRequest.merge({ ...provider.request, generation: {}, options: {} }, model.request), variant: model.request.variant, } return new ModelV2.Info({ @@ -199,6 +216,7 @@ export const layer = Layer.effect( } }), }) + const active = () => credentials.activeAll().pipe(Effect.orDie) yield* events.subscribe(PluginV2.Event.Added).pipe( // Plugin registries are location scoped even though the event bus is process scoped. @@ -207,7 +225,7 @@ export const layer = Layer.effect( event.location?.directory === location.directory && event.location.workspaceID === location.workspaceID, ), Stream.runForEach((event) => - state.update((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"), + state.mutate((catalog) => plugin.triggerFor(event.data.id, "catalog.transform", catalog, {}), "plugin.added"), ), Effect.forkIn(scope, { startImmediately: true }), ) @@ -218,17 +236,18 @@ export const layer = Layer.effect( provider: { get: Effect.fn("CatalogV2.provider.get")(function* (providerID) { const record = yield* getRecord(providerID) - return record.provider + return project(record.provider, yield* active()) }), all: Effect.fn("CatalogV2.provider.all")(function* () { - return Array.fromIterable(state.get().providers.values()).map((record) => record.provider) + const credentials = yield* active() + return Array.fromIterable(state.get().providers.values()).map((record) => + project(record.provider, credentials), + ) }), available: Effect.fn("CatalogV2.provider.available")(function* () { - return Array.fromIterable(state.get().providers.values()) - .map((record) => record.provider) - .filter((provider) => provider.enabled) + return (yield* result.provider.all()).filter((provider) => provider.enabled) }), }, @@ -237,30 +256,36 @@ export const layer = Layer.effect( const record = yield* getRecord(providerID) const model = record.models.get(modelID) if (!model) return yield* new ModelNotFoundError({ providerID, modelID }) - return resolve(model) + return resolve(model, project(record.provider, yield* active())) }), all: Effect.fn("CatalogV2.model.all")(function* () { + const credentials = yield* active() return pipe( Array.fromIterable(state.get().providers.values()), - Array.flatMap((record) => Array.fromIterable(record.models.values())), - Array.map(resolve), + Array.flatMap((record) => { + const provider = project(record.provider, credentials) + return Array.fromIterable(record.models.values()).map((model) => resolve(model, provider)) + }), Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)), ) }), available: Effect.fn("CatalogV2.model.available")(function* () { - return (yield* result.model.all()).filter((model) => { - const record = state.get().providers.get(model.providerID) - return record?.provider.enabled !== false && model.enabled - }) + const providers = new Map((yield* result.provider.all()).map((provider) => [provider.id, provider])) + return (yield* result.model.all()).filter( + (model) => providers.get(model.providerID)?.enabled !== false && model.enabled, + ) }), default: Effect.fn("CatalogV2.model.default")(function* () { const defaultModel = state.get().defaultModel if (defaultModel) { - const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option) - if (Option.isSome(model) && model.value.enabled) return model + const provider = yield* result.provider.get(defaultModel.providerID).pipe(Effect.option) + if (Option.isSome(provider) && provider.value.enabled !== false) { + const model = yield* result.model.get(defaultModel.providerID, defaultModel.modelID).pipe(Effect.option) + if (Option.isSome(model) && model.value.enabled) return model + } } return pipe( @@ -273,10 +298,11 @@ export const layer = Layer.effect( small: Effect.fn("CatalogV2.model.small")(function* (providerID) { const record = state.get().providers.get(providerID) if (!record) return Option.none() + const provider = project(record.provider, yield* active()) if (providerID === ProviderV2.ID.opencode) { const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano")) - if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano)) + if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano, provider)) } const candidates = pipe( @@ -304,7 +330,7 @@ export const layer = Layer.effect( return pipe( items, Array.sortWith((item) => (item.cost / maxCost) * 0.8 + (item.age / maxAge) * 0.2, Order.Number), - Array.map((item) => resolve(item.model)), + Array.map((item) => resolve(item.model, provider)), Array.head, ) } diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index b9a5ae15d8b..f6b8210be12 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -27,6 +27,7 @@ export type Editor = { export interface Interface { readonly transform: State.Interface["transform"] + readonly update: State.Interface["update"] readonly get: (name: string) => Effect.Effect readonly list: () => Effect.Effect } @@ -54,6 +55,7 @@ export const layer = Layer.effect( }) return Service.of({ + update: state.update, transform: state.transform, get: Effect.fn("CommandV2.get")(function* (name) { return state.get().commands.get(name) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 8e1db95ba35..6e651da3698 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -4,6 +4,7 @@ import path from "path" import { type ParseError, parse } from "jsonc-parser" import { Context, Effect, Layer, Option, Schema } from "effect" import { FSUtil } from "./fs-util" +import { Flag } from "./flag/flag" // kilocode_change import { Global } from "./global" import { Location } from "./location" import { PermissionSchema } from "./permission/schema" @@ -118,6 +119,12 @@ export class Directory extends Schema.Class("Config.Directory")({ export type Entry = Document | Directory +export function latest(entries: readonly Entry[], key: K): Info[K] | undefined { + return entries + .filter((entry): entry is Document => entry.type === "document") + .findLast((entry) => entry.info[key] !== undefined)?.info[key] +} + export interface Interface { /** Returns location config documents and supplemental directories from lowest to highest priority. */ readonly entries: () => Effect.Effect @@ -132,7 +139,7 @@ export const layer = Layer.effect( const global = yield* Global.Service const location = yield* Location.Service const policy = yield* Policy.Service - const names = ["config.json", "opencode.json", "opencode.jsonc"] + const names = ["config.json", "kilo.json", "kilo.jsonc", "opencode.json", "opencode.jsonc"] // kilocode_change const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions) const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions) @@ -167,11 +174,11 @@ export const layer = Layer.effect( const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config) // Read configuration once when this location opens. Later calls reuse these // values until the location is reopened. - const discovered = locationIsGlobal + const discovered = locationIsGlobal || Flag.KILO_DISABLE_PROJECT_CONFIG // kilocode_change ? [] : yield* fs .up({ - targets: [".opencode", ...names.toReversed()], + targets: [".kilo", ".kilocode", ...names.toReversed()], // kilocode_change start: location.directory, stop: location.project.directory, }) @@ -179,20 +186,22 @@ export const layer = Layer.effect( const directories = [ globalDirectory, ...discovered - .filter((item) => path.basename(item) === ".opencode") + .filter((item) => [".kilo", ".kilocode"].includes(path.basename(item))) // kilocode_change .toReversed() .map((directory) => AbsolutePath.make(directory)), ] // A config closer to the opened directory should win over one higher up. // Search starts nearby, so reverse the results before applying them. - const directPaths = discovered.filter((item) => path.basename(item) !== ".opencode").toReversed() + // kilocode_change start + const directPaths = discovered.filter((item) => ![".kilo", ".kilocode"].includes(path.basename(item))).toReversed() + // kilocode_change end const direct = yield* Effect.forEach(directPaths, loadFile).pipe( Effect.orDie, Effect.map((configs) => configs.filter((config): config is Document => config !== undefined)), ) const supplementary = yield* Effect.forEach(directories, loadDirectory).pipe(Effect.orDie) // Apply general settings first and more specific settings last: - // global config, project files, then `.opencode` files. + // global config, project files, then Kilo config-directory files. // kilocode_change const configs = [...(supplementary[0] ?? []), ...direct, ...supplementary.slice(1).flat()] // Rules use the opposite order so a user-global rule can override a // repository rule. Statement order inside each file stays unchanged. diff --git a/packages/core/src/config/compaction.ts b/packages/core/src/config/compaction.ts index eef67ee26a0..3c5960c8355 100644 --- a/packages/core/src/config/compaction.ts +++ b/packages/core/src/config/compaction.ts @@ -4,7 +4,6 @@ import { Schema } from "effect" import { NonNegativeInt } from "../schema" export class Keep extends Schema.Class("ConfigV2.Compaction.Keep")({ - turns: NonNegativeInt.pipe(Schema.optional), tokens: NonNegativeInt.pipe(Schema.optional), }) {} diff --git a/packages/core/src/config/mcp.ts b/packages/core/src/config/mcp.ts index fce853815b4..54998e18506 100644 --- a/packages/core/src/config/mcp.ts +++ b/packages/core/src/config/mcp.ts @@ -6,6 +6,9 @@ import { PositiveInt } from "../schema" export class Local extends Schema.Class("ConfigV2.MCP.Local")({ type: Schema.Literal("local"), command: Schema.String.pipe(Schema.Array), + cwd: Schema.String.pipe(Schema.optional).annotate({ + description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.", + }), environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), disabled: Schema.Boolean.pipe(Schema.optional), timeout: PositiveInt.pipe(Schema.optional), diff --git a/packages/core/src/config/plugin/agent.ts b/packages/core/src/config/plugin/agent.ts index 5b183f8ead1..36534b0d382 100644 --- a/packages/core/src/config/plugin/agent.ts +++ b/packages/core/src/config/plugin/agent.ts @@ -58,8 +58,7 @@ export const Plugin = PluginV2.define({ yield* agent.update((editor) => { const global = documents.flatMap((document) => document.info.permissions ?? []) - const configuredDefault = documents.findLast((document) => document.info.default_agent !== undefined)?.info - .default_agent + const configuredDefault = Config.latest(documents, "default_agent") if (configuredDefault !== undefined) editor.default(AgentV2.ID.make(configuredDefault)) for (const current of editor.list()) { editor.update(current.id, (agent) => agent.permissions.push(...global)) diff --git a/packages/core/src/config/plugin/provider.ts b/packages/core/src/config/plugin/provider.ts index 75afe93257b..0d31b32d08f 100644 --- a/packages/core/src/config/plugin/provider.ts +++ b/packages/core/src/config/plugin/provider.ts @@ -4,6 +4,7 @@ import { Effect } from "effect" import { Catalog } from "../../catalog" import { Config } from "../../config" import { ModelV2 } from "../../model" +import { ModelRequest } from "../../model-request" import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" @@ -13,9 +14,15 @@ export const Plugin = PluginV2.define({ const catalog = yield* Catalog.Service const config = yield* Config.Service const transform = yield* catalog.transform() - const files = (yield* config.entries()).filter((entry): entry is Config.Document => entry.type === "document") + const entries = yield* config.entries() + const files = entries.filter((entry): entry is Config.Document => entry.type === "document") yield* transform((catalog) => { + const configuredDefault = Config.latest(entries, "model") + if (configuredDefault !== undefined) { + const model = ModelV2.parse(configuredDefault) + catalog.model.default.set(model.providerID, model.modelID) + } for (const file of files) { for (const [id, item] of Object.entries(file.info.providers ?? {})) { const providerID = ProviderV2.ID.make(id) @@ -25,16 +32,19 @@ export const Plugin = PluginV2.define({ provider.enabled = { via: "custom", data: {} } if (item.api !== undefined) provider.api = { ...item.api } if (item.request !== undefined) { - Object.assign(provider.request.headers, item.request.headers ?? {}) - Object.assign(provider.request.body, item.request.body ?? {}) + Object.assign(provider.request.headers, item.request.headers) + Object.assign(provider.request.body, item.request.body) } }) + const providerApi = catalog.provider.get(providerID)?.provider.api + const providerPackage = providerApi?.type === "aisdk" ? providerApi.package : undefined for (const [id, config] of Object.entries(item.models ?? {})) { catalog.model.update(providerID, ModelV2.ID.make(id), (model) => { if (config.family !== undefined) model.family = config.family if (config.name !== undefined) model.name = config.name if (config.api !== undefined) model.api = { ...model.api, ...config.api } + const packageName = model.api.type === "aisdk" ? model.api.package : providerPackage if (config.capabilities !== undefined) { model.capabilities = { tools: config.capabilities.tools, @@ -43,8 +53,10 @@ export const Plugin = PluginV2.define({ } } if (config.request !== undefined) { - Object.assign(model.request.headers, config.request.headers ?? {}) - Object.assign(model.request.body, config.request.body ?? {}) + ModelRequest.assign(model.request, { + headers: config.request.headers, + ...ModelRequest.normalizeAiSdkOptions(packageName, config.request.body ?? {}), + }) if (config.request.variant !== undefined) model.request.variant = config.request.variant } if (config.variants !== undefined) { @@ -55,11 +67,15 @@ export const Plugin = PluginV2.define({ id: variant.id, headers: {}, body: {}, + generation: {}, + options: {}, } model.variants.push(existing) } - Object.assign(existing.headers, variant.headers ?? {}) - Object.assign(existing.body, variant.body ?? {}) + ModelRequest.assign(existing, { + headers: variant.headers, + ...ModelRequest.normalizeAiSdkOptions(packageName, variant.body ?? {}), + }) } } if (config.cost !== undefined) { diff --git a/packages/core/src/config/plugin/reference.ts b/packages/core/src/config/plugin/reference.ts new file mode 100644 index 00000000000..91f4e740a3e --- /dev/null +++ b/packages/core/src/config/plugin/reference.ts @@ -0,0 +1,72 @@ +export * as ConfigReferencePlugin from "./reference" + +import path from "path" +import { Effect } from "effect" +import { Config } from "../../config" +import { ConfigReference } from "../reference" +import { Global } from "../../global" +import { Location } from "../../location" +import { PluginV2 } from "../../plugin" +import { Reference } from "../../reference" +import { AbsolutePath } from "../../schema" + +export const Plugin = { + id: PluginV2.ID.make("core/config-reference"), + effect: Effect.gen(function* () { + const config = yield* Config.Service + const global = yield* Global.Service + const location = yield* Location.Service + const references = yield* Reference.Service + const update = yield* references.transform() + const entries = new Map() + for (const doc of (yield* config.entries()).filter( + (entry): entry is Config.Document => entry.type === "document", + )) { + // kilocode_change start + const root = path.parse(location.project.directory).root + const directory = location.project.directory === root ? location.directory : location.project.directory + // kilocode_change end + for (const [name, entry] of Object.entries(doc.info.references ?? {})) { + if (!validAlias(name)) continue + entries.set( + name, + local(entry) + ? new Reference.LocalSource({ + type: "local", + path: AbsolutePath.make( + localPath(directory, global.home, typeof entry === "string" ? entry : entry.path), + ), + description: typeof entry === "string" ? undefined : entry.description, + hidden: typeof entry === "string" ? undefined : entry.hidden, + }) + : new Reference.GitSource({ + type: "git", + repository: typeof entry === "string" ? entry : entry.repository, + branch: typeof entry === "string" ? undefined : entry.branch, + description: typeof entry === "string" ? undefined : entry.description, + hidden: typeof entry === "string" ? undefined : entry.hidden, + }), + ) + } + } + + yield* update((editor) => { + for (const [name, source] of entries) editor.add(name, source) + }) + }), +} + +function validAlias(name: string) { + return name.length > 0 && !/[\/\s`,]/.test(name) +} + +function local(entry: ConfigReference.Entry): entry is string | ConfigReference.Local { + return typeof entry === "string" + ? entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~") + : "path" in entry +} + +function localPath(directory: string, home: string, value: string) { + if (value.startsWith("~/")) return path.join(home, value.slice(2)) + return path.isAbsolute(value) ? value : path.resolve(directory, value) +} diff --git a/packages/core/src/config/reference.ts b/packages/core/src/config/reference.ts index fbd6c840da6..4518eb4f87d 100644 --- a/packages/core/src/config/reference.ts +++ b/packages/core/src/config/reference.ts @@ -5,10 +5,14 @@ import { Schema } from "effect" export class Git extends Schema.Class("ConfigV2.Reference.Git")({ repository: Schema.String, branch: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + hidden: Schema.Boolean.pipe(Schema.optional), }) {} export class Local extends Schema.Class("ConfigV2.Reference.Local")({ path: Schema.String, + description: Schema.String.pipe(Schema.optional), + hidden: Schema.Boolean.pipe(Schema.optional), }) {} export const Entry = Schema.Union([Schema.String, Git, Local]) @@ -16,33 +20,3 @@ export type Entry = typeof Entry.Type export const Info = Schema.Record(Schema.String, Entry) export type Info = typeof Info.Type - -export type NormalizedEntry = - | { readonly kind: "local"; readonly path: string } - | { readonly kind: "git"; readonly repository: string; readonly branch?: string } - | { readonly kind: "invalid"; readonly message: string } - -export type NormalizedInfo = Record - -export function validateAlias(name: string) { - if (name.length === 0) return "Reference alias must not be empty" - if (/[\/\s`,]/.test(name)) return "Reference alias must not contain /, whitespace, comma, or backtick" -} - -export function normalizeEntry(entry: Entry): NormalizedEntry { - if (typeof entry === "string") { - if (entry.startsWith(".") || entry.startsWith("/") || entry.startsWith("~")) return { kind: "local", path: entry } - return { kind: "git", repository: entry } - } - if ("path" in entry) return { kind: "local", path: entry.path } - return { kind: "git", repository: entry.repository, branch: entry.branch } -} - -export function normalize(info: Info): NormalizedInfo { - return Object.fromEntries( - Object.entries(info).map(([name, entry]) => { - const message = validateAlias(name) - return [name, message ? { kind: "invalid" as const, message } : normalizeEntry(entry)] - }), - ) -} diff --git a/packages/core/src/connector.ts b/packages/core/src/connector.ts new file mode 100644 index 00000000000..24656a0067a --- /dev/null +++ b/packages/core/src/connector.ts @@ -0,0 +1,538 @@ +export * as Connector from "./connector" + +import { Cause, Clock, Context, Duration, Effect, Exit, Layer, Schedule, Schema, Scope, SynchronizedRef } from "effect" +import { castDraft, enableMapSet, type Draft } from "immer" +import { Credential } from "./credential" +import { ConnectorSchema } from "./connector/schema" +import { withStatics } from "./schema" +import { State } from "./state" +import { Identifier } from "./util/identifier" +import { KeyedMutex } from "./effect/keyed-mutex" +import { EventV2 } from "./event" + +export const ID = ConnectorSchema.ID +export type ID = ConnectorSchema.ID + +export const MethodID = ConnectorSchema.MethodID +export type MethodID = ConnectorSchema.MethodID + +export const AttemptID = Schema.String.pipe( + Schema.brand("Connector.AttemptID"), + withStatics((schema) => ({ create: () => schema.make("con_" + Identifier.ascending()) })), +) +export type AttemptID = typeof AttemptID.Type + +export const When = Schema.Struct({ + key: Schema.String, + op: Schema.Literals(["eq", "neq"]), + value: Schema.String, +}).annotate({ identifier: "Connector.When" }) +export type When = typeof When.Type + +export class TextPrompt extends Schema.Class("Connector.TextPrompt")({ + type: Schema.Literal("text"), + key: Schema.String, + message: Schema.String, + placeholder: Schema.optional(Schema.String), + when: Schema.optional(When), +}) {} + +export class SelectPrompt extends Schema.Class("Connector.SelectPrompt")({ + type: Schema.Literal("select"), + key: Schema.String, + message: Schema.String, + options: Schema.Array( + Schema.Struct({ + label: Schema.String, + value: Schema.String, + hint: Schema.optional(Schema.String), + }), + ), + when: Schema.optional(When), +}) {} + +export const Prompt = Schema.Union([TextPrompt, SelectPrompt]).pipe(Schema.toTaggedUnion("type")) +export type Prompt = typeof Prompt.Type + +export class OAuthMethod extends Schema.Class("Connector.OAuthMethod")({ + id: MethodID, + type: Schema.Literal("oauth"), + label: Schema.String, + prompts: Schema.optional(Schema.Array(Prompt)), +}) {} + +export class KeyMethod extends Schema.Class("Connector.KeyMethod")({ + id: MethodID, + type: Schema.Literal("key"), + label: Schema.String, + prompts: Schema.optional(Schema.Array(Prompt)), +}) {} + +export const Method = Schema.Union([OAuthMethod, KeyMethod]).pipe(Schema.toTaggedUnion("type")) +export type Method = typeof Method.Type + +export class Info extends Schema.Class("Connector.Info")({ + id: ID, + name: Schema.String, + methods: Schema.Array(Method), +}) {} + +export type Inputs = Readonly<{ [key: string]: string }> + +export type OAuthAuthorization = { + readonly url: string + readonly instructions: string +} & ( + | { + readonly mode: "auto" + readonly callback: Effect.Effect + } + | { + readonly mode: "code" + readonly callback: (code: string) => Effect.Effect + } +) + +export interface OAuthImplementation { + readonly connectorID: ID + readonly method: OAuthMethod + readonly authorize: (inputs: Inputs) => Effect.Effect + readonly refresh?: (credential: Credential.OAuth) => Effect.Effect +} + +export interface KeyImplementation { + readonly connectorID: ID + readonly method: KeyMethod + readonly authorize: (key: string, inputs: Inputs) => Effect.Effect +} + +export type Implementation = OAuthImplementation | KeyImplementation + +function isKeyImplementation(implementation: Implementation): implementation is KeyImplementation { + return implementation.method.type === "key" +} + +function isOAuthImplementation(implementation: Implementation): implementation is OAuthImplementation { + return implementation.method.type === "oauth" +} + +export class Attempt extends Schema.Class("Connector.Attempt")({ + attemptID: AttemptID, + url: Schema.String, + instructions: Schema.String, + mode: Schema.Literals(["auto", "code"]), + time: Schema.Struct({ + created: Schema.Number, + expires: Schema.Number, + }), +}) {} + +const Time = Schema.Struct({ + created: Schema.Number, + expires: Schema.Number, +}) + +export const AttemptStatus = Schema.Union([ + Schema.Struct({ status: Schema.Literal("pending"), time: Time }), + Schema.Struct({ status: Schema.Literal("complete"), time: Time }), + Schema.Struct({ status: Schema.Literal("failed"), message: Schema.String, time: Time }), + Schema.Struct({ status: Schema.Literal("expired"), time: Time }), +]).pipe(Schema.toTaggedUnion("status")) +export type AttemptStatus = typeof AttemptStatus.Type + +export class CodeRequiredError extends Schema.TaggedErrorClass()("Connector.CodeRequired", { + attemptID: AttemptID, +}) {} + +export class AuthorizationError extends Schema.TaggedErrorClass()("Connector.Authorization", { + cause: Schema.Defect, +}) {} + +export type Error = CodeRequiredError | AuthorizationError + +export const Event = { + Updated: EventV2.define({ + type: "connector.updated", + schema: {}, + }), +} + +type Entry = { + connector: Info + implementations: Map +} + +type Data = { + connectors: Map +} + +export type Editor = { + list: () => readonly Info[] + get: (id: ID) => Info | undefined + update: (id: ID, update: (connector: Draft>) => void) => void + remove: (id: ID) => void + method: { + update: (implementation: Implementation) => void + remove: (connectorID: ID, methodID: MethodID) => void + } +} + +export interface Interface { + /** Registers a scoped transform over the connector registry. */ + readonly transform: State.Interface["transform"] + /** Registers and immediately applies a scoped connector registry update. */ + readonly update: State.Interface["update"] + /** Returns one connector with its serializable login methods. */ + readonly get: (id: ID) => Effect.Effect + /** Returns all connectors with their serializable login methods. */ + readonly list: () => Effect.Effect + /** Refreshes an OAuth credential with its originating method. */ + readonly refresh: (credentialID: Credential.ID) => Effect.Effect + readonly connect: { + /** Runs a key method and stores the resulting credential. */ + readonly key: (input: { + /** Connector receiving the credential. */ + readonly connectorID: ID + /** Key method selected by the caller. */ + readonly methodID: MethodID + /** Secret entered by the user. */ + readonly key: string + /** Answers to the method's optional prompts. */ + readonly inputs: Inputs + /** User-facing label for the stored credential. */ + readonly label?: string + }) => Effect.Effect + readonly oauth: { + /** Starts a stateful OAuth attempt. */ + readonly begin: (input: { + /** Connector being authenticated. */ + readonly connectorID: ID + /** OAuth method selected by the caller. */ + readonly methodID: MethodID + /** Answers to the method's optional prompts. */ + readonly inputs: Inputs + /** User-facing label for the credential created on completion. */ + readonly label?: string + }) => Effect.Effect + /** Returns the current state of an OAuth attempt. */ + readonly status: (attemptID: AttemptID) => Effect.Effect + /** Completes the attempt and stores its credential. */ + readonly complete: (input: { + /** Opaque handle returned by `begin`. */ + readonly attemptID: AttemptID + /** Authorization code required by attempts in code mode. */ + readonly code?: string + }) => Effect.Effect + /** Cancels an attempt and releases its resources. */ + readonly cancel: (attemptID: AttemptID) => Effect.Effect + } + } +} + +export class Service extends Context.Service()("@opencode/v2/Connector") {} + +enableMapSet() + +const attemptLifetime = Duration.toMillis(Duration.minutes(10)) +const terminalRetention = Duration.toMillis(Duration.minutes(1)) +const scrubInterval = Duration.seconds(30) +const settlementTimeout = Duration.seconds(30) // kilocode_change - bound retained OAuth attempt secrets + +type AttemptTime = { created: number; expires: number } +type PendingAttempt = { + status: "pending" + completing: boolean + settling: boolean // kilocode_change - cancellation and expiry cannot overtake credential persistence + authorization: OAuthAuthorization + connectorID: ID + methodID: MethodID + label?: string + scope: Scope.Closeable + time: AttemptTime +} +type TerminalAttempt = { + status: "complete" | "failed" | "expired" + message?: string + removeAt: number + time: AttemptTime +} +type AttemptEntry = PendingAttempt | TerminalAttempt + +export const locationLayer = Layer.effect( + Service, + Effect.gen(function* () { + const credentials = yield* Credential.Service + const events = yield* EventV2.Service + const scope = yield* Scope.Scope + const attempts = SynchronizedRef.makeUnsafe(new Map()) + const refreshLocks = KeyedMutex.makeUnsafe() + const state = State.create({ + initial: () => ({ connectors: new Map() }), + editor: (draft) => ({ + list: () => Array.from(draft.connectors.values(), (entry) => entry.connector) as Info[], + get: (id) => draft.connectors.get(id)?.connector as Info | undefined, + update: (id, update) => { + const current = + draft.connectors.get(id) ?? + castDraft({ connector: new Info({ id, name: id, methods: [] }), implementations: new Map() }) + if (!draft.connectors.has(id)) draft.connectors.set(id, current) + update(current.connector) + current.connector.id = id + }, + remove: (id) => draft.connectors.delete(id), + method: { + update: (implementation) => { + const current = + draft.connectors.get(implementation.connectorID) ?? + castDraft({ + connector: new Info({ id: implementation.connectorID, name: implementation.connectorID, methods: [] }), + implementations: new Map(), + }) + if (!draft.connectors.has(implementation.connectorID)) { + draft.connectors.set(implementation.connectorID, current) + } + const index = current.connector.methods.findIndex((method) => method.id === implementation.method.id) + if (index === -1) current.connector.methods.push(castDraft(implementation.method)) + else current.connector.methods[index] = castDraft(implementation.method) + current.implementations.set(implementation.method.id, castDraft(implementation)) + }, + remove: (connectorID, methodID) => { + const current = draft.connectors.get(connectorID) + if (!current) return + const index = current.connector.methods.findIndex((method) => method.id === methodID) + if (index !== -1) current.connector.methods.splice(index, 1) + current.implementations.delete(methodID) + }, + }, + }), + finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), + }) + + const authorize = (effect: Effect.Effect) => + effect.pipe(Effect.mapError((cause) => new AuthorizationError({ cause }))) + + const close = (attemptScope: Scope.Closeable) => + Scope.close(attemptScope, Exit.void).pipe(Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) + + const message = (cause: Cause.Cause) => { + const error = Cause.squash(cause) + return error instanceof Error ? error.message : String(error) + } + + // kilocode_change start - persist before exposing completion and make settlement atomic with cancellation + const settle = Effect.fnUntraced(function* ( + attemptID: AttemptID, + exit: Exit.Exit, + owned = false, + ) { + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const pending = yield* SynchronizedRef.modify(attempts, (current) => { + const attempt = current.get(attemptID) + if (!attempt || attempt.status !== "pending") return [undefined, current] + if (owned) return attempt.settling ? [attempt, current] : [undefined, current] + if (attempt.settling) return [undefined, current] + return [attempt, new Map(current).set(attemptID, { ...attempt, settling: true })] + }) + if (!pending) return + const settled = Exit.isSuccess(exit) + ? yield* restore( + credentials + .create({ + connectorID: pending.connectorID, + methodID: pending.methodID, + label: pending.label, + value: exit.value, + }) + .pipe( + Effect.timeout(settlementTimeout), + Effect.mapError((cause) => new AuthorizationError({ cause })), + ), + ).pipe(Effect.asVoid, Effect.exit) + : Exit.failCause(exit.cause) + const now = yield* Clock.currentTimeMillis + const result = yield* SynchronizedRef.modify(attempts, (current) => { + const attempt = current.get(attemptID) + if (!attempt || attempt.status !== "pending") return [undefined, current] + const terminal: TerminalAttempt = Exit.isSuccess(settled) + ? { status: "complete", time: attempt.time, removeAt: now + terminalRetention } + : { + status: "failed", + message: message(settled.cause), + time: attempt.time, + removeAt: now + terminalRetention, + } + return [attempt, new Map(current).set(attemptID, terminal)] + }) + if (!result) return settled + yield* close(result.scope) + return settled + }), + ) + }) + // kilocode_change end + + const scrub = Effect.fnUntraced(function* () { + const now = yield* Clock.currentTimeMillis + const expired = yield* SynchronizedRef.modify(attempts, (current) => { + const next = new Map(current) + const scopes: Scope.Closeable[] = [] + for (const [id, attempt] of current) { + if (attempt.status === "pending" && !attempt.settling && attempt.time.expires <= now) { // kilocode_change + scopes.push(attempt.scope) + next.set(id, { status: "expired", time: attempt.time, removeAt: now + terminalRetention }) + continue + } + if (attempt.status !== "pending" && attempt.removeAt <= now) next.delete(id) + } + return [scopes, next] + }) + yield* Effect.forEach(expired, close, { discard: true }) + }) + + yield* scrub().pipe(Effect.repeat(Schedule.spaced(scrubInterval)), Effect.forkIn(scope)) + + return Service.of({ + transform: state.transform, + update: state.update, + get: Effect.fn("Connector.get")(function* (id) { + return state.get().connectors.get(id)?.connector + }), + list: Effect.fn("Connector.list")(function* () { + return Array.from(state.get().connectors.values(), (record) => record.connector).toSorted((a, b) => + a.name.localeCompare(b.name), + ) + }), + refresh: Effect.fn("Connector.refresh")(function* (credentialID) { + yield* refreshLocks.withLock(credentialID)( + Effect.gen(function* () { + const credential = yield* credentials.get(credentialID) + if (!credential || credential.value.type !== "oauth") { + return yield* Effect.die(`OAuth credential not found: ${credentialID}`) + } + const implementation = state + .get() + .connectors.get(credential.connectorID) + ?.implementations.get(credential.methodID) + if (!implementation || !isOAuthImplementation(implementation) || !implementation.refresh) { + return yield* Effect.die( + `OAuth refresh method not found: ${credential.connectorID}/${credential.methodID}`, + ) + } + const value = yield* authorize(implementation.refresh(credential.value)) + yield* credentials.update(credential.id, { value }) + }), + ) + }), + connect: { + key: Effect.fn("Connector.connect.key")(function* (input) { + const method = state.get().connectors.get(input.connectorID)?.implementations.get(input.methodID) + if (!method || !isKeyImplementation(method)) { + return yield* Effect.die(`Key method not found: ${input.connectorID}/${input.methodID}`) + } + const value = yield* authorize(method.authorize(input.key, input.inputs)) + yield* credentials.create({ + connectorID: input.connectorID, + methodID: input.methodID, + label: input.label, + value, + }) + }), + oauth: { + begin: Effect.fn("Connector.connect.oauth.begin")(function* (input) { + const method = state.get().connectors.get(input.connectorID)?.implementations.get(input.methodID) + if (!method || !isOAuthImplementation(method)) { + return yield* Effect.die(`OAuth method not found: ${input.connectorID}/${input.methodID}`) + } + const attemptScope = yield* Scope.fork(scope) + const authorization = yield* authorize(method.authorize(input.inputs)).pipe( + Scope.provide(attemptScope), + Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(attemptScope, exit) : Effect.void)), + ) + const id = AttemptID.create() + const created = yield* Clock.currentTimeMillis + const time = { created, expires: created + attemptLifetime } + yield* SynchronizedRef.update(attempts, (current) => + new Map(current).set(id, { + status: "pending", + completing: authorization.mode === "auto", + settling: false, // kilocode_change + authorization, + connectorID: input.connectorID, + methodID: input.methodID, + label: input.label, + scope: attemptScope, + time, + }), + ) + if (authorization.mode === "auto") { + // kilocode_change start - settle persistence atomically with cancellation + yield* authorize(authorization.callback).pipe( + Effect.exit, + Effect.flatMap((exit) => settle(id, exit)), + Effect.forkIn(attemptScope, { startImmediately: true }), + ) + // kilocode_change end + } + return new Attempt({ + attemptID: id, + url: authorization.url, + instructions: authorization.instructions, + mode: authorization.mode, + time, + }) + }), + status: Effect.fn("Connector.connect.oauth.status")(function* (attemptID) { + const attempt = (yield* SynchronizedRef.get(attempts)).get(attemptID) + if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${attemptID}`) + if (attempt.status === "failed") { + return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time } + } + return { status: attempt.status, time: attempt.time } + }), + complete: Effect.fn("Connector.connect.oauth.complete")(function* (input) { + const attempt = yield* SynchronizedRef.modify(attempts, (current) => { + const match = current.get(input.attemptID) + if (!match || match.status !== "pending" || match.completing) return [match, current] + if (match.authorization.mode === "code" && input.code === undefined) return [match, current] + return [match, new Map(current).set(input.attemptID, { ...match, completing: true, settling: true })] // kilocode_change + }) + if (!attempt) return yield* Effect.die(`OAuth attempt not found: ${input.attemptID}`) + if (attempt.status !== "pending") return + if (attempt.authorization.mode === "code" && input.code === undefined) { + return yield* new CodeRequiredError({ attemptID: input.attemptID }) + } + if (attempt.completing) return yield* Effect.die(`OAuth attempt already completing: ${input.attemptID}`) + const callback = + attempt.authorization.mode === "auto" + ? attempt.authorization.callback + : attempt.authorization.callback(input.code as string) + // kilocode_change start - an interrupted or timed-out callback still settles and releases its attempt. + return yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const exit = yield* restore(authorize(callback)).pipe( + Effect.timeout(settlementTimeout), + Effect.mapError((cause) => new AuthorizationError({ cause })), + Effect.exit, + ) + const settled = yield* settle(input.attemptID, exit, true) + if (settled && Exit.isFailure(settled)) return yield* settled + }), + ) + // kilocode_change end + }), + cancel: Effect.fn("Connector.connect.oauth.cancel")(function* (attemptID) { + const attempt = yield* SynchronizedRef.modify(attempts, (current) => { + const match = current.get(attemptID) + if (!match || match.status !== "pending" || match.settling) return [undefined, current] // kilocode_change + const next = new Map(current) + next.delete(attemptID) + return [match, next] + }) + if (attempt) yield* Scope.close(attempt.scope, Exit.void) + }), + }, + }, + }) + }), +) diff --git a/packages/core/src/connector/schema.ts b/packages/core/src/connector/schema.ts new file mode 100644 index 00000000000..40c43faab84 --- /dev/null +++ b/packages/core/src/connector/schema.ts @@ -0,0 +1,9 @@ +export * as ConnectorSchema from "./schema" + +import { Schema } from "effect" + +export const ID = Schema.String.pipe(Schema.brand("Connector.ID")) +export type ID = typeof ID.Type + +export const MethodID = Schema.String.pipe(Schema.brand("Connector.MethodID")) +export type MethodID = typeof MethodID.Type diff --git a/packages/core/src/control-plane/move-session.ts b/packages/core/src/control-plane/move-session.ts index 9825cbf9339..0239eecada2 100644 --- a/packages/core/src/control-plane/move-session.ts +++ b/packages/core/src/control-plane/move-session.ts @@ -6,6 +6,7 @@ import { Git } from "../git" import { Location } from "../location" import { ProjectV2 } from "../project" import { SessionV2 } from "../session" +import { SessionExecution } from "../session/execution" import { SessionEvent } from "../session/event" import { SessionSchema } from "../session/schema" import { AbsolutePath, RelativePath } from "../schema" @@ -124,5 +125,6 @@ export const defaultLayer = layer.pipe( Layer.provide(Git.defaultLayer), Layer.provide(EventV2.defaultLayer), Layer.provide(ProjectV2.defaultLayer), + Layer.provide(SessionExecution.noopLayer), Layer.provide(SessionV2.defaultLayer), ) diff --git a/packages/core/src/credential.ts b/packages/core/src/credential.ts new file mode 100644 index 00000000000..5b532eb8c20 --- /dev/null +++ b/packages/core/src/credential.ts @@ -0,0 +1,620 @@ +export * as Credential from "./credential" + +// kilocode_change start +import { and, asc, desc, eq, ne } from "drizzle-orm" +import { Context, Effect, Layer, Option, Schema, Semaphore } from "effect" +// kilocode_change end +import { Database } from "./database/database" +import { ConnectorSchema } from "./connector/schema" +import { EventV2 } from "./event" +import { NonNegativeInt, withStatics } from "./schema" +import { CredentialTable } from "./credential/sql" +import { Identifier } from "./util/identifier" +import { FSUtil } from "./fs-util" +import { Global } from "./global" +import { DataMigrationTable } from "./data-migration.sql" +import path from "path" +import { parse as parseKiloAccounts } from "./kilocode/credential-migration" // kilocode_change + +export const ID = Schema.String.pipe( + Schema.brand("Credential.ID"), + withStatics((schema) => ({ create: () => schema.make("cred_" + Identifier.ascending()) })), +) +export type ID = typeof ID.Type + +export class OAuth extends Schema.Class("Credential.OAuth")({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export class Key extends Schema.Class("Credential.Key")({ + type: Schema.Literal("key"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) {} + +export const Value = Schema.Union([OAuth, Key]) + .pipe(Schema.toTaggedUnion("type")) + .annotate({ identifier: "Credential.Value" }) +export type Value = Schema.Schema.Type + +const LegacyOAuth = Schema.Struct({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, + accountId: Schema.optional(Schema.String), + enterpriseUrl: Schema.optional(Schema.String), +}) + +const LegacyKey = Schema.Struct({ + type: Schema.Literal("api"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) + +// kilocode_change start - recognize config-bootstrap credentials without projecting them into model credentials +const LegacyWellKnown = Schema.Struct({ + type: Schema.Literal("wellknown"), + key: Schema.String, + token: Schema.String, +}) + +const LegacyValue = Schema.Union([LegacyOAuth, LegacyKey]) +const LegacyAuth = Schema.Union([LegacyOAuth, LegacyKey, LegacyWellKnown]) +// kilocode_change end + +export class Info extends Schema.Class("Credential.Info")({ + id: ID, + connectorID: ConnectorSchema.ID, + methodID: ConnectorSchema.MethodID, + label: Schema.String, + value: Value, +}) {} + +export const Event = { + Added: EventV2.define({ + type: "credential.added", + schema: { credential: Info }, + }), + Removed: EventV2.define({ + type: "credential.removed", + schema: { credential: Info }, + }), + Switched: EventV2.define({ + type: "credential.switched", + schema: { + connectorID: ConnectorSchema.ID, + from: Schema.optional(ID), + to: Schema.optional(ID), + }, + }), +} + +export interface Interface { + readonly get: (id: ID) => Effect.Effect + readonly all: () => Effect.Effect + readonly create: (input: { + connectorID: ConnectorSchema.ID + methodID: ConnectorSchema.MethodID + value: Value + label?: string + }) => Effect.Effect + readonly update: (id: ID, updates: Partial>) => Effect.Effect + readonly remove: (id: ID) => Effect.Effect + readonly activate: (id: ID) => Effect.Effect + readonly active: (connectorID: ConnectorSchema.ID) => Effect.Effect + readonly activeAll: () => Effect.Effect> + readonly forConnector: (connectorID: ConnectorSchema.ID) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Credential") {} + +export const legacyImportLayer = Layer.effectDiscard( + Effect.gen(function* () { + const { db } = yield* Database.Service + const fs = yield* FSUtil.Service + const global = yield* Global.Service + // kilocode_change start - preserve Kilo's multi-account JSON stores before the upstream auth.json fallback + const kiloName = "credential.kilo-account-json" + if (!(yield* db.select().from(DataMigrationTable).where(eq(DataMigrationTable.name, kiloName)).get())) { + const current = yield* fs.readJson(path.join(global.data, "account.json")).pipe(Effect.option) + const prior = yield* fs.readJson(path.join(global.data, "auth-v2.json")).pipe(Effect.option) + const raw = Option.isSome(current) ? current.value : Option.getOrUndefined(prior) + const values = parseKiloAccounts(raw) + if (values.length > 0) { + yield* db.transaction((tx) => + Effect.gen(function* () { + const existing = new Set( + (yield* tx.select({ connectorID: CredentialTable.connector_id }).from(CredentialTable).all()).map( + (item) => item.connectorID, + ), + ) + for (const item of values) { + const connector = ConnectorSchema.ID.make(item.connectorID.replace(/\/+$/, "")) + if (existing.has(connector)) continue + const value: Value = + item.credential.type === "api" + ? new Key({ + type: "key", + key: item.credential.key, + metadata: item.credential.metadata, + }) + : new OAuth({ + type: "oauth", + refresh: item.credential.refresh, + access: item.credential.access, + expires: item.credential.expires, + metadata: { + ...(item.credential.accountId ? { accountID: item.credential.accountId } : {}), + ...(item.credential.enterpriseUrl ? { enterpriseURL: item.credential.enterpriseUrl } : {}), + }, + }) + yield* tx.insert(CredentialTable).values({ + id: ID.create(), + connector_id: connector, + method_id: ConnectorSchema.MethodID.make( + item.credential.type === "api" + ? "api-key" + : connector === ConnectorSchema.ID.make("openai") + ? "chatgpt-browser" + : "oauth", + ), + label: item.label, + value, + active: item.active, + }) + } + yield* tx.insert(DataMigrationTable).values({ name: kiloName, time_completed: Date.now() }).run() + }), + ) + } + } + // kilocode_change end + const name = "credential.auth-json" + const raw = yield* fs.readJson(path.join(global.data, "auth.json")).pipe(Effect.option) + if (Option.isNone(raw) || typeof raw.value !== "object" || raw.value === null || Array.isArray(raw.value)) return + const decode = Schema.decodeUnknownOption(LegacyValue) + const values = Object.entries(raw.value).flatMap(([connectorID, value]) => { + const decoded = decode(value) + if (Option.isNone(decoded)) return [] + const credential = decoded.value + const id = ID.create() + const connector = ConnectorSchema.ID.make(connectorID.replace(/\/+$/, "")) + const methodID = ConnectorSchema.MethodID.make( + credential.type === "api" + ? "api-key" + : connector === ConnectorSchema.ID.make("openai") + ? "chatgpt-browser" + : "oauth", + ) + const next: Value = + credential.type === "api" + ? new Key({ type: "key", key: credential.key, metadata: credential.metadata }) + : new OAuth({ + type: "oauth", + refresh: credential.refresh, + access: credential.access, + expires: credential.expires, + metadata: { + ...(credential.accountId ? { accountID: credential.accountId } : {}), + ...(credential.enterpriseUrl ? { enterpriseURL: credential.enterpriseUrl } : {}), + }, + }) + return [{ id, connectorID: connector, methodID, value: next }] + }) + yield* db.transaction((tx) => + Effect.gen(function* () { + for (const item of values) { + // kilocode_change start - reconcile on every startup so a released client can update auth.json after import. + const current = yield* tx + .select() + .from(CredentialTable) + .where(eq(CredentialTable.connector_id, item.connectorID)) + .orderBy(desc(CredentialTable.active), asc(CredentialTable.time_created)) + .get() + yield* tx + .update(CredentialTable) + .set({ active: false }) + .where(eq(CredentialTable.connector_id, item.connectorID)) + .run() + if (current) { + yield* tx + .update(CredentialTable) + .set({ method_id: item.methodID, value: item.value, active: true }) + .where(eq(CredentialTable.id, current.id)) + .run() + continue + } + yield* tx.insert(CredentialTable).values({ + id: item.id, + connector_id: item.connectorID, + method_id: item.methodID, + label: "Imported", + value: item.value, + active: true, + }) + // kilocode_change end + } + yield* tx.insert(DataMigrationTable).values({ name, time_completed: Date.now() }).onConflictDoNothing().run() + }), + ) + }).pipe(Effect.orDie), +) + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const { db } = yield* Database.Service + const events = yield* EventV2.Service + // kilocode_change start + const fs = Option.getOrUndefined(yield* Effect.serviceOption(FSUtil.Service)) + const global = Option.getOrUndefined(yield* Effect.serviceOption(Global.Service)) + // kilocode_change end + const decodeValue = Schema.decodeUnknownSync(Value) + const info = (row: typeof CredentialTable.$inferSelect) => + new Info({ + id: row.id, + connectorID: row.connector_id, + methodID: row.method_id, + label: row.label, + value: decodeValue(row.value), + }) + + // kilocode_change start - process-local workspace credentials override host storage without being persisted + const content = process.env.KILO_AUTH_CONTENT + const injected = yield* content === undefined + ? Effect.succeed(new Map()) + : Effect.try({ + try: () => JSON.parse(content) as unknown, + catch: (cause) => cause, + }).pipe( + Effect.flatMap((raw) => { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return Effect.succeed(new Map()) + } + const decode = Schema.decodeUnknownOption(LegacyAuth) + return Effect.succeed( + new Map( + Object.entries(raw).flatMap(([name, raw]) => { + const decoded = decode(raw) + if (Option.isNone(decoded) || decoded.value.type === "wellknown") return [] + const credential = decoded.value + const connectorID = ConnectorSchema.ID.make(name.replace(/\/+$/, "")) + const value: Value = + credential.type === "api" + ? new Key({ type: "key", key: credential.key, metadata: credential.metadata }) + : new OAuth({ + type: "oauth", + refresh: credential.refresh, + access: credential.access, + expires: credential.expires, + metadata: { + ...(credential.accountId ? { accountID: credential.accountId } : {}), + ...(credential.enterpriseUrl ? { enterpriseURL: credential.enterpriseUrl } : {}), + }, + }) + return [ + [ + connectorID, + new Info({ + id: ID.make(`cred_env_${Buffer.from(connectorID).toString("base64url")}`), + connectorID, + methodID: ConnectorSchema.MethodID.make( + credential.type === "api" + ? "api-key" + : connectorID === ConnectorSchema.ID.make("openai") + ? "chatgpt-browser" + : "oauth", + ), + label: "Environment", + value, + }), + ] as const, + ] + }), + ), + ) + }), + Effect.catch((cause) => + Effect.logWarning("invalid KILO_AUTH_CONTENT; using no process-local credentials", { cause }).pipe( + Effect.as(new Map()), + ), + ), + ) + const isolated = content !== undefined + const local = new Map([...injected.values()].map((credential) => [credential.id, credential])) + const selected = new Map([...injected].map(([connectorID, credential]) => [connectorID, credential.id])) + + const lock = Semaphore.makeUnsafe(1) + const writeLegacy = (connectorID: ConnectorSchema.ID) => + lock.withPermit( + Effect.gen(function* () { + if (!fs || !global || isolated) return + const file = path.join(global.data, "auth.json") + const raw = yield* fs.readJson(file).pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed({})), + Effect.catch((cause) => + Effect.logWarning("failed to read legacy auth.json; preserving existing file", { cause }).pipe( + Effect.as(undefined), + ), + ), + ) + if (raw === undefined) return + const data: Record = + typeof raw === "object" && raw !== null && !Array.isArray(raw) + ? { ...(raw as Record) } + : {} + const row = yield* db + .select() + .from(CredentialTable) + .where(and(eq(CredentialTable.connector_id, connectorID), eq(CredentialTable.active, true))) + .get() + .pipe(Effect.orDie) + delete data[connectorID + "/"] + if (!row) delete data[connectorID] + else { + const value = decodeValue(row.value) + data[connectorID] = + value.type === "key" + ? { type: "api", key: value.key, metadata: value.metadata } + : { + type: "oauth", + refresh: value.refresh, + access: value.access, + expires: value.expires, + accountId: value.metadata?.accountID, + enterpriseUrl: value.metadata?.enterpriseURL, + } + } + yield* fs.writeJson(file, data, 0o600).pipe(Effect.orDie) + }), + ) + // kilocode_change end + + const activate = Effect.fn("Credential.activate")(function* (id: ID) { + // kilocode_change start - isolated credential state remains process-local + if (isolated) { + const credential = local.get(id) + if (!credential) return + const from = selected.get(credential.connectorID) + if (from === id) return + selected.set(credential.connectorID, id) + yield* events.publish(Event.Switched, { connectorID: credential.connectorID, from, to: id }) + return + } + // kilocode_change end + const switched = yield* db + .transaction((tx) => + Effect.gen(function* () { + const credential = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get() + if (!credential || credential.active) return + const current = yield* tx + .select({ id: CredentialTable.id }) + .from(CredentialTable) + .where(and(eq(CredentialTable.connector_id, credential.connector_id), eq(CredentialTable.active, true))) + .get() + yield* tx + .update(CredentialTable) + .set({ active: false }) + .where(eq(CredentialTable.connector_id, credential.connector_id)) + .run() + yield* tx.update(CredentialTable).set({ active: true }).where(eq(CredentialTable.id, id)).run() + return { connectorID: credential.connector_id, from: current?.id, to: id } + }), + ) + .pipe(Effect.orDie) + if (switched) yield* events.publish(Event.Switched, switched) + if (switched) yield* writeLegacy(switched.connectorID) // kilocode_change + }) + + return Service.of({ + get: Effect.fn("Credential.get")(function* (id) { + if (isolated) return local.get(id) // kilocode_change + const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie) + return row ? info(row) : undefined + }), + all: Effect.fn("Credential.all")(function* () { + if (isolated) return [...local.values()] // kilocode_change + return (yield* db + .select() + .from(CredentialTable) + .orderBy(asc(CredentialTable.time_created)) + .all() + .pipe(Effect.orDie)).map(info) + }), + active: Effect.fn("Credential.active")(function* (connectorID) { + if (isolated) return local.get(selected.get(connectorID)!) // kilocode_change + const row = yield* db + .select() + .from(CredentialTable) + .where(and(eq(CredentialTable.connector_id, connectorID), eq(CredentialTable.active, true))) + .get() + .pipe(Effect.orDie) + return row ? info(row) : undefined + }), + activeAll: Effect.fn("Credential.activeAll")(function* () { + // kilocode_change start - project process-local selections without touching host storage + if (isolated) { + return new Map( + [...selected].flatMap(([connectorID, id]) => { + const credential = local.get(id) + return credential ? [[connectorID, credential] as const] : [] + }), + ) + } + // kilocode_change end + const rows = yield* db + .select() + .from(CredentialTable) + .where(eq(CredentialTable.active, true)) + .all() + .pipe(Effect.orDie) + return new Map(rows.map((row) => [row.connector_id, info(row)])) + }), + forConnector: Effect.fn("Credential.forConnector")(function* (connectorID) { + if (isolated) return [...local.values()].filter((credential) => credential.connectorID === connectorID) // kilocode_change + return (yield* db + .select() + .from(CredentialTable) + .where(eq(CredentialTable.connector_id, connectorID)) + .orderBy(asc(CredentialTable.time_created)) + .all() + .pipe(Effect.orDie)).map(info) + }), + create: Effect.fn("Credential.create")(function* (input) { + const credential = new Info({ + id: ID.create(), + connectorID: input.connectorID, + methodID: input.methodID, + label: input.label ?? "default", + value: input.value, + }) + // kilocode_change start - OAuth and key changes in isolated workspaces are process-local + if (isolated) { + const from = selected.get(credential.connectorID) + local.set(credential.id, credential) + selected.set(credential.connectorID, credential.id) + yield* events.publish(Event.Added, { credential }) + yield* events.publish(Event.Switched, { connectorID: credential.connectorID, from, to: credential.id }) + return credential + } + // kilocode_change end + const from = yield* db + .transaction((tx) => + Effect.gen(function* () { + const current = yield* tx + .select({ id: CredentialTable.id }) + .from(CredentialTable) + .where(and(eq(CredentialTable.connector_id, input.connectorID), eq(CredentialTable.active, true))) + .get() + yield* tx + .update(CredentialTable) + .set({ active: false }) + .where(eq(CredentialTable.connector_id, input.connectorID)) + .run() + yield* tx + .insert(CredentialTable) + .values({ + id: credential.id, + connector_id: credential.connectorID, + method_id: credential.methodID, + label: credential.label, + value: credential.value, + active: true, + }) + .run() + return current?.id + }), + ) + .pipe(Effect.orDie) + yield* events.publish(Event.Added, { credential }) + yield* events.publish(Event.Switched, { connectorID: credential.connectorID, from, to: credential.id }) + yield* writeLegacy(credential.connectorID) // kilocode_change + return credential + }), + update: Effect.fn("Credential.update")(function* (id, updates) { + if (!updates.label && !updates.value) return + // kilocode_change start - isolated updates never reach the host database + if (isolated) { + const credential = local.get(id) + if (!credential) return + local.set( + id, + new Info({ + ...credential, + label: updates.label ?? credential.label, + value: updates.value ?? credential.value, + }), + ) + return + } + const row = yield* db.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get().pipe(Effect.orDie) + // kilocode_change end + yield* db + .update(CredentialTable) + .set({ label: updates.label, value: updates.value }) + .where(eq(CredentialTable.id, id)) + .run() + .pipe(Effect.orDie) + if (row?.active) yield* writeLegacy(row.connector_id) // kilocode_change + }), + remove: Effect.fn("Credential.remove")(function* (id) { + // kilocode_change start - isolated removals and fallback selection remain process-local + if (isolated) { + const credential = local.get(id) + if (!credential) return + local.delete(id) + const active = selected.get(credential.connectorID) + const replacement = + active === id ? [...local.values()].find((item) => item.connectorID === credential.connectorID) : undefined + if (active === id) { + if (replacement) selected.set(credential.connectorID, replacement.id) + else selected.delete(credential.connectorID) + } + yield* events.publish(Event.Removed, { credential }) + if (active === id) { + yield* events.publish(Event.Switched, { + connectorID: credential.connectorID, + from: id, + to: replacement?.id, + }) + } + return + } + // kilocode_change end + const removed = yield* db + .transaction((tx) => + Effect.gen(function* () { + const row = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get() + if (!row) return + yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run() + if (!row.active) return { credential: info(row) } + const replacement = yield* tx + .select() + .from(CredentialTable) + .where(and(eq(CredentialTable.connector_id, row.connector_id), ne(CredentialTable.id, id))) + .orderBy(asc(CredentialTable.time_created)) + .get() + if (replacement) { + yield* tx + .update(CredentialTable) + .set({ active: true }) + .where(eq(CredentialTable.id, replacement.id)) + .run() + } + return { + credential: info(row), + switched: { connectorID: row.connector_id, from: id, to: replacement?.id }, + } + }), + ) + .pipe(Effect.orDie) + if (!removed) return + yield* events.publish(Event.Removed, { credential: removed.credential }) + if (removed.switched) yield* events.publish(Event.Switched, removed.switched) + yield* writeLegacy(removed.credential.connectorID) // kilocode_change + }), + activate, + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(Database.defaultLayer), + Layer.provide(EventV2.defaultLayer), + // kilocode_change start + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Global.defaultLayer), + // kilocode_change end + Layer.provideMerge( + legacyImportLayer.pipe( + Layer.provide(Database.defaultLayer), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Global.defaultLayer), + ), + ), +) diff --git a/packages/core/src/credential/sql.ts b/packages/core/src/credential/sql.ts new file mode 100644 index 00000000000..ff2c50fb083 --- /dev/null +++ b/packages/core/src/credential/sql.ts @@ -0,0 +1,23 @@ +import { sql } from "drizzle-orm" +import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core" +import { Timestamps } from "../database/schema.sql" +import type { ConnectorSchema } from "../connector/schema" +import type { Credential } from "../credential" + +export const CredentialTable = sqliteTable( + "credential", + { + id: text().$type().primaryKey(), + connector_id: text().$type().notNull(), + method_id: text().$type().notNull(), + label: text().notNull(), + value: text({ mode: "json" }).$type().notNull(), + active: integer({ mode: "boolean" }).notNull().default(false), + ...Timestamps, + }, + (table) => [ + uniqueIndex("credential_connector_active_idx") + .on(table.connector_id) + .where(sql`${table.active} = 1`), + ], +) diff --git a/packages/core/src/cross-spawn-spawner.ts b/packages/core/src/cross-spawn-spawner.ts index 6c29a89dd4e..554bea48928 100644 --- a/packages/core/src/cross-spawn-spawner.ts +++ b/packages/core/src/cross-spawn-spawner.ts @@ -3,6 +3,7 @@ import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node" import * as NodePath from "@effect/platform-node/NodePath" import { prepareCommand as prepareSandbox } from "@kilocode/sandbox" // kilocode_change import { tap as tapStdio, tapped } from "./kilocode/stdio-tap" // kilocode_change - Bun drops buffered stdio on close +import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change import * as Deferred from "effect/Deferred" import * as Effect from "effect/Effect" import * as Exit from "effect/Exit" @@ -26,6 +27,8 @@ import { import * as NodeChildProcess from "node:child_process" import { PassThrough } from "node:stream" import launch from "cross-spawn" +import { LayerNode } from "./effect/layer-node" +import { filesystem, path } from "./effect/layer-node-platform" const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err))) @@ -365,6 +368,7 @@ export const make = Effect.gen(function* () { function* (command) { switch (command._tag) { case "StandardCommand": { + const validation = SpawnValidation.take(command) // kilocode_change - retain target validation through preparation const dir = yield* cwd(command.options) // kilocode_change start - prepare agent-scoped commands through the selected sandbox backend const target = yield* prepareSandbox(command, dir, env(command.options)) @@ -374,6 +378,21 @@ export const make = Effect.gen(function* () { const extra = fds(target.options) // kilocode_change end + // kilocode_change start - close target-swap races at the raw spawn boundary + if (validation) + yield* validation.pipe( + Effect.mapError((cause) => + PlatformError.systemError({ + _tag: "Unknown", + module: "ChildProcess", + method: "validate", + pathOrDescriptor: command.command, + cause, + }), + ), + ) + // kilocode_change end + const [proc, signal] = yield* Effect.acquireRelease( // kilocode_change start - spawn the prepared command and options spawn(target, { @@ -511,5 +530,6 @@ export const layer: Layer.Layer @@ -65,3 +66,5 @@ export const defaultLayer = Layer.unwrap( return layerFromPath(path()) }), ).pipe(Layer.provide(Global.defaultLayer)) + +export const node = LayerNode.make(layerFromPath(path()), []) diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts index 1d6101500d2..f9b6415c75e 100644 --- a/packages/core/src/database/migration.gen.ts +++ b/packages/core/src/database/migration.gen.ts @@ -34,6 +34,7 @@ export const migrations = ( import("./migration/20260604172448_event_sourced_session_input"), import("./migration/20260605003541_add_session_context_snapshot"), import("./migration/20260605042240_add_context_epoch_agent"), + import("./migration/20260611035744_credential"), import("./migration/20260714141136_session-message-legacy-writer-compat"), ]) ).map((module) => module.default) satisfies DatabaseMigration.Migration[] diff --git a/packages/core/src/database/migration/20260611035744_credential.ts b/packages/core/src/database/migration/20260611035744_credential.ts new file mode 100644 index 00000000000..80df43cdf58 --- /dev/null +++ b/packages/core/src/database/migration/20260611035744_credential.ts @@ -0,0 +1,25 @@ +import { Effect } from "effect" +import type { DatabaseMigration } from "../migration" + +export default { + id: "20260611035744_credential", + up(tx) { + return Effect.gen(function* () { + yield* tx.run(` + CREATE TABLE \`credential\` ( + \`id\` text PRIMARY KEY, + \`connector_id\` text NOT NULL, + \`method_id\` text NOT NULL, + \`label\` text NOT NULL, + \`value\` text NOT NULL, + \`active\` integer DEFAULT false NOT NULL, + \`time_created\` integer NOT NULL, + \`time_updated\` integer NOT NULL + ); + `) + yield* tx.run( + `CREATE UNIQUE INDEX \`credential_connector_active_idx\` ON \`credential\` (\`connector_id\`) WHERE "credential"."active" = 1;`, + ) + }) + }, +} satisfies DatabaseMigration.Migration diff --git a/packages/core/src/effect/layer-node-platform.ts b/packages/core/src/effect/layer-node-platform.ts new file mode 100644 index 00000000000..2e63d295825 --- /dev/null +++ b/packages/core/src/effect/layer-node-platform.ts @@ -0,0 +1,12 @@ +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" +import { FetchHttpClient } from "effect/unstable/http" +import { LayerNode } from "./layer-node" + +export const filesystem = LayerNode.make(NodeFileSystem.layer, []) +export const path = LayerNode.make(NodePath.layer, []) +export const httpClient = LayerNode.make(FetchHttpClient.layer, []) +export const requestExecutor = LayerNode.make(RequestExecutor.layer, [httpClient]) +export const llmClient = LayerNode.make(LLMClient.layer, [requestExecutor]) + +export * as LayerNodePlatform from "./layer-node-platform" diff --git a/packages/core/src/effect/layer-node.ts b/packages/core/src/effect/layer-node.ts new file mode 100644 index 00000000000..c6ee6b23693 --- /dev/null +++ b/packages/core/src/effect/layer-node.ts @@ -0,0 +1,102 @@ +import { Layer } from "effect" + +type RuntimeLayer = Layer.Layer +type AnyNode = Node +type NodeList = readonly [] | readonly [AnyNode, ...AnyNode[]] +type Output = [Item] extends [never] ? never : Item extends Node ? A : never +type Error = [Item] extends [never] ? never : Item extends Node ? E : never +type Missing = Exclude> +type CheckDependencies = [ + Missing, Dependencies>, +] extends [never] + ? unknown + : { readonly "Missing dependencies": Missing, Dependencies> } +declare const $OutputType: unique symbol +declare const $ErrorType: unique symbol + +export type Node = { + readonly kind: "layer" | "group" + readonly implementation?: Layer.Any + readonly dependencies: readonly AnyNode[] + readonly [$OutputType]?: () => A + readonly [$ErrorType]?: () => E +} + +export function make( + implementation: Implementation, + dependencies: Items & CheckDependencies>, +): Node, Layer.Error | Error> { + return { kind: "layer", implementation: implementation as Layer.Any, dependencies } +} + +export function group( + dependencies: Items, +): Node, Error> { + return { kind: "group", dependencies } +} + +export type Replacement = { + readonly source: Node + readonly replacement: Node +} + +type CheckReplacementErrors = [Exclude] extends [never] + ? unknown + : { readonly "New replacement errors": Exclude } + +export function replaceWithNode( + source: Node, + replacement: Node, E2> & CheckReplacementErrors>, +): Replacement { + return { source, replacement } +} + +export function replace( + source: Node, + replacement: Layer.Layer, E2, never> & CheckReplacementErrors>, +): Replacement { + return { source, replacement: make(replacement as Layer.Layer, []) } +} + +export function buildLayer(node: Node, options?: { readonly replacements?: readonly Replacement[] }) { + const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement])) + const cache = new Map() + const visiting = new Set() + const stack: AnyNode[] = [] + const ids = new Map() + + const visit = (input: AnyNode): RuntimeLayer => { + const node = replacements.get(input) ?? input + const cached = cache.get(node) + if (cached) return cached + if (visiting.has(node)) { + const start = stack.indexOf(node) + const cycle = [...stack.slice(start), node].map((item) => `${item.kind}#${ids.get(item)}`).join(" -> ") + throw new Error(`Cycle detected in app graph: ${cycle}`) + } + if (!ids.has(node)) ids.set(node, ids.size + 1) + visiting.add(node) + stack.push(node) + try { + const dependencies = node.dependencies.map(visit) + const nonEmpty = dependencies as [RuntimeLayer, ...RuntimeLayer[]] + const result = + node.kind === "group" + ? dependencies.length === 0 + ? Layer.empty + : Layer.mergeAll(...nonEmpty) + : dependencies.length === 0 + ? (node.implementation as RuntimeLayer) + : Layer.provide(node.implementation as RuntimeLayer, nonEmpty) + cache.set(node, result) + return result + } finally { + stack.pop() + visiting.delete(node) + } + } + + return visit(node) as unknown as Layer.Layer +} + +export * as LayerNode from "./layer-node" diff --git a/packages/core/src/effect/logger.ts b/packages/core/src/effect/logger.ts deleted file mode 100644 index 69f9631e06b..00000000000 --- a/packages/core/src/effect/logger.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Cause, Effect, Logger, References } from "effect" -import * as Log from "../util/log" - -type Fields = Record - -const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key) - -export interface Handle { - readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect - readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect - readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect - readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect - readonly with: (extra: Fields) => Handle -} - -const clean = (input?: Fields): Fields => - Object.fromEntries( - Object.entries(input ?? {}) - .filter((entry) => entry[1] !== undefined && entry[1] !== null) - .map(([key, value]) => [normalizeKey(key), value]), - ) - -const text = (input: unknown): string => { - // oxlint-disable-next-line no-base-to-string - if (Array.isArray(input)) return input.map((item) => String(item)).join(" ") - // oxlint-disable-next-line no-base-to-string - return input === undefined ? "" : String(input) -} - -const call = (run: (msg?: unknown) => Effect.Effect, base: Fields, msg?: unknown, extra?: Fields) => { - const ann = clean({ ...base, ...extra }) - const fx = run(msg) - return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx -} - -export const logger = Logger.make((opts) => { - const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations)) - const now = opts.date.getTime() - for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) { - extra[`logSpan.${key}`] = `${now - start}ms` - } - if (opts.cause.reasons.length > 0) { - extra.cause = Cause.pretty(opts.cause) - } - - const svc = typeof extra.service === "string" ? extra.service : undefined - if (svc) delete extra.service - const log = svc ? Log.create({ service: svc }) : Log.Default - const msg = text(opts.message) - - switch (opts.logLevel) { - case "Trace": - case "Debug": - return log.debug(msg, extra) - case "Warn": - return log.warn(msg, extra) - case "Error": - case "Fatal": - return log.error(msg, extra) - default: - return log.info(msg, extra) - } -}) - -export const layer = Logger.layer([logger], { mergeWithExisting: false }) - -export const create = (base: Fields = {}): Handle => ({ - debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra), - info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra), - warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra), - error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra), - with: (extra) => create({ ...base, ...extra }), -}) diff --git a/packages/core/src/effect/observability.ts b/packages/core/src/effect/observability.ts deleted file mode 100644 index d9c24369b7d..00000000000 --- a/packages/core/src/effect/observability.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { Effect, Layer, Logger } from "effect" -import { FetchHttpClient } from "effect/unstable/http" -import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability" -import * as EffectLogger from "./logger" -import { Flag } from "../flag/flag" -import { InstallationChannel, InstallationVersion } from "../installation/version" -import { ensureProcessMetadata } from "../util/opencode-process" - -const base = Flag.OTEL_EXPORTER_OTLP_ENDPOINT -export const enabled = !!base -const processID = crypto.randomUUID() - -const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS - ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce( - (acc, x) => { - const [key, ...value] = x.split("=") - acc[key] = value.join("=") - return acc - }, - {} as Record, - ) - : undefined - -export function resource(): { serviceName: string; serviceVersion: string; attributes: Record } { - const processMetadata = ensureProcessMetadata("main") - const attributes: Record = (() => { - const value = process.env.OTEL_RESOURCE_ATTRIBUTES - if (!value) return {} - try { - return Object.fromEntries( - value.split(",").map((entry) => { - const index = entry.indexOf("=") - if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry") - return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))] - }), - ) - } catch { - return {} - } - })() - - return { - serviceName: "opencode", - serviceVersion: InstallationVersion, - attributes: { - ...attributes, - "deployment.environment.name": InstallationChannel, - "opencode.client": Flag.KILO_CLIENT, - "opencode.process_role": processMetadata.processRole, - "opencode.run_id": processMetadata.runID, - "service.instance.id": processID, - }, - } -} - -function logs() { - return Logger.layer( - [ - EffectLogger.logger, - OtlpLogger.make({ - url: `${base}/v1/logs`, - resource: resource(), - headers, - }), - ], - { mergeWithExisting: false }, - ).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer)) -} - -const traces = async () => { - const NodeSdk = await import("@effect/opentelemetry/NodeSdk") - const OTLP = await import("@opentelemetry/exporter-trace-otlp-http") - const SdkBase = await import("@opentelemetry/sdk-trace-base") - - // @effect/opentelemetry creates a NodeTracerProvider but never calls - // register(), so the global @opentelemetry/api context manager stays - // as the no-op default. Non-Effect code (like the AI SDK) that calls - // tracer.startActiveSpan() relies on context.active() to find the - // parent span - without a real context manager every span starts a - // new trace. Registering AsyncLocalStorageContextManager fixes this. - const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks") - const { context } = await import("@opentelemetry/api") - const mgr = new AsyncLocalStorageContextManager() - mgr.enable() - context.setGlobalContextManager(mgr) - - return NodeSdk.layer(() => ({ - resource: resource(), - spanProcessor: new SdkBase.BatchSpanProcessor( - new OTLP.OTLPTraceExporter({ - url: `${base}/v1/traces`, - headers, - }), - ), - })) -} - -export const layer = !base - ? EffectLogger.layer - : Layer.unwrap( - Effect.gen(function* () { - const trace = yield* Effect.promise(traces) - return Layer.mergeAll(trace, logs()) - }), - ) - -export const Observability = { enabled, layer } diff --git a/packages/core/src/effect/runtime.ts b/packages/core/src/effect/runtime.ts index e4f68270989..6ad0f85176c 100644 --- a/packages/core/src/effect/runtime.ts +++ b/packages/core/src/effect/runtime.ts @@ -1,6 +1,6 @@ import { Layer, type Context, ManagedRuntime, type Effect } from "effect" import { memoMap } from "./memo-map" -import { Observability } from "./observability" +import { Observability } from "../observability" export function makeRuntime(service: Context.Service, layer: Layer.Layer) { let rt: ManagedRuntime.ManagedRuntime | undefined diff --git a/packages/core/src/event.ts b/packages/core/src/event.ts index 0ad0714e98d..2edccfd2938 100644 --- a/packages/core/src/event.ts +++ b/packages/core/src/event.ts @@ -7,6 +7,7 @@ import { EventSequenceTable, EventTable } from "./event/sql" import { Location } from "./location" import { externalID, type ExternalID, NonNegativeInt, withStatics } from "./schema" import { Identifier } from "./util/identifier" +import { LayerNode } from "./effect/layer-node" import { isDeepStrictEqual } from "node:util" export const ID = Schema.String.check(Schema.isStartsWith("evt_")).pipe( @@ -30,6 +31,7 @@ export type Definition // kilocode_change - storage-only compatibility decoder } readonly data: DataSchema } @@ -90,13 +92,16 @@ type SyncDefinition = Definition & { const syncRegistry = new Map() // Synchronized events cross a JSON boundary, so their data schemas must encode and decode without services. -const syncCodec = (definition: Definition) => definition.data as Schema.Codec +// kilocode_change - keep persistence compatibility codecs out of public event schemas +const syncCodec = (definition: Definition) => + definition.sync?.codec ?? (definition.data as Schema.Codec) export function define(input: { readonly type: Type readonly sync?: { readonly version: number readonly aggregate: string + readonly codec?: Schema.Codec // kilocode_change } readonly schema: Fields }): Schema.Schema>>> & Definition> { @@ -410,9 +415,7 @@ export const layerWith = (options?: LayerOptions) => Effect.catchCauseIf( (cause) => !Cause.hasInterrupts(cause), (cause) => - Effect.logError("Event observer failed").pipe( - Effect.annotateLogs({ eventID: event.id, eventType: event.type, kind, cause }), - ), + Effect.logError("Event observer failed", { eventID: event.id, eventType: event.type, kind, cause }), ), ) @@ -676,5 +679,6 @@ export const layerWith = (options?: LayerOptions) => ) export const layer = layerWith() +export const node = LayerNode.make(layer, [Database.node]) export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer)) diff --git a/packages/core/src/file-mutation.ts b/packages/core/src/file-mutation.ts index 9eab32e8a2c..a3c6f519a31 100644 --- a/packages/core/src/file-mutation.ts +++ b/packages/core/src/file-mutation.ts @@ -4,15 +4,19 @@ import { Context, Effect, Layer, Schema } from "effect" import { dirname } from "path" import { KeyedMutex } from "./effect/keyed-mutex" import { FSUtil } from "./fs-util" -import { LocationMutation } from "./location-mutation" + +export interface Target { + readonly canonical: string + readonly resource: string +} export interface WriteInput { - readonly plan: LocationMutation.Plan + readonly target: Target readonly content: string | Uint8Array } export interface TextWriteInput { - readonly plan: LocationMutation.Plan + readonly target: Target readonly content: string } @@ -21,7 +25,7 @@ export interface ConditionalWriteInput extends WriteInput { } export interface RemoveInput { - readonly plan: LocationMutation.Plan + readonly target: Target } export class StaleContentError extends Schema.TaggedErrorClass()("FileMutation.StaleContentError", { @@ -34,143 +38,131 @@ export class TargetExistsError extends Schema.TaggedErrorClass Effect.Effect - /** Write after immediately revalidating the planned target. */ - readonly write: (input: WriteInput) => Effect.Effect + /** Create without replacing an existing target. */ + readonly create: (input: WriteInput) => Effect.Effect + readonly write: (input: WriteInput) => Effect.Effect /** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */ - readonly writeTextPreservingBom: ( - input: TextWriteInput, - ) => Effect.Effect + readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect /** Commit only if an existing target still has the expected bytes. */ readonly writeIfUnchanged: ( input: ConditionalWriteInput, - ) => Effect.Effect - /** Remove after immediately revalidating the planned target. */ - readonly remove: ( - input: RemoveInput, - ) => Effect.Effect + ) => Effect.Effect + readonly remove: (input: RemoveInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/FileMutation") {} /** - * Commit planned file changes. - * - * resolve(path) -> approve -> lock target -> revalidate(plan) -> mutate - * - * The caller approves the plan first. This service locks the canonical target, - * revalidates the plan immediately before the filesystem operation, then mutates. - * - * `writeIfUnchanged` compares and writes while holding the same in-memory lock, - * so cooperating calls in this process cannot overwrite from the same stale - * content. Locks apply only within this service layer and only to identical - * canonical targets. - * - * Revalidation reduces the race window but is not atomic with the next - * path-based filesystem operation. A hostile local process can still race it. - * - * TODO: Use descriptor-relative no-follow operations where supported to close - * the final race. + * Serialize file changes by canonical target. Conditional writes compare and + * write under the same process-local lock so cooperating OpenCode mutations do + * not overwrite changes made from the same stale content. */ export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service - const mutation = yield* LocationMutation.Service const locks = KeyedMutex.makeUnsafe() const withTargetLock = - (target: string) => + (target: Target) => (effect: Effect.Effect) => - locks.withLock(target)(Effect.uninterruptible(effect)) + locks.withLock(target.canonical)(Effect.uninterruptible(effect)) - const withValidatedTarget = - (plan: LocationMutation.Plan) => - (commit: (target: LocationMutation.Target) => Effect.Effect) => - withTargetLock(plan.target.canonical)(mutation.revalidate(plan).pipe(Effect.flatMap(commit))) - - const writeResult = (target: LocationMutation.Target, existed = target.exists): WriteResult => ({ + const writeResult = (target: Target, existed: boolean): WriteResult => ({ operation: "write", target: target.canonical, resource: target.resource, existed, }) - const removeResult = (target: LocationMutation.Target): RemoveResult => ({ + const removeResult = (target: Target, existed: boolean): RemoveResult => ({ operation: "remove", target: target.canonical, resource: target.resource, - existed: target.exists, + existed, }) const write = Effect.fn("FileMutation.write")((input: WriteInput) => - withValidatedTarget(input.plan)((target) => + withTargetLock(input.target)( Effect.gen(function* () { - yield* fs.writeWithDirs(target.canonical, input.content) - return writeResult(target) + const existed = yield* fs.exists(input.target.canonical) + yield* fs.writeWithDirs(input.target.canonical, input.content) + return writeResult(input.target, existed) }), ), ) const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) => - withValidatedTarget(input.plan)((target) => + withTargetLock(input.target)( Effect.gen(function* () { const next = splitBom(input.content) - const preserveBom = target.exists && hasUtf8Bom(yield* fs.readFile(target.canonical)) - yield* fs.writeWithDirs(target.canonical, joinBom(next.text, preserveBom || next.bom)) - return writeResult(target) + const current = yield* fs + .readFile(input.target.canonical) + .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) + yield* fs.writeWithDirs( + input.target.canonical, + joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom), + ) + return writeResult(input.target, current !== undefined) }), ), ) const create = Effect.fn("FileMutation.create")((input: WriteInput) => - withValidatedTarget(input.plan)((target) => + withTargetLock(input.target)( Effect.gen(function* () { - if (target.exists) return yield* new TargetExistsError({ path: target.canonical }) - yield* fs.ensureDir(dirname(target.canonical)) - if (typeof input.content === "string") - yield* fs.writeFileString(target.canonical, input.content, { flag: "wx" }) - else yield* fs.writeFile(target.canonical, input.content, { flag: "wx" }) - return writeResult(target, false) + const write = + typeof input.content === "string" + ? fs.writeFileString(input.target.canonical, input.content, { flag: "wx" }) + : fs.writeFile(input.target.canonical, input.content, { flag: "wx" }) + yield* write.pipe( + Effect.catchReason("PlatformError", "NotFound", () => + fs.ensureDir(dirname(input.target.canonical)).pipe(Effect.andThen(write)), + ), + Effect.catchReason("PlatformError", "AlreadyExists", () => + Effect.fail(new TargetExistsError({ path: input.target.canonical })), + ), + ) + return writeResult(input.target, false) }), ), ) const writeIfUnchanged = Effect.fn("FileMutation.writeIfUnchanged")((input: ConditionalWriteInput) => - withValidatedTarget(input.plan)((target) => + withTargetLock(input.target)( Effect.gen(function* () { - const current = yield* fs.readFile(target.canonical) - if (!sameBytes(current, input.expected)) return yield* new StaleContentError({ path: target.canonical }) - yield* fs.writeWithDirs(target.canonical, input.content) - return writeResult(target) + const current = yield* fs.readFile(input.target.canonical) + if (!sameBytes(current, input.expected)) { + return yield* new StaleContentError({ path: input.target.canonical }) + } + yield* typeof input.content === "string" + ? fs.writeFileString(input.target.canonical, input.content) + : fs.writeFile(input.target.canonical, input.content) + return writeResult(input.target, true) }), ), ) const remove = Effect.fn("FileMutation.remove")((input: RemoveInput) => - withValidatedTarget(input.plan)((target) => + withTargetLock(input.target)( Effect.gen(function* () { - yield* fs.remove(target.canonical) - return removeResult(target) + const existed = yield* fs.remove(input.target.canonical).pipe( + Effect.as(true), + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)), + ) + return removeResult(input.target, existed) }), ), ) diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index e8efd1c6be6..8ecb09a3957 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -1,146 +1,56 @@ export * as FileSystem from "./filesystem" import path from "path" -import { pathToFileURL } from "url" -import fuzzysort from "fuzzysort" -import ignore from "ignore" -import { Context, Effect, Layer, Option, Schema, Stream } from "effect" +import { Context, Effect, Layer, Option, Schema } from "effect" // kilocode_change import { EventV2 } from "./event" import { FSUtil } from "./fs-util" -import { Global } from "./global" import { Location } from "./location" -import { ProjectReference } from "./project-reference" -import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" -import { Protected } from "./filesystem/protected" -import { Ripgrep } from "./filesystem/ripgrep" +import { PositiveInt, RelativePath } from "./schema" +import { FileSystemSearch } from "./filesystem/search" +import { Entry, Match } from "./filesystem/schema" +import * as SearchTarget from "./kilocode/search-target" // kilocode_change +export { Entry, Match, Submatch } from "./filesystem/schema" export const ReadInput = Schema.Struct({ path: RelativePath, - reference: Schema.NonEmptyString.pipe(Schema.optional), }) export type ReadInput = typeof ReadInput.Type -export const MAX_READ_LINES = 2_000 -export const MAX_READ_BYTES = 50 * 1024 -const MAX_LINE_LENGTH = 2_000 -const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)` - -export class TextContent extends Schema.Class("FileSystem.TextContent")({ - type: Schema.Literal("text"), - content: Schema.String, - mime: Schema.String, -}) {} - -export class BinaryContent extends Schema.Class("FileSystem.BinaryContent")({ - type: Schema.Literal("binary"), +export const Content = Schema.Struct({ + uri: Schema.String, + name: Schema.String.pipe(Schema.optional), content: Schema.String, - encoding: Schema.Literal("base64"), + encoding: Schema.Literals(["utf8", "base64"]), mime: Schema.String, -}) {} - -export const Content = Schema.Union([TextContent, BinaryContent]).pipe(Schema.toTaggedUnion("type")) +}).annotate({ identifier: "FileSystem.Content" }) export type Content = typeof Content.Type -export const TextPageInput = Schema.Struct({ - offset: PositiveInt.pipe(Schema.optional), - limit: PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_READ_LINES)).pipe(Schema.optional), -}) -export type TextPageInput = typeof TextPageInput.Type - -export class TextPage extends Schema.Class("FileSystem.TextPage")({ - type: Schema.Literal("text-page"), - content: Schema.String, - mime: Schema.String, - offset: PositiveInt, - truncated: Schema.Boolean, - next: PositiveInt.pipe(Schema.optional), -}) {} - -export class ReadTarget extends Schema.Class("FileSystem.ReadTarget")({ - real: Schema.String, - resource: Schema.String, - size: NonNegativeInt, - dev: Schema.Number, - ino: Schema.Number.pipe(Schema.optional), -}) {} - export const ListInput = Schema.Struct({ path: RelativePath.pipe(Schema.optional), - reference: Schema.NonEmptyString.pipe(Schema.optional), }) export type ListInput = typeof ListInput.Type -export const ListPageInput = Schema.Struct({ - ...ListInput.fields, - offset: PositiveInt.pipe(Schema.optional), - limit: PositiveInt.check(Schema.isLessThanOrEqualTo(2_000)).pipe(Schema.optional), -}) -export type ListPageInput = typeof ListPageInput.Type - -export class ListTarget extends Schema.Class("FileSystem.ListTarget")({ - absolute: Schema.String, - real: Schema.String, - directory: Schema.String, - root: Schema.String, - resource: Schema.String, -}) {} - -/** Canonical read authority for Location-scoped search and metadata leaves. */ -export class RootTarget extends Schema.Class("FileSystem.RootTarget")({ - absolute: Schema.String, - real: Schema.String, - directory: Schema.String, - root: Schema.String, - resource: Schema.String, - reference: Schema.NonEmptyString.pipe(Schema.optional), - type: Schema.Literals(["file", "directory"]), - dev: Schema.Number, - ino: Schema.Number.pipe(Schema.optional), -}) {} - -export type ReadPathTarget = - | { readonly type: "file"; readonly target: ReadTarget } - | { readonly type: "directory"; readonly target: ListTarget } - -export class Entry extends Schema.Class("FileSystem.Entry")({ - path: RelativePath, - uri: Schema.String, - type: Schema.Literals(["file", "directory"]), - mime: Schema.String, +export class FindInput extends Schema.Class("FileSystem.FindInput")({ + query: Schema.String, + type: Schema.Literals(["file", "directory"]).pipe(Schema.optional), + limit: PositiveInt.pipe(Schema.optional), }) {} -export class ListPage extends Schema.Class("FileSystem.ListPage")({ - entries: Schema.Array(Entry), - truncated: Schema.Boolean, - next: PositiveInt.pipe(Schema.optional), -}) {} +export const DEFAULT_SEARCH_LIMIT = 100 // kilocode_change - preserve bounded Kilo tool searches +export const MAX_SEARCH_LIMIT = 100 // kilocode_change +export const SearchLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_SEARCH_LIMIT)) // kilocode_change -export const FindInput = Schema.Struct({ - query: Schema.String, - type: Schema.Literals(["file", "directory"]).pipe(Schema.optional), +export class GlobInput extends Schema.Class("FileSystem.GlobInput")({ + pattern: Schema.String, + path: RelativePath.pipe(Schema.optional), limit: PositiveInt.pipe(Schema.optional), -}) -export type FindInput = typeof FindInput.Type +}) {} -export const GrepInput = Schema.Struct({ +export class GrepInput extends Schema.Class("FileSystem.GrepInput")({ pattern: Schema.String, + path: RelativePath.pipe(Schema.optional), include: Schema.String.pipe(Schema.optional), limit: PositiveInt.pipe(Schema.optional), -}) -export type GrepInput = typeof GrepInput.Type - -export class GrepMatch extends Schema.Class("FileSystem.GrepMatch")({ - path: RelativePath, - lines: Schema.String, - line: PositiveInt, - offset: NonNegativeInt, - submatches: Schema.Array( - Schema.Struct({ - text: Schema.String, - start: NonNegativeInt, - end: NonNegativeInt, - }), - ), }) {} export const Event = { @@ -153,421 +63,95 @@ export const Event = { } export interface Interface { - readonly read: (input: ReadInput) => Effect.Effect - readonly resolveReadPath: (input: ReadInput) => Effect.Effect - readonly resolveRead: (input: ReadInput) => Effect.Effect - readonly readResolved: (target: ReadTarget, maximumBytes?: number) => Effect.Effect - readonly readTextPageResolved: (target: ReadTarget, page?: TextPageInput) => Effect.Effect + readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> readonly list: (input?: ListInput) => Effect.Effect - /** Select a contained canonical read root without asserting leaf policy. */ - readonly resolveRoot: (input?: ListInput) => Effect.Effect - readonly revalidateRoot: (target: RootTarget) => Effect.Effect - readonly resolveList: (input?: ListInput) => Effect.Effect - readonly listResolved: (target: ListTarget) => Effect.Effect - readonly listPage: (input?: ListPageInput) => Effect.Effect - readonly listPageResolved: ( - target: ListTarget, - page?: Pick, - ) => Effect.Effect readonly find: (input: FindInput) => Effect.Effect - readonly grep: (input: GrepInput) => Effect.Effect - readonly isIgnored: (path: RelativePath, type: "file" | "directory") => boolean + readonly glob: (input: GlobInput) => Effect.Effect + readonly grep: (input: GrepInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/FileSystem") {} -export const layer = Layer.effect( +const baseLayer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service const location = yield* Location.Service - const references = yield* ProjectReference.Service - const ripgrep = yield* Ripgrep.Service + const search = yield* FileSystemSearch.Service const root = yield* fs.realPath(location.directory).pipe(Effect.orDie) - const ignored = ignore() - const gitignore = yield* fs - .readFileString(path.join(location.project.directory, ".gitignore")) - .pipe(Effect.catch(() => Effect.succeed(""))) - if (gitignore) ignored.add(gitignore) - const ignorefile = yield* fs - .readFileString(path.join(location.project.directory, ".ignore")) - .pipe(Effect.catch(() => Effect.succeed(""))) - if (ignorefile) ignored.add(ignorefile) - const select = Effect.fnUntraced(function* (reference?: string) { - if (!reference) return { directory: location.directory, root } - const resolved = yield* references.get(reference) - if (!resolved) return yield* Effect.die(new Error(`Unknown project reference: ${reference}`)) - if (resolved.kind === "invalid") return yield* Effect.die(new Error(resolved.message)) - if (resolved.kind === "git") yield* references.ensurePath(resolved.path).pipe(Effect.orDie) - return { directory: resolved.path, root: yield* fs.realPath(resolved.path).pipe(Effect.orDie) } - }) - const resolve = Effect.fnUntraced(function* (input?: RelativePath, reference?: string) { - if (input && path.isAbsolute(input)) return yield* Effect.die(new Error("Path must be relative to the location")) - const selected = yield* select(reference) - const absolute = path.resolve(selected.directory, input ?? ".") - if (!FSUtil.contains(selected.directory, absolute)) + const resolve = Effect.fnUntraced(function* (input?: RelativePath) { + const absolute = path.resolve(location.directory, input ?? ".") + if (!FSUtil.contains(location.directory, absolute)) return yield* Effect.die(new Error("Path escapes the location")) const real = yield* fs.realPath(absolute).pipe(Effect.orDie) - if (!FSUtil.contains(selected.root, real)) return yield* Effect.die(new Error("Path escapes the location")) - return { absolute, real, ...selected } - }) - const entry = Effect.fnUntraced(function* (absolute: string, selected = { directory: location.directory, root }) { - const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void)) - if (!real) return - if (!FSUtil.contains(selected.root, real)) return - const info = yield* fs.stat(real).pipe(Effect.catch(() => Effect.void)) - if (!info) return - const type = info.type === "Directory" ? "directory" : info.type === "File" ? "file" : undefined - if (!type) return - return new Entry({ - path: RelativePath.make(path.relative(selected.directory, absolute)), - uri: pathToFileURL(real).href, - type, - mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(real), - }) + if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location")) + const target = yield* SearchTarget.inspect(fs, real).pipe(Effect.orDie) // kilocode_change + return { absolute, real, directory: location.directory, root, target } // kilocode_change }) - - const scan = Effect.fnUntraced(function* () { - if (location.directory === Global.Path.home && location.project.id === "global") { - const protectedNames = Protected.names() - const nested = new Set(["node_modules", "dist", "build", "target", "vendor"]) - return (yield* Effect.forEach( - yield* fs.readDirectoryEntries(location.directory).pipe(Effect.orElseSucceed(() => [])), - (item) => - Effect.gen(function* () { - if (item.type !== "directory" || item.name.startsWith(".") || protectedNames.has(item.name)) return [] - const directory = path.join(location.directory, item.name) - return [ - item.name + "/", - ...(yield* fs.readDirectoryEntries(directory).pipe(Effect.orElseSucceed(() => []))).flatMap((child) => - child.type === "directory" && !child.name.startsWith(".") && !nested.has(child.name) - ? [`${item.name}/${child.name}/`] - : [], - ), - ] - }), - )).flat() - } - - const files = Array.from(yield* ripgrep.files({ cwd: location.directory }).pipe(Stream.runCollect, Effect.orDie)) - const dirs = new Set() - for (const file of files) { - let current = file - while (true) { - const directory = path.dirname(current) - if (directory === "." || directory === current) break - current = directory - dirs.add(directory + "/") - } - } - return [...files, ...dirs] - }) - - const resolveReadPath = Effect.fn("FileSystem.resolveReadPath")(function* (input: ReadInput) { - const file = yield* resolve(input.path, input.reference) - const info = yield* fs.stat(file.real).pipe(Effect.orDie) - const relative = path.relative(file.root, file.real).replaceAll("\\", "/") - const resource = input.reference === undefined ? relative || "." : `${input.reference}:${relative || "."}` - if (info.type === "File") { - return { - type: "file" as const, - target: new ReadTarget({ - real: file.real, - resource, - size: Number(info.size), - dev: info.dev, - ino: Option.getOrUndefined(info.ino), - }), - } - } - if (info.type === "Directory") { - return { type: "directory" as const, target: new ListTarget({ ...file, resource }) } - } - return yield* Effect.die(new Error("Path is not a file or directory")) - }) - const resolveRead = Effect.fn("FileSystem.resolveRead")(function* (input: ReadInput) { - const resolved = yield* resolveReadPath(input) - if (resolved.type !== "file") return yield* Effect.die(new Error("Path is not a file")) - return resolved.target - }) - const content = (target: ReadTarget, bytes: Uint8Array) => - Effect.gen(function* () { - const mime = FSUtil.mimeType(target.real) - if (!bytes.includes(0)) { - const content = yield* Effect.sync(() => new TextDecoder("utf-8", { fatal: true }).decode(bytes)).pipe( - Effect.option, - ) - if (content._tag === "Some") return new TextContent({ type: "text", content: content.value, mime }) - } - return new BinaryContent({ - type: "binary", - content: Buffer.from(bytes).toString("base64"), - encoding: "base64", - mime, - }) - }) - const readResolved = Effect.fn("FileSystem.readResolved")(function* (target: ReadTarget, maximumBytes?: number) { - if (maximumBytes === undefined) return yield* content(target, yield* fs.readFile(target.real).pipe(Effect.orDie)) - return yield* Effect.scoped( - Effect.gen(function* () { - const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie) - const info = yield* file.stat.pipe(Effect.orDie) - if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) - if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino) - return yield* Effect.die(new Error("File changed after permission approval")) - if (info.size > maximumBytes) - return yield* Effect.die(new Error(`File exceeds ${maximumBytes} byte read limit`)) - const bytes = yield* file.readAlloc(maximumBytes + 1).pipe(Effect.orDie) - if (bytes._tag === "Some" && bytes.value.length > maximumBytes) - return yield* Effect.die(new Error(`File exceeds ${maximumBytes} byte read limit`)) - return yield* content(target, bytes._tag === "Some" ? bytes.value : new Uint8Array()) - }), - ) - }) - const readTextPageResolved = Effect.fn("FileSystem.readTextPageResolved")(function* ( - target: ReadTarget, - page: TextPageInput = {}, - ) { - return yield* Effect.scoped( - Effect.gen(function* () { - const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie) - const info = yield* file.stat.pipe(Effect.orDie) - if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) - if (info.dev !== target.dev || Option.getOrUndefined(info.ino) !== target.ino) - return yield* Effect.die(new Error("File changed after permission approval")) - - const offset = page.offset ?? 1 - const limit = Math.min(page.limit ?? MAX_READ_LINES, MAX_READ_LINES) - const lines: string[] = [] - const decoder = new TextDecoder("utf-8", { fatal: true }) - let pending = "" - let discard = false - let line = 1 - let bytes = 0 - let found = false - let truncated = false - let next: number | undefined - - const append = (input: string) => { - if (line < offset) { - line++ - return true - } - if (lines.length >= limit) { - truncated = true - next = line - return false - } - found = true - const text = input.length > MAX_LINE_LENGTH ? input.slice(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX : input - const size = Buffer.byteLength(text, "utf-8") + (lines.length > 0 ? 1 : 0) - if (bytes + size > MAX_READ_BYTES) { - truncated = true - next = line - return false - } - lines.push(text) - bytes += size - line++ - return true - } - - let done = false - while (!done) { - const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie) - if (Option.isNone(chunk)) break - if (chunk.value.includes(0)) return yield* Effect.die(new Error("Cannot page binary file")) - let text = decoder.decode(chunk.value, { stream: true }) + return Service.of({ + find: search.find, + glob: search.glob, + grep: search.grep, + read: Effect.fn("FileSystem.read")(function* (input) { + const target = yield* resolve(input.path) + if (target.target.type !== "file") return yield* Effect.die(new Error("Path is not a file")) // kilocode_change + // kilocode_change start - read from the validated descriptor, not a second pathname lookup. + return yield* Effect.scoped( + Effect.gen(function* () { + const file = yield* fs.open(target.real, { flag: "r" }).pipe(Effect.orDie) + const info = yield* file.stat.pipe(Effect.orDie) + if ( + info.type !== "File" || + info.dev !== target.target.dev || + Option.getOrUndefined(info.ino) !== target.target.ino + ) + return yield* Effect.die(new Error("Path changed during read")) + const chunks: Uint8Array[] = [] while (true) { - const index = text.indexOf("\n") - if (index === -1) { - if (!discard) { - pending += text - if (pending.length > MAX_LINE_LENGTH) { - pending = pending.slice(0, MAX_LINE_LENGTH + 1) - discard = true - } - } - break - } - const current = pending + (discard ? "" : text.slice(0, index)) - pending = "" - discard = false - text = text.slice(index + 1) - if (!append(current.endsWith("\r") ? current.slice(0, -1) : current)) { - done = true - break - } + const chunk = yield* file.readAlloc(64 * 1024).pipe(Effect.orDie) + if (Option.isNone(chunk)) break + chunks.push(chunk.value) + } + return { + content: new Uint8Array(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)))), + mime: FSUtil.mimeType(target.real), } - } - if (!done) { - const tail = decoder.decode() - if (!discard) pending += tail - if (pending && !append(pending.endsWith("\r") ? pending.slice(0, -1) : pending)) done = true - } - if (!done && !found && offset !== 1) return yield* Effect.die(new Error(`Offset ${offset} is out of range`)) - - return new TextPage({ - type: "text-page", - content: lines.join("\n"), - mime: FSUtil.mimeType(target.real), - offset, - truncated, - ...(next === undefined ? {} : { next }), - }) - }), - ) - }) - const resolveList = Effect.fn("FileSystem.resolveList")(function* (input: ListInput = {}) { - const directory = yield* resolve(input.path, input.reference) - const info = yield* fs.stat(directory.real).pipe(Effect.orDie) - if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory")) - const relative = path.relative(directory.root, directory.real).replaceAll("\\", "/") || "." - return new ListTarget({ - ...directory, - resource: input.reference === undefined ? relative : `${input.reference}:${relative}`, - }) - }) - const resolveRoot = Effect.fn("FileSystem.resolveRoot")(function* (input: ListInput = {}) { - const target = yield* resolve(input.path, input.reference) - const info = yield* fs.stat(target.real).pipe(Effect.orDie) - const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined - if (!type) return yield* Effect.die(new Error("Path is not a file or directory")) - const relative = path.relative(target.root, target.real).replaceAll("\\", "/") || "." - return new RootTarget({ - ...target, - resource: input.reference === undefined ? relative : `${input.reference}:${relative}`, - reference: input.reference, - type, - dev: info.dev, - ino: Option.getOrUndefined(info.ino), - }) - }) - const revalidateRoot = Effect.fn("FileSystem.revalidateRoot")(function* (target: RootTarget) { - const canonical = yield* fs.realPath(target.absolute).pipe(Effect.orDie) - if (canonical !== target.real) return yield* Effect.die(new Error("Search root changed after approval")) - const info = yield* fs.stat(canonical).pipe(Effect.orDie) - if ( - info.type !== (target.type === "file" ? "File" : "Directory") || - info.dev !== target.dev || - Option.getOrUndefined(info.ino) !== target.ino - ) - return yield* Effect.die(new Error("Search root identity changed after approval")) - return target - }) - const listResolved = Effect.fn("FileSystem.listResolved")(function* (directory: ListTarget) { - return yield* fs.readDirectoryEntries(directory.real).pipe( - Effect.orDie, - Effect.flatMap((items) => - Effect.forEach(items, (item) => entry(path.join(directory.absolute, item.name), directory), { - concurrency: "unbounded", }), - ), - Effect.map((items) => - items - .filter((item): item is Entry => item !== undefined) - .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)), - ), - ) - }) - const listPageResolved = Effect.fn("FileSystem.listPageResolved")(function* ( - target: ListTarget, - page: Pick = {}, - ) { - type Candidate = Entry | { readonly name: string; readonly type: "file" | "directory" } - const offset = page.offset ?? 1 - const limit = Math.min(page.limit ?? 2_000, 2_000) - const items = yield* fs.readDirectoryEntries(target.real).pipe(Effect.orDie) - const candidates = yield* Effect.forEach( - items, - (item): Effect.Effect => { - if (item.type === "other") return Effect.succeed(undefined) - if (item.type === "symlink") return entry(path.join(target.absolute, item.name), target) - return Effect.succeed({ name: item.name, type: item.type } as const) - }, - { concurrency: 16 }, - ).pipe(Effect.map((items) => items.filter((item): item is Candidate => item !== undefined))) - candidates.sort((a, b) => { - return a.type === b.type - ? (a instanceof Entry ? a.path : a.name).localeCompare(b instanceof Entry ? b.path : b.name) - : a.type === "directory" - ? -1 - : 1 - }) - const selected = candidates.slice(offset - 1, offset - 1 + limit) - const entries = yield* Effect.forEach( - selected, - (item) => (item instanceof Entry ? Effect.succeed(item) : entry(path.join(target.absolute, item.name), target)), - { - concurrency: 16, - }, - ).pipe(Effect.map((items) => items.filter((item): item is Entry => item !== undefined))) - const truncated = offset - 1 + selected.length < candidates.length - return new ListPage({ entries, truncated, ...(truncated ? { next: offset + selected.length } : {}) }) - }) - - return Service.of({ - read: Effect.fn("FileSystem.read")(function* (input) { - return yield* readResolved(yield* resolveRead(input)) - }), - resolveReadPath, - resolveRead, - readResolved, - readTextPageResolved, - list: Effect.fn("FileSystem.list")(function* (input) { - return yield* listResolved(yield* resolveList(input)) - }), - resolveRoot, - revalidateRoot, - resolveList, - listResolved, - listPage: Effect.fn("FileSystem.listPage")(function* (input) { - return yield* listPageResolved(yield* resolveList(input), input) - }), - listPageResolved, - find: Effect.fn("FileSystem.find")(function* (input) { - const items = (yield* scan()).filter((item) => input.type !== "file" || !item.endsWith("/")) - const filtered = items.filter((item) => input.type !== "directory" || item.endsWith("/")) - const sorted = input.query.trim() - ? fuzzysort.go(input.query.trim(), filtered, { limit: input.limit ?? 100 }).map((item) => item.target) - : filtered.slice(0, input.limit) - return yield* Effect.forEach(sorted, (item) => entry(path.join(location.directory, item))).pipe( - Effect.map((items) => items.filter((item): item is Entry => item !== undefined)), ) + // kilocode_change end }), - grep: Effect.fn("FileSystem.grep")(function* (input) { - return (yield* ripgrep - .search({ - cwd: location.directory, - pattern: input.pattern, - glob: input.include ? [input.include] : undefined, - limit: input.limit, - }) - .pipe(Effect.orDie)).items.map( - (item) => - new GrepMatch({ - path: RelativePath.make(item.path.text), - lines: item.lines.text, - line: item.line_number, - offset: item.absolute_offset, - submatches: item.submatches.map((submatch) => ({ - text: submatch.match.text, - start: submatch.start, - end: submatch.end, - })), - }), + list: Effect.fn("FileSystem.list")(function* (input = {}) { + const target = yield* resolve(input.path) + if (target.target.type !== "directory") return yield* Effect.die(new Error("Path is not a directory")) // kilocode_change + // kilocode_change start - reject directory replacement during enumeration + yield* SearchTarget.validate(fs, target.target).pipe(Effect.orDie) + const entries = yield* fs.readDirectoryEntries(target.real).pipe( + Effect.orDie, + Effect.map((items) => + items + .flatMap((item) => { + if (item.type !== "file" && item.type !== "directory") return [] + const absolute = path.join(target.absolute, item.name) + const relative = path.relative(target.directory, absolute) + return [ + new Entry({ + path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")), + type: item.type, + mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute), + }), + ] + }) + .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)), + ), ) + yield* SearchTarget.validate(fs, target.target).pipe(Effect.orDie) + return entries + // kilocode_change end }), - isIgnored: (input, type) => - ignored.ignores( - path.relative(location.project.directory, path.join(location.directory, input)) + - (type === "directory" ? "/" : ""), - ), }) }), ) -export const locationLayer = layer.pipe( - Layer.provide(Ripgrep.defaultLayer), - Layer.provideMerge(ProjectReference.locationLayer), -) +export const layer = baseLayer.pipe(Layer.provide(FileSystemSearch.defaultLayer), Layer.provide(FSUtil.defaultLayer)) + +export const locationLayer = layer diff --git a/packages/core/src/filesystem/fff.bun.ts b/packages/core/src/filesystem/fff.bun.ts new file mode 100644 index 00000000000..9843419ed11 --- /dev/null +++ b/packages/core/src/filesystem/fff.bun.ts @@ -0,0 +1,140 @@ +import { + FileFinder, + type DirItem, + type DirSearchResult, + type FileItem, + type GrepCursor, + type GrepMatch, + type GrepResult, + type InitOptions, + type MixedItem, + type MixedSearchResult, + type SearchResult, +} from "@ff-labs/fff-bun" + +declare global { + const FFF_LIBC: "gnu" | "musl" +} + +export type Result = { ok: true; value: T } | { ok: false; error: string } + +export type Init = InitOptions + +export interface Search { + items: FileItem[] + scores: SearchResult["scores"] + totalMatched: number + totalFiles: number +} + +export interface DirSearch { + items: DirItem[] + scores: DirSearchResult["scores"] + totalMatched: number + totalDirs: number +} + +export interface MixedSearch { + items: MixedItem[] + scores: MixedSearchResult["scores"] + totalMatched: number + totalFiles: number + totalDirs: number +} + +export type File = FileItem +export type Directory = DirItem +export type Mixed = MixedItem +export type Cursor = GrepCursor | null +export type Hit = GrepMatch + +export interface Grep { + items: GrepResult["items"] + totalMatched: number + totalFilesSearched: number + totalFiles: number + filteredFileCount: number + nextCursor: Cursor + regexFallbackError?: string +} + +export interface Picker { + destroy(): void + isScanning(): boolean + waitForScan(timeoutMs?: number): Promise> + refreshGitStatus(): Result + fileSearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + glob( + pattern: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + directorySearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + mixedSearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + grep( + query: string, + opts?: { + mode?: "plain" | "regex" | "fuzzy" + maxMatchesPerFile?: number + timeBudgetMs?: number + beforeContext?: number + afterContext?: number + cursor?: Cursor + pageSize?: number + }, + ): Result + trackQuery(query: string, file: string): Result + getHistoricalQuery(offset: number): Result +} + +export function available() { + return FileFinder.isAvailable() +} + +export function create(opts: Init): Result { + const made = FileFinder.create(opts) + if (!made.ok) return made + const pick = made.value + return { + ok: true, + value: { + destroy: () => pick.destroy(), + isScanning: () => pick.isScanning(), + waitForScan: (timeoutMs) => pick.waitForScan(timeoutMs), + refreshGitStatus: () => pick.refreshGitStatus(), + fileSearch: (query, next) => pick.fileSearch(query, next), + glob: (pattern, next) => pick.glob(pattern, next), + directorySearch: (query, next) => pick.directorySearch(query, next), + mixedSearch: (query, next) => pick.mixedSearch(query, next), + grep: (query, next) => pick.grep(query, next), + trackQuery: (query, file) => pick.trackQuery(query, file), + getHistoricalQuery: (offset) => pick.getHistoricalQuery(offset), + }, + } +} + +export * as Fff from "./fff.bun" diff --git a/packages/core/src/filesystem/fff.node.ts b/packages/core/src/filesystem/fff.node.ts new file mode 100644 index 00000000000..464c2853d7e --- /dev/null +++ b/packages/core/src/filesystem/fff.node.ts @@ -0,0 +1,138 @@ +export type Result = { ok: true; value: T } | { ok: false; error: string } + +export interface Init { + basePath: string + frecencyDbPath?: string + historyDbPath?: string + useUnsafeNoLock?: boolean + disableMmapCache?: boolean + disableContentIndexing?: boolean + disableWatch?: boolean + aiMode?: boolean + logFilePath?: string + logLevel?: "trace" | "debug" | "info" | "warn" | "error" + enableFsRootScanning?: boolean + enableHomeDirScanning?: boolean +} + +export interface File { + relativePath: string + fileName: string + modified: number +} + +export interface Directory { + relativePath: string + dirName: string + maxAccessFrecency: number +} + +export type Mixed = { type: "file"; item: File } | { type: "directory"; item: Directory } + +export interface Search { + items: File[] + scores: Array<{ total: number }> + totalMatched: number + totalFiles: number +} + +export interface DirSearch { + items: Directory[] + scores: Array<{ total: number }> + totalMatched: number + totalDirs: number +} + +export interface MixedSearch { + items: Mixed[] + scores: Array<{ total: number }> + totalMatched: number + totalFiles: number + totalDirs: number +} + +export type Cursor = null + +export interface Hit { + relativePath: string + fileName: string + lineNumber: number + byteOffset: number + lineContent: string + matchRanges: [number, number][] + contextBefore?: string[] + contextAfter?: string[] +} + +export interface Grep { + items: Hit[] + totalMatched: number + totalFilesSearched: number + totalFiles: number + filteredFileCount: number + nextCursor: Cursor + regexFallbackError?: string +} + +export interface Picker { + destroy(): void + isScanning(): boolean + waitForScan(timeoutMs?: number): Promise> + refreshGitStatus(): Result + fileSearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + glob( + pattern: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + directorySearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + mixedSearch( + query: string, + opts?: { + currentFile?: string + pageIndex?: number + pageSize?: number + }, + ): Result + grep( + query: string, + opts?: { + mode?: "plain" | "regex" | "fuzzy" + maxMatchesPerFile?: number + timeBudgetMs?: number + beforeContext?: number + afterContext?: number + cursor?: Cursor + pageSize?: number + }, + ): Result + trackQuery(query: string, file: string): Result + getHistoricalQuery(offset: number): Result +} + +export function available() { + return false +} + +export function create(_opts: Init): Result { + return { ok: false, error: "fff unavailable on node runtime" } +} + +export * as Fff from "./fff.node" diff --git a/packages/core/src/filesystem/ripgrep.ts b/packages/core/src/filesystem/ripgrep.ts deleted file mode 100644 index ae53a4f22c6..00000000000 --- a/packages/core/src/filesystem/ripgrep.ts +++ /dev/null @@ -1,487 +0,0 @@ -import path from "path" -import { serviceUse } from "../effect/service-use" -import { FSUtil } from "../fs-util" -import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect" -import type { PlatformError } from "effect/PlatformError" -import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" -import { ChildProcess } from "effect/unstable/process" -import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" - -import { CrossSpawnSpawner } from "../cross-spawn-spawner" -import { Global } from "../global" -import { NonNegativeInt } from "../schema" -import * as Log from "../util/log" -import { sanitizedProcessEnv } from "../util/opencode-process" -import { which } from "../util/which" - -const log = Log.create({ service: "ripgrep" }) -const VERSION = "15.1.0" -const PLATFORM = { - "arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" }, - "arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" }, - "x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" }, - "x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" }, - "arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" }, - "ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" }, - "x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" }, -} as const - -const TimeStats = Schema.Struct({ - secs: NonNegativeInt, - nanos: NonNegativeInt, - human: Schema.String, -}) - -const Stats = Schema.Struct({ - elapsed: TimeStats, - searches: NonNegativeInt, - searches_with_match: NonNegativeInt, - bytes_searched: NonNegativeInt, - bytes_printed: NonNegativeInt, - matched_lines: NonNegativeInt, - matches: NonNegativeInt, -}) - -const PathText = Schema.Struct({ - text: Schema.String, -}) - -const Begin = Schema.Struct({ - type: Schema.Literal("begin"), - data: Schema.Struct({ - path: PathText, - }), -}) - -export const SearchMatch = Schema.Struct({ - path: PathText, - lines: Schema.Struct({ - text: Schema.String, - }), - line_number: NonNegativeInt, - absolute_offset: NonNegativeInt, - submatches: Schema.Array( - Schema.Struct({ - match: Schema.Struct({ - text: Schema.String, - }), - start: NonNegativeInt, - end: NonNegativeInt, - }), - ), -}) - -export const Match = Schema.Struct({ - type: Schema.Literal("match"), - data: SearchMatch, -}) - -const End = Schema.Struct({ - type: Schema.Literal("end"), - data: Schema.Struct({ - path: PathText, - binary_offset: Schema.NullOr(NonNegativeInt), - stats: Stats, - }), -}) - -const Summary = Schema.Struct({ - type: Schema.Literal("summary"), - data: Schema.Struct({ - elapsed_total: TimeStats, - stats: Stats, - }), -}) - -const Result = Schema.Union([Begin, Match, End, Summary]) -const decodeResult = Schema.decodeUnknownEffect(Schema.fromJsonString(Result)) - -export type Result = Schema.Schema.Type -export type Match = Schema.Schema.Type -export type Item = Match["data"] -export type Begin = Schema.Schema.Type -export type End = Schema.Schema.Type -export type Summary = Schema.Schema.Type -export type Row = Match["data"] - -export interface SearchResult { - items: Item[] - partial: boolean -} - -export interface FilesInput { - cwd: string - glob?: string[] - hidden?: boolean - follow?: boolean - maxDepth?: number - signal?: AbortSignal -} - -export interface SearchInput { - cwd: string - pattern: string - glob?: string[] - limit?: number - follow?: boolean - file?: string[] - signal?: AbortSignal -} - -export interface TreeInput { - cwd: string - limit?: number - signal?: AbortSignal -} - -export interface Interface { - readonly filepath: Effect.Effect - readonly files: (input: FilesInput) => Stream.Stream - readonly tree: (input: TreeInput) => Effect.Effect - readonly search: (input: SearchInput) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/Ripgrep") {} - -export const use = serviceUse(Service) - -function env() { - const env = sanitizedProcessEnv() - delete env.RIPGREP_CONFIG_PATH - return env -} - -function aborted(signal?: AbortSignal) { - const err = signal?.reason - if (err instanceof Error) return err - const out = new Error("Aborted") - out.name = "AbortError" - return out -} - -function waitForAbort(signal?: AbortSignal) { - if (!signal) return Effect.never - if (signal.aborted) return Effect.fail(aborted(signal)) - return Effect.callback((resume) => { - const onabort = () => resume(Effect.fail(aborted(signal))) - signal.addEventListener("abort", onabort, { once: true }) - return Effect.sync(() => signal.removeEventListener("abort", onabort)) - }) -} - -function error(stderr: string, code: number) { - const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`) - err.name = "RipgrepError" - return err -} - -function clean(file: string) { - return path.normalize(file.replace(/^\.[\\/]/, "")) -} - -function row(data: Row): Row { - return { - ...data, - path: { - ...data.path, - text: clean(data.path.text), - }, - } -} - -function parse(line: string) { - return decodeResult(line).pipe(Effect.mapError((cause) => new Error("invalid ripgrep output", { cause }))) -} - -function fail(queue: Queue.Queue, err: PlatformError | Error) { - Queue.failCauseUnsafe(queue, Cause.fail(err)) -} - -function filesArgs(input: FilesInput) { - const args = ["--no-config", "--files", "--glob=!.git/*"] - if (input.follow) args.push("--follow") - if (input.hidden !== false) args.push("--hidden") - if (input.hidden === false) args.push("--glob=!.*") - if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) - if (input.glob) { - for (const glob of input.glob) args.push(`--glob=${glob}`) - } - args.push(".") - return args -} - -function searchArgs(input: SearchInput) { - const args = ["--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"] - if (input.follow) args.push("--follow") - if (input.glob) { - for (const glob of input.glob) args.push(`--glob=${glob}`) - } - if (input.limit) args.push(`--max-count=${input.limit}`) - args.push("--", input.pattern, ...(input.file ?? ["."])) - return args -} - -function raceAbort(effect: Effect.Effect, signal?: AbortSignal) { - return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect -} - -export const layer: Layer.Layer = - Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient) - const spawner = yield* ChildProcessSpawner - - const run = Effect.fnUntraced(function* (command: string, args: string[], opts?: { cwd?: string }) { - const handle = yield* spawner.spawn( - ChildProcess.make(command, args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }), - ) - const [stdout, stderr, code] = yield* Effect.all( - [ - Stream.mkString(Stream.decodeText(handle.stdout)), - Stream.mkString(Stream.decodeText(handle.stderr)), - handle.exitCode, - ], - { concurrency: "unbounded" }, - ) - return { stdout, stderr, code } - }, Effect.scoped) - - const extract = Effect.fnUntraced(function* ( - archive: string, - config: (typeof PLATFORM)[keyof typeof PLATFORM], - target: string, - ) { - const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" }) - - if (config.extension === "zip") { - const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe" - const result = yield* run(shell, [ - "-NoProfile", - "-NonInteractive", - "-Command", - `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`, - ]) - if (result.code !== 0) { - return yield* Effect.fail(error(result.stderr || result.stdout, result.code)) - } - } - - if (config.extension === "tar.gz") { - const result = yield* run("tar", ["-xzf", archive, "-C", dir]) - if (result.code !== 0) { - return yield* Effect.fail(error(result.stderr || result.stdout, result.code)) - } - } - - const extracted = path.join( - dir, - `ripgrep-${VERSION}-${config.platform}`, - process.platform === "win32" ? "rg.exe" : "rg", - ) - if (!(yield* fs.isFile(extracted))) { - return yield* Effect.fail(new Error(`ripgrep archive did not contain executable: ${extracted}`)) - } - - yield* fs.copyFile(extracted, target) - if (process.platform === "win32") return - yield* fs.chmod(target, 0o755) - }, Effect.scoped) - - const filepath = yield* Effect.cached( - Effect.gen(function* () { - // kilocode_change start - Git for Windows can expose an MSYS rg.exe that fails when spawned natively - const system = yield* Effect.sync(() => (process.platform === "win32" ? undefined : which("rg"))) - if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system - // kilocode_change end - - const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`) - if (yield* fs.isFile(target).pipe(Effect.orDie)) return target - - const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM - const config = PLATFORM[platformKey] - if (!config) { - return yield* Effect.fail(new Error(`unsupported platform for ripgrep: ${platformKey}`)) - } - - const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}` - const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}` - const archive = path.join(Global.Path.bin, filename) - - log.info("downloading ripgrep", { url }) - yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie) - - const bytes = yield* HttpClientRequest.get(url).pipe( - http.execute, - Effect.flatMap((response) => response.arrayBuffer), - Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))), - ) - if (bytes.byteLength === 0) { - return yield* Effect.fail(new Error(`failed to download ripgrep from ${url}`)) - } - - yield* fs.writeWithDirs(archive, new Uint8Array(bytes)) - yield* extract(archive, config, target) - yield* fs.remove(archive, { force: true }).pipe(Effect.ignore) - return target - }), - ) - - const check = Effect.fnUntraced(function* (cwd: string) { - if (yield* fs.isDir(cwd).pipe(Effect.orDie)) return - return yield* Effect.fail( - Object.assign(new Error(`No such file or directory: '${cwd}'`), { - code: "ENOENT", - errno: -2, - path: cwd, - }), - ) - }) - - const command = Effect.fnUntraced(function* (cwd: string, args: string[]) { - const binary = yield* filepath - return ChildProcess.make(binary, args, { - cwd, - env: env(), - extendEnv: true, - stdin: "ignore", - }) - }) - - const files: Interface["files"] = (input) => - Stream.callback((queue) => - Effect.gen(function* () { - yield* Effect.forkScoped( - Effect.gen(function* () { - yield* check(input.cwd) - const handle = yield* spawner.spawn(yield* command(input.cwd, filesArgs(input))) - const stderr = yield* Stream.mkString(Stream.decodeText(handle.stderr)).pipe(Effect.forkScoped) - const stdout = yield* Stream.decodeText(handle.stdout).pipe( - Stream.splitLines, - Stream.filter((line) => line.length > 0), - Stream.runForEach((line) => Effect.sync(() => Queue.offerUnsafe(queue, clean(line)))), - Effect.forkScoped, - ) - const code = yield* raceAbort(handle.exitCode, input.signal) - yield* Fiber.join(stdout) - if (code === 0 || code === 1) { - Queue.endUnsafe(queue) - return - } - fail(queue, error(yield* Fiber.join(stderr), code)) - }).pipe( - Effect.catch((err) => - Effect.sync(() => { - fail(queue, err) - }), - ), - ), - ) - }), - ) - - const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) { - yield* check(input.cwd) - - const program = Effect.scoped( - Effect.gen(function* () { - const handle = yield* spawner.spawn(yield* command(input.cwd, searchArgs(input))) - - const [items, stderr, code] = yield* Effect.all( - [ - Stream.decodeText(handle.stdout).pipe( - Stream.splitLines, - Stream.filter((line) => line.length > 0), - Stream.mapEffect(parse), - Stream.filter((item): item is Match => item.type === "match"), - Stream.map((item) => row(item.data)), - Stream.runCollect, - Effect.map((chunk) => [...chunk]), - ), - Stream.mkString(Stream.decodeText(handle.stderr)), - handle.exitCode, - ], - { concurrency: "unbounded" }, - ) - - if (code !== 0 && code !== 1 && code !== 2) { - return yield* Effect.fail(error(stderr, code)) - } - - return { - items: code === 1 ? [] : items, - partial: code === 2, - } - }), - ) - - return yield* raceAbort(program, input.signal) - }) - - const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) { - log.info("tree", input) - const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect)) - - interface Node { - name: string - children: Map - } - - function child(node: Node, name: string) { - const item = node.children.get(name) - if (item) return item - const next = { name, children: new Map() } - node.children.set(name, next) - return next - } - - function count(node: Node): number { - return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0) - } - - const root: Node = { name: "", children: new Map() } - for (const file of list) { - if (file.includes(".kilo") || file.includes(".kilocode")) continue // kilocode_change - const parts = file.split(path.sep) - if (parts.length < 2) continue - let node = root - for (const part of parts.slice(0, -1)) { - node = child(node, part) - } - } - - const total = count(root) - const limit = input.limit ?? total - const lines: string[] = [] - const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values()) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((node) => ({ node, path: node.name })) - - let used = 0 - for (let i = 0; i < queue.length && used < limit; i++) { - const item = queue[i] - lines.push(item.path) - used++ - queue.push( - ...Array.from(item.node.children.values()) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((node) => ({ node, path: `${item.path}/${node.name}` })), - ) - } - - if (total > used) lines.push(`[${total - used} truncated]`) - return lines.join("\n") - }) - - return Service.of({ filepath, files, tree, search }) - }), - ) - -export const defaultLayer = layer.pipe( - Layer.provide(FetchHttpClient.layer), - Layer.provide(FSUtil.defaultLayer), - Layer.provide(CrossSpawnSpawner.defaultLayer), -) - -export * as Ripgrep from "./ripgrep" diff --git a/packages/core/src/filesystem/schema.ts b/packages/core/src/filesystem/schema.ts new file mode 100644 index 00000000000..6a2cb48413c --- /dev/null +++ b/packages/core/src/filesystem/schema.ts @@ -0,0 +1,23 @@ +import { Schema } from "effect" +import { NonNegativeInt, PositiveInt, RelativePath } from "../schema" + +export class Entry extends Schema.Class("FileSystem.Entry")({ + path: RelativePath, + type: Schema.Literals(["file", "directory"]), + mime: Schema.String, +}) {} + +export const Submatch = Schema.Struct({ + text: Schema.String, + start: NonNegativeInt, + end: NonNegativeInt, +}) +export type Submatch = typeof Submatch.Type + +export class Match extends Schema.Class("FileSystem.Match")({ + entry: Entry, + line: PositiveInt, + offset: NonNegativeInt, + text: Schema.String, + submatches: Schema.Array(Submatch), +}) {} diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts new file mode 100644 index 00000000000..93833414265 --- /dev/null +++ b/packages/core/src/filesystem/search.ts @@ -0,0 +1,297 @@ +export * as FileSystemSearch from "./search" + +import path from "path" +import { Context, Effect, Layer, Scope } from "effect" +import { Fff } from "#fff" +import fuzzysort from "fuzzysort" +import { FileSystem } from "../filesystem" +import { FSUtil } from "../fs-util" +import { Location } from "../location" +import { Ripgrep } from "../ripgrep" +import { RelativePath } from "../schema" +import { Flag } from "../flag/flag" +// kilocode_change start +import * as SearchTarget from "../kilocode/search-target" +import { scanning } from "../kilocode/fff" +// kilocode_change end + +export interface Interface { + readonly find: (input: FileSystem.FindInput) => Effect.Effect + readonly glob: (input: FileSystem.GlobInput) => Effect.Effect + readonly grep: (input: FileSystem.GrepInput) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/FileSystem/Search") {} + +export const ripgrepLayer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const location = yield* Location.Service + const ripgrep = yield* Ripgrep.Service + const scope = yield* Scope.Scope + // kilocode_change start - confine every search to the canonical active Location. + const inspect = Effect.fnUntraced(function* (input?: string) { + const root = yield* SearchTarget.inspect(fs, location.directory).pipe(Effect.orDie) + const requested = path.resolve(location.directory, input ?? ".") + if (!FSUtil.contains(location.directory, requested)) + return yield* Effect.die(new Error("Path escapes the location")) + const target = yield* SearchTarget.inspect(fs, requested).pipe(Effect.orDie) + if (root.type !== "directory" || !FSUtil.contains(root.path, target.path)) + return yield* Effect.die(new Error("Path escapes the location")) + return target + }) + // kilocode_change end + const state = { + files: [] as string[], + directories: [] as string[], + } + const directories = new Set() + yield* ripgrep + .find({ + cwd: location.directory, + pattern: "*", + limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000, + onEntry: (entry) => + Effect.sync(() => { + state.files.push(entry.path) + const parts = entry.path.split("/") + parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep)) + state.directories = Array.from(directories) + }), + }) + .pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope)) + return Service.of({ + glob: (input) => + Effect.gen(function* () { + // kilocode_change start + const target = yield* inspect(input.path) + const cwd = target.type === "file" ? path.dirname(target.path) : target.path + // kilocode_change end + return yield* ripgrep + .glob({ + cwd, + pattern: input.pattern, + limit: input.limit ?? Number.MAX_SAFE_INTEGER, + validate: SearchTarget.validate(fs, target), // kilocode_change + }) + .pipe( + Effect.map((result) => + result.items.map( // kilocode_change + (entry) => + new FileSystem.Entry({ + ...entry, + path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, entry.path))), + }), + ), + ), + Effect.orDie, + ) + }), + grep: (input) => + Effect.gen(function* () { + // kilocode_change start + const target = yield* inspect(input.path) + const cwd = target.type === "file" ? path.dirname(target.path) : target.path + // kilocode_change end + return yield* ripgrep + .grep({ + cwd, + pattern: input.pattern, + file: target.type === "file" ? path.basename(target.path) : undefined, // kilocode_change + include: input.include, + limit: input.limit ?? Number.MAX_SAFE_INTEGER, + validate: SearchTarget.validate(fs, target), // kilocode_change + }) + .pipe( + Effect.map((result) => + result.items.map( // kilocode_change + (match) => + new FileSystem.Match({ + ...match, + entry: new FileSystem.Entry({ + ...match.entry, + path: RelativePath.make(path.relative(location.directory, path.resolve(cwd, match.entry.path))), + }), + }), + ), + ), + Effect.orDie, + ) + }), + find: (input) => + Effect.gen(function* () { + const items = + input.type === "file" + ? state.files + : input.type === "directory" + ? state.directories + : [...state.files, ...state.directories] + return fuzzysort.go(input.query, items, { limit: input.limit ?? 50 }).map((item) => { + const relative = item.target + const type = relative.endsWith(path.sep) ? ("directory" as const) : ("file" as const) + const clean = type === "directory" ? relative.slice(0, -path.sep.length) : relative + const absolute = path.resolve(location.directory, clean) + return new FileSystem.Entry({ + path: RelativePath.make(relative), + type, + mime: type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute), + }) + }) + }), + }) + }), +) + +export const fffLayer = Layer.effect( + Service, + Effect.gen(function* () { + const location = yield* Location.Service + // kilocode_change start + const fs = yield* FSUtil.Service + const inspect = Effect.fnUntraced(function* (input?: string) { + const root = yield* SearchTarget.inspect(fs, location.directory).pipe(Effect.orDie) + const requested = path.resolve(location.directory, input ?? ".") + if (!FSUtil.contains(location.directory, requested)) + return yield* Effect.die(new Error("Path escapes the location")) + const target = yield* SearchTarget.inspect(fs, requested).pipe(Effect.orDie) + if (root.type !== "directory" || !FSUtil.contains(root.path, target.path)) + return yield* Effect.die(new Error("Path escapes the location")) + return { root, target } + }) + const safe = Effect.fnUntraced(function* (root: SearchTarget.Target, relative: string) { + const absolute = path.resolve(location.directory, relative) + if (!FSUtil.contains(location.directory, absolute)) return false + const real = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.succeed(undefined))) + return real !== undefined && FSUtil.contains(root.path, real) + }) + // kilocode_change end + const result = yield* Effect.try({ + try: () => + Fff.create({ + basePath: location.directory, + aiMode: true, + ...scanning(location.directory), // kilocode_change - permit broad scanning only at the exact boundary. + }), + catch: (cause) => cause, + }).pipe(Effect.orDie) + if (!result.ok) return yield* Effect.die(result.error) + yield* Effect.addFinalizer(() => Effect.sync(() => result.value.destroy()).pipe(Effect.ignore)) + return Service.of({ + glob: (input) => + // kilocode_change start + Effect.gen(function* () { + const { root, target } = yield* inspect(input.path) + // kilocode_change end + const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "") + // kilocode_change start + const found = yield* Effect.sync(() => + result.value.glob(prefix ? `${prefix}/${input.pattern}` : input.pattern, { + pageIndex: 0, + pageSize: input.limit, + }), + ) + // kilocode_change end + if (!found.ok) throw found.error + // kilocode_change start + yield* SearchTarget.validate(fs, target).pipe(Effect.orDie) + const items = yield* Effect.filter(found.value.items, (item) => safe(root, item.relativePath)) + return items.map((item) => { + // kilocode_change end + const absolute = path.resolve(location.directory, item.relativePath) + return new FileSystem.Entry({ + path: RelativePath.make(item.relativePath.replaceAll("\\", "/")), + type: "file", + mime: FSUtil.mimeType(absolute), + }) + }) + }), + grep: (input) => + // kilocode_change start + Effect.gen(function* () { + const { root, target } = yield* inspect(input.path) + // kilocode_change end + const prefix = input.path?.replaceAll("\\", "/").replace(/\/$/, "") + // kilocode_change start + const found = yield* Effect.sync(() => + result.value.grep( + [prefix ? `${prefix}/**` : undefined, input.include, input.pattern] + .filter((value) => value !== undefined) + .join(" "), + { mode: "regex", pageSize: input.limit, timeBudgetMs: 1_500 }, + ), + // kilocode_change end + ) + if (!found.ok) throw found.error + // kilocode_change start + yield* SearchTarget.validate(fs, target).pipe(Effect.orDie) + const items = yield* Effect.filter(found.value.items, (item) => safe(root, item.relativePath)) + return items.map((match) => { + // kilocode_change end + const bytes = Buffer.from(match.lineContent) + return new FileSystem.Match({ + entry: new FileSystem.Entry({ + path: RelativePath.make(match.relativePath.replaceAll("\\", "/")), + type: "file", + mime: FSUtil.mimeType(match.relativePath), + }), + line: match.lineNumber, + offset: match.byteOffset, + text: match.lineContent.length > 2_000 ? match.lineContent.slice(0, 2_000) + "..." : match.lineContent, + submatches: match.matchRanges.map(([start, end]) => ({ + text: bytes.subarray(start, end).toString("utf8"), + start, + end, + })), + }) + }) + }), + find: (input) => + Effect.sync(() => { + const options = { pageIndex: 0, pageSize: input.limit ?? 50 } + const items = (() => { + if (input.type === "file") { + const found = result.value.fileSearch(input.query.trim(), options) + if (!found.ok) throw found.error + return found.value.items.map((item, index) => ({ + path: item.relativePath, + type: "file" as const, + score: found.value.scores[index]?.total ?? 0, + })) + } + if (input.type === "directory") { + const found = result.value.directorySearch(input.query.trim(), options) + if (!found.ok) throw found.error + return found.value.items.map((item, index) => ({ + path: item.relativePath, + type: "directory" as const, + score: found.value.scores[index]?.total ?? 0, + })) + } + const found = result.value.mixedSearch(input.query.trim(), options) + if (!found.ok) throw found.error + return found.value.items.map((item, index) => ({ + path: item.item.relativePath, + type: item.type, + score: found.value.scores[index]?.total ?? 0, + })) + })() + return items + .sort((a, b) => b.score - a.score || a.path.length - b.path.length) + .map((item) => { + const relative = item.path.replaceAll("\\", "/").replace(/\/$/, "") + const absolute = path.resolve(location.directory, relative) + return new FileSystem.Entry({ + path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")), + type: item.type, + mime: item.type === "directory" ? "application/x-directory" : FSUtil.mimeType(absolute), + }) + }) + }), + }) + }), +) + +export const defaultLayer = Layer.unwrap( + Effect.sync(() => (Flag.KILO_DISABLE_FFF || !Fff.available() ? ripgrepLayer : fffLayer)), +) diff --git a/packages/core/src/filesystem/watcher.ts b/packages/core/src/filesystem/watcher.ts index 3ef8bf59f6e..69a4501fbc2 100644 --- a/packages/core/src/filesystem/watcher.ts +++ b/packages/core/src/filesystem/watcher.ts @@ -12,13 +12,11 @@ import { FSUtil } from "../fs-util" import { Git } from "../git" import { Location } from "../location" import { lazy } from "../util/lazy" -import * as Log from "../util/log" import { Ignore } from "./ignore" import { Protected } from "./protected" declare const KILO_LIBC: string | undefined -const log = Log.create({ service: "file.watcher" }) const SUBSCRIBE_TIMEOUT_MS = 10_000 export const Event = { @@ -38,8 +36,7 @@ const watcher = lazy((): typeof import("@parcel/watcher") | undefined => { `@parcel/watcher-${process.platform}-${process.arch}${process.platform === "linux" ? `-${libc || "glibc"}` : ""}`, ) return createWrapper(binding) as typeof import("@parcel/watcher") - } catch (error) { - log.error("failed to load watcher binding", { error }) + } catch { return } }) @@ -71,14 +68,17 @@ export const layer = Layer.effect( const backend = getBackend() const location = yield* Location.Service if (!backend) { - log.error("watcher backend not supported", { directory: location.directory, platform: process.platform }) + yield* Effect.logError("watcher backend not supported", { + directory: location.directory, + platform: process.platform, + }) return Service.of({}) } const w = watcher() if (!w) return Service.of({}) - log.info("watcher backend", { directory: location.directory, platform: process.platform, backend }) + yield* Effect.logInfo("watcher backend", { directory: location.directory, platform: process.platform, backend }) const events = yield* EventV2.Service const fs = yield* FSUtil.Service const git = yield* Git.Service @@ -103,9 +103,8 @@ export const layer = Layer.effect( Effect.tap((subscription) => Effect.sync(() => subscriptions.push(subscription))), Effect.timeout(SUBSCRIBE_TIMEOUT_MS), Effect.catchCause((cause) => { - log.error("failed to subscribe", { directory, cause: Cause.pretty(cause) }) pending.then((subscription) => subscription.unsubscribe()).catch(() => {}) - return Effect.void + return Effect.logError("failed to subscribe", { directory, cause: Cause.pretty(cause) }) }), ) } @@ -133,8 +132,9 @@ export const layer = Layer.effect( return Service.of({}) }).pipe( Effect.catchCause((cause) => { - log.error("failed to init watcher service", { cause: Cause.pretty(cause) }) - return Effect.succeed(Service.of({})) + return Effect.logError("failed to init watcher service", { cause: Cause.pretty(cause) }).pipe( + Effect.as(Service.of({})), + ) }), ), ) diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index c8b89df6c1a..3b8736ebee1 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -1,11 +1,12 @@ import { Config } from "effect" -import { InstallationChannel } from "../installation/version" +import { InstallationChannel } from "../installation/version" // kilocode_change export function truthy(key: string) { const value = process.env[key]?.toLowerCase() return value === "true" || value === "1" } +// kilocode_change start function falsy(key: string) { const value = process.env[key]?.toLowerCase() return value === "false" || value === "0" @@ -26,7 +27,9 @@ function number(key: string) { const KILO_EXPERIMENTAL = truthy("KILO_EXPERIMENTAL") const KILO_DISABLE_CLAUDE_CODE = truthy("KILO_DISABLE_CLAUDE_CODE") const KILO_DISABLE_CLAUDE_CODE_SKILLS = KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_SKILLS") +// kilocode_change end const copy = process.env["KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"] +const fff = process.env["KILO_DISABLE_FFF"] function enabledByExperimental(key: string) { return process.env[key] === undefined ? truthy("KILO_EXPERIMENTAL") : truthy(key) @@ -36,7 +39,7 @@ export const Flag = { OTEL_EXPORTER_OTLP_ENDPOINT: process.env["OTEL_EXPORTER_OTLP_ENDPOINT"], OTEL_EXPORTER_OTLP_HEADERS: process.env["OTEL_EXPORTER_OTLP_HEADERS"], - KILO_AUTO_SHARE: truthy("KILO_AUTO_SHARE"), + KILO_AUTO_SHARE: truthy("KILO_AUTO_SHARE"), // kilocode_change KILO_AUTO_HEAP_SNAPSHOT: truthy("KILO_AUTO_HEAP_SNAPSHOT"), KILO_GIT_BASH_PATH: process.env["KILO_GIT_BASH_PATH"], KILO_CONFIG: process.env["KILO_CONFIG"], @@ -46,53 +49,85 @@ export const Flag = { KILO_DISABLE_PRUNE: truthy("KILO_DISABLE_PRUNE"), KILO_DISABLE_TERMINAL_TITLE: truthy("KILO_DISABLE_TERMINAL_TITLE"), KILO_SHOW_TTFD: truthy("KILO_SHOW_TTFD"), + // kilocode_change start KILO_DISABLE_DEFAULT_PLUGINS: truthy("KILO_DISABLE_DEFAULT_PLUGINS"), KILO_DISABLE_LSP_DOWNLOAD: truthy("KILO_DISABLE_LSP_DOWNLOAD"), KILO_ENABLE_EXPERIMENTAL_MODELS: truthy("KILO_ENABLE_EXPERIMENTAL_MODELS"), + // kilocode_change end KILO_DISABLE_AUTOCOMPACT: truthy("KILO_DISABLE_AUTOCOMPACT"), KILO_DISABLE_MODELS_FETCH: truthy("KILO_DISABLE_MODELS_FETCH"), KILO_DISABLE_MOUSE: truthy("KILO_DISABLE_MOUSE"), + // kilocode_change start KILO_DISABLE_CLAUDE_CODE, KILO_DISABLE_CLAUDE_CODE_PROMPT: KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_PROMPT"), KILO_DISABLE_CLAUDE_CODE_SKILLS, KILO_DISABLE_EXTERNAL_SKILLS: truthy("KILO_DISABLE_EXTERNAL_SKILLS"), - KILO_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("KILO_EXPERIMENTAL_CUSTOMIZE_SKILL"), // kilocode_change + KILO_EXPERIMENTAL_CUSTOMIZE_SKILL: unstableDefault("KILO_EXPERIMENTAL_CUSTOMIZE_SKILL"), + // kilocode_change end KILO_FAKE_VCS: process.env["KILO_FAKE_VCS"], KILO_SERVER_PASSWORD: process.env["KILO_SERVER_PASSWORD"], KILO_SERVER_USERNAME: process.env["KILO_SERVER_USERNAME"], - KILO_ENABLE_QUESTION_TOOL: truthy("KILO_ENABLE_QUESTION_TOOL"), + KILO_ENABLE_QUESTION_TOOL: truthy("KILO_ENABLE_QUESTION_TOOL"), // kilocode_change + + KILO_EXPERIMENTAL, // kilocode_change + + KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe(Config.withDefault(false)), // kilocode_change - KILO_EXPERIMENTAL, - KILO_EXPERIMENTAL_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe(Config.withDefault(false)), KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: Config.boolean("KILO_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe( Config.withDefault(false), ), - KILO_EXPERIMENTAL_ICON_DISCOVERY: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_ICON_DISCOVERY"), + + KILO_EXPERIMENTAL_ICON_DISCOVERY: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_ICON_DISCOVERY"), // kilocode_change + KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT: copy === undefined ? process.platform === "win32" : truthy("KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"), - KILO_ENABLE_EXA: truthy("KILO_ENABLE_EXA") || KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_EXA"), - KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), - KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), - KILO_EXPERIMENTAL_OXFMT: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_OXFMT"), - KILO_EXPERIMENTAL_LSP_TY: truthy("KILO_EXPERIMENTAL_LSP_TY"), - KILO_EXPERIMENTAL_LSP_TOOL: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_LSP_TOOL"), - KILO_EXPERIMENTAL_PLAN_MODE: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_PLAN_MODE"), - KILO_EXPERIMENTAL_SCOUT: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_SCOUT"), - KILO_EXPERIMENTAL_MARKDOWN: !falsy("KILO_EXPERIMENTAL_MARKDOWN"), - KILO_ENABLE_PARALLEL: truthy("KILO_ENABLE_PARALLEL") || truthy("KILO_EXPERIMENTAL_PARALLEL"), + + KILO_ENABLE_EXA: truthy("KILO_ENABLE_EXA") || KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_EXA"), // kilocode_change + + KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS: number("KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), // kilocode_change + + KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX: number("KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), // kilocode_change + + KILO_EXPERIMENTAL_OXFMT: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_OXFMT"), // kilocode_change + + KILO_EXPERIMENTAL_LSP_TY: truthy("KILO_EXPERIMENTAL_LSP_TY"), // kilocode_change + + KILO_EXPERIMENTAL_LSP_TOOL: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_LSP_TOOL"), // kilocode_change + + KILO_EXPERIMENTAL_PLAN_MODE: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_PLAN_MODE"), // kilocode_change + + KILO_EXPERIMENTAL_SCOUT: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_SCOUT"), // kilocode_change + + KILO_EXPERIMENTAL_MARKDOWN: !falsy("KILO_EXPERIMENTAL_MARKDOWN"), // kilocode_change + + KILO_ENABLE_PARALLEL: truthy("KILO_ENABLE_PARALLEL") || truthy("KILO_EXPERIMENTAL_PARALLEL"), // kilocode_change + KILO_MODELS_URL: process.env["KILO_MODELS_URL"], + KILO_MODELS_PATH: process.env["KILO_MODELS_PATH"], - KILO_DISABLE_EMBEDDED_WEB_UI: truthy("KILO_DISABLE_EMBEDDED_WEB_UI"), + + KILO_DISABLE_EMBEDDED_WEB_UI: truthy("KILO_DISABLE_EMBEDDED_WEB_UI"), // kilocode_change + KILO_DB: process.env["KILO_DB"], - KILO_DISABLE_CHANNEL_DB: truthy("KILO_DISABLE_CHANNEL_DB"), - KILO_SKIP_MIGRATIONS: truthy("KILO_SKIP_MIGRATIONS"), - KILO_STRICT_CONFIG_DEPS: truthy("KILO_STRICT_CONFIG_DEPS"), + + KILO_DISABLE_CHANNEL_DB: truthy("KILO_DISABLE_CHANNEL_DB"), // kilocode_change + + KILO_SKIP_MIGRATIONS: truthy("KILO_SKIP_MIGRATIONS"), // kilocode_change + + KILO_STRICT_CONFIG_DEPS: truthy("KILO_STRICT_CONFIG_DEPS"), // kilocode_change KILO_WORKSPACE_ID: process.env["KILO_WORKSPACE_ID"], + KILO_EXPERIMENTAL_WORKSPACES: enabledByExperimental("KILO_EXPERIMENTAL_WORKSPACES"), - KILO_EXPERIMENTAL_EVENT_SYSTEM: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_EVENT_SYSTEM"), - KILO_EXPERIMENTAL_SESSION_SWITCHING: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_SESSION_SWITCHING"), - KILO_EXPERIMENTAL_SESSION_SWITCHER: enabledByExperimental("KILO_EXPERIMENTAL_SESSION_SWITCHER"), + + KILO_EXPERIMENTAL_EVENT_SYSTEM: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_EVENT_SYSTEM"), // kilocode_change + + KILO_EXPERIMENTAL_SESSION_SWITCHING: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_SESSION_SWITCHING"), // kilocode_change + + KILO_EXPERIMENTAL_SESSION_SWITCHER: enabledByExperimental("KILO_EXPERIMENTAL_SESSION_SWITCHER"), // kilocode_change + + KILO_DISABLE_FFF: fff === undefined ? process.platform === "win32" : truthy("KILO_DISABLE_FFF"), // kilocode_change + get KILO_DISABLE_PROJECT_CONFIG() { return truthy("KILO_DISABLE_PROJECT_CONFIG") }, @@ -117,7 +152,9 @@ export const Flag = { get KILO_CLIENT() { return process.env["KILO_CLIENT"] ?? "cli" }, + // kilocode_change start get KILO_SESSION_RETRY_LIMIT() { return number("KILO_SESSION_RETRY_LIMIT") }, + // kilocode_change end } diff --git a/packages/core/src/fs-util.ts b/packages/core/src/fs-util.ts index 67156873b17..475bb21ef92 100644 --- a/packages/core/src/fs-util.ts +++ b/packages/core/src/fs-util.ts @@ -8,6 +8,8 @@ import { Context, Effect, FileSystem, Layer, Schema } from "effect" import type { PlatformError } from "effect/PlatformError" import { Glob } from "./util/glob" import { serviceUse } from "./effect/service-use" +import { LayerNode } from "./effect/layer-node" +import { filesystem } from "./effect/layer-node-platform" export namespace FSUtil { export class FileSystemError extends Schema.TaggedErrorClass()("FileSystemError", { @@ -195,6 +197,7 @@ export namespace FSUtil { ) export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer)) + export const node = LayerNode.make(layer, [filesystem]) // Pure helpers that don't need Effect (path manipulation, sync operations) export function mimeType(p: string): string { diff --git a/packages/core/src/git.ts b/packages/core/src/git.ts index 806decbc674..0041c3353f4 100644 --- a/packages/core/src/git.ts +++ b/packages/core/src/git.ts @@ -6,6 +6,7 @@ import { ChildProcess } from "effect/unstable/process" import { AbsolutePath } from "./schema" import { FSUtil } from "./fs-util" import { AppProcess } from "./process" +import { LayerNode } from "./effect/layer-node" export interface Repo { /** @@ -30,6 +31,7 @@ export class WorktreeError extends Schema.TaggedErrorClass()("Git operation: Schema.Literals(["create", "remove", "list"]), message: Schema.String, directory: Schema.optional(AbsolutePath), + forceRequired: Schema.optional(Schema.Boolean), cause: Schema.optional(Schema.Defect), }) {} @@ -64,7 +66,11 @@ export interface Interface { readonly resetChanges: (directory: AbsolutePath) => Effect.Effect readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect - readonly worktreeRemove: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect + readonly worktreeRemove: (input: { + repo: Repo + directory: AbsolutePath + force: boolean + }) => Effect.Effect readonly worktreeList: (repo: Repo) => Effect.Effect } @@ -335,10 +341,12 @@ export const layer = Layer.effect( ), ) if (result.exitCode === 0) return result.stdout.toString("utf8") + const message = result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed" return yield* new WorktreeError({ operation, directory: worktreeDirectory, - message: result.stderr.toString("utf8").trim() || result.stdout.toString("utf8").trim() || "Git failed", + message, + forceRequired: operation === "remove" && /contains modified or untracked files|is dirty/i.test(message), }) }) @@ -346,11 +354,15 @@ export const layer = Layer.effect( yield* worktree("create", input.repo, ["worktree", "add", "--detach", input.directory, "HEAD"], input.directory) }) - const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: { repo: Repo; directory: AbsolutePath }) { + const worktreeRemove = Effect.fn("Git.worktreeRemove")(function* (input: { + repo: Repo + directory: AbsolutePath + force: boolean + }) { yield* worktree( "remove", input.repo, - ["worktree", "remove", "--force", input.directory], + ["worktree", "remove", ...(input.force ? ["--force"] : []), input.directory], input.directory, input.repo.store, ) @@ -389,6 +401,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(FSUtil.defaultLayer), Layer.provide(AppProcess.defaultLayer)) +export const node = LayerNode.make(layer, [FSUtil.node, AppProcess.node]) export interface Result { readonly exitCode: number diff --git a/packages/core/src/global.ts b/packages/core/src/global.ts index 9c923f826bd..7206709f044 100644 --- a/packages/core/src/global.ts +++ b/packages/core/src/global.ts @@ -7,6 +7,7 @@ import { Flock } from "./util/flock" import { markNoIndex } from "./kilocode/spotlight" // kilocode_change import { ensureRealDir } from "./kilocode/global" // kilocode_change import { Flag } from "./flag/flag" +import { LayerNode } from "./effect/layer-node" const app = "kilo" // kilocode_change // kilocode_change start @@ -92,6 +93,7 @@ export const layer = Layer.effect( ) export const defaultLayer = layer +export const node = LayerNode.make(layer, []) export const layerWith = (input: Partial) => Layer.effect( diff --git a/packages/core/src/image.ts b/packages/core/src/image.ts new file mode 100644 index 00000000000..cdfcabfb89a --- /dev/null +++ b/packages/core/src/image.ts @@ -0,0 +1,92 @@ +export * as Image from "./image" + +import { Context, Effect, Layer, Schema } from "effect" +import { Config } from "./config" +import { FileSystem } from "./filesystem" + +export class ResizerUnavailableError extends Schema.TaggedErrorClass()( + "Image.ResizerUnavailableError", + {}, +) {} + +export class DecodeError extends Schema.TaggedErrorClass()("Image.DecodeError", { + resource: Schema.String, +}) { + override get message() { + return `Image could not be decoded: ${this.resource}` + } +} + +// kilocode_change start - report images rejected before native decode allocation +export class PixelLimitError extends Schema.TaggedErrorClass()("Image.PixelLimitError", { + resource: Schema.String, + width: Schema.Number, + height: Schema.Number, + maxDimension: Schema.Number, + maxPixels: Schema.Number, +}) { + override get message() { + return `Image ${this.resource} is ${this.width}x${this.height}, exceeding the safe decode limit of ${this.maxDimension}px per side/${this.maxPixels} pixels` + } +} +// kilocode_change end + +export class SizeError extends Schema.TaggedErrorClass()("Image.SizeError", { + resource: Schema.String, + width: Schema.Number, + height: Schema.Number, + bytes: Schema.Number, + maxWidth: Schema.Number, + maxHeight: Schema.Number, + maxBytes: Schema.Number, +}) { + override get message() { + return `Image ${this.resource} is ${this.width}x${this.height} with base64 size ${this.bytes}, exceeding configured limits ${this.maxWidth}x${this.maxHeight}/${this.maxBytes} bytes` + } +} + +export interface Interface { + readonly normalize: ( + resource: string, + content: FileSystem.Content & { readonly encoding: "base64" }, + ) => Effect.Effect< + FileSystem.Content & { readonly encoding: "base64" }, + ResizerUnavailableError | DecodeError | SizeError | PixelLimitError // kilocode_change + > +} + +export class Service extends Context.Service()("@opencode/Image") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const loadAdapter = yield* Effect.cached( + Effect.tryPromise({ + try: () => import("./image/photon"), + catch: () => new ResizerUnavailableError(), + }).pipe(Effect.flatMap((adapter) => adapter.make)), + ) + const normalize = Effect.fn("Image.normalize")(function* ( + resource: string, + content: FileSystem.Content & { readonly encoding: "base64" }, + ) { + const image = Object.assign( + {}, + ...(yield* config.entries()).flatMap((entry) => + entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [], + ), + ) + const normalize = yield* loadAdapter + return yield* normalize(resource, content, { + autoResize: image.auto_resize ?? true, + maxWidth: image.max_width ?? 2_000, + maxHeight: image.max_height ?? 2_000, + maxBase64Bytes: image.max_base64_bytes ?? 5 * 1024 * 1024, + }) + }) + return Service.of({ normalize }) + }), +) + +export const locationLayer = layer.pipe(Layer.provide(Config.locationLayer)) diff --git a/packages/core/src/image/photon.ts b/packages/core/src/image/photon.ts new file mode 100644 index 00000000000..a8df2a2b35e --- /dev/null +++ b/packages/core/src/image/photon.ts @@ -0,0 +1,110 @@ +// @ts-ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm. +import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" } +import { Effect } from "effect" +import path from "node:path" +import { fileURLToPath } from "node:url" +import { FileSystem } from "../filesystem" +import { DecodeError, PixelLimitError, ResizerUnavailableError, SizeError } from "../image" // kilocode_change +import { allowed, dimensions, MAX_DIMENSION, MAX_PIXELS } from "../kilocode/image-size" // kilocode_change + +const JPEG_QUALITIES = [80, 85, 70, 55, 40] + +export const make = Effect.gen(function* () { + ;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH = + path.isAbsolute(photonWasm) ? photonWasm : fileURLToPath(new URL(photonWasm, import.meta.url)) + const loadPhoton = yield* Effect.cached( + Effect.tryPromise({ + try: () => import("@silvia-odwyer/photon-node"), + catch: () => new ResizerUnavailableError(), + }), + ) + return Effect.fn("Image.Photon.normalize")(function* ( + resource: string, + content: FileSystem.Content & { readonly encoding: "base64" }, + limits: { + readonly autoResize: boolean + readonly maxWidth: number + readonly maxHeight: number + readonly maxBase64Bytes: number + }, + ) { + // kilocode_change start - reject decompression bombs before Photon allocates native pixels + const input = Buffer.from(content.content, "base64") + const size = yield* Effect.try({ + try: () => dimensions(input), + catch: () => new DecodeError({ resource }), + }) + if (!allowed(size)) + return yield* new PixelLimitError({ + resource, + width: size.width, + height: size.height, + maxDimension: MAX_DIMENSION, + maxPixels: MAX_PIXELS, + }) + // kilocode_change end + const photon = yield* loadPhoton + const decoded = yield* Effect.try({ + try: () => photon.PhotonImage.new_from_byteslice(input), // kilocode_change + catch: () => new DecodeError({ resource }), + }) + try { + const width = decoded.get_width() + const height = decoded.get_height() + const bytes = Buffer.byteLength(content.content, "utf-8") + if (width <= limits.maxWidth && height <= limits.maxHeight && bytes <= limits.maxBase64Bytes) return content + if (!limits.autoResize) + return yield* new SizeError({ + resource, + width, + height, + bytes, + maxWidth: limits.maxWidth, + maxHeight: limits.maxHeight, + maxBytes: limits.maxBase64Bytes, + }) + const scale = Math.min(1, limits.maxWidth / width, limits.maxHeight / height) + const sizes = Array.from({ length: 32 }).reduce>((acc) => { + const previous = acc.at(-1) ?? { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + } + const next = + acc.length === 0 + ? previous + : { + width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)), + height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)), + } + return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next] + }, []) + for (const size of sizes) { + const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3) + try { + const encoders: Array Uint8Array]> = [ + ["image/png", () => resized.get_bytes()], + ...JPEG_QUALITIES.map((quality) => ["image/jpeg", () => resized.get_bytes_jpeg(quality)] as const), + ] + for (const [mime, encode] of encoders) { + const candidate = Buffer.from(encode()).toString("base64") + if (Buffer.byteLength(candidate, "utf-8") <= limits.maxBase64Bytes) + return { ...content, content: candidate, encoding: "base64" as const, mime } + } + } finally { + resized.free() + } + } + return yield* new SizeError({ + resource, + width, + height, + bytes, + maxWidth: limits.maxWidth, + maxHeight: limits.maxHeight, + maxBytes: limits.maxBase64Bytes, + }) + } finally { + decoded.free() + } + }) +}) diff --git a/packages/core/src/instruction-context.ts b/packages/core/src/instruction-context.ts index 543262674cd..0a8866341fb 100644 --- a/packages/core/src/instruction-context.ts +++ b/packages/core/src/instruction-context.ts @@ -70,7 +70,7 @@ export const layer = Layer.effectDiscard( return files.filter((file): file is File => file !== undefined) }) - yield* registry.contribute({ + yield* registry.register({ key, load: observe().pipe( Effect.map((files) => diff --git a/packages/core/src/kilocode/credential-migration.ts b/packages/core/src/kilocode/credential-migration.ts new file mode 100644 index 00000000000..387c9dce0f6 --- /dev/null +++ b/packages/core/src/kilocode/credential-migration.ts @@ -0,0 +1,48 @@ +import { Option, Schema } from "effect" +import { NonNegativeInt } from "../schema" + +const OAuth = Schema.Struct({ + type: Schema.Literal("oauth"), + refresh: Schema.String, + access: Schema.String, + expires: NonNegativeInt, + accountId: Schema.optional(Schema.String), + enterpriseUrl: Schema.optional(Schema.String), +}) + +const Key = Schema.Struct({ + type: Schema.Literal("api"), + key: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) + +const Account = Schema.Struct({ + id: Schema.String, + serviceID: Schema.String, + description: Schema.String, + credential: Schema.Union([OAuth, Key]), +}) + +const Store = Schema.Struct({ + version: Schema.Literal(2), + accounts: Schema.Record(Schema.String, Account), + active: Schema.Record(Schema.String, Schema.String), +}) + +export function parse(input: unknown) { + const decoded = Schema.decodeUnknownOption(Store)(input) + if (Option.isNone(decoded)) return [] + const first = new Set() + return Object.values(decoded.value.accounts).map((account) => { + const fallback = !first.has(account.serviceID) + first.add(account.serviceID) + return { + connectorID: account.serviceID, + label: account.description, + credential: account.credential, + active: decoded.value.active[account.serviceID] + ? decoded.value.active[account.serviceID] === account.id + : fallback, + } + }) +} diff --git a/packages/core/src/kilocode/fff.ts b/packages/core/src/kilocode/fff.ts new file mode 100644 index 00000000000..f217a417605 --- /dev/null +++ b/packages/core/src/kilocode/fff.ts @@ -0,0 +1,9 @@ +import os from "os" +import path from "path" + +export function scanning(directory: string) { + return { + enableFsRootScanning: directory === path.parse(directory).root, + enableHomeDirScanning: directory === os.homedir(), + } +} diff --git a/packages/core/src/kilocode/image-size.ts b/packages/core/src/kilocode/image-size.ts new file mode 100644 index 00000000000..84cbc21d410 --- /dev/null +++ b/packages/core/src/kilocode/image-size.ts @@ -0,0 +1,73 @@ +export const MAX_DIMENSION = 16_384 +export const MAX_PIXELS = 25_000_000 + +export function dimensions(input: Buffer) { + const png = input.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + if (png) { + if (input.length < 24 || input.toString("ascii", 12, 16) !== "IHDR") throw new TypeError("invalid PNG") + return { width: input.readUInt32BE(16), height: input.readUInt32BE(20) } + } + + const gif = input.toString("ascii", 0, 6) + if (gif === "GIF87a" || gif === "GIF89a") { + if (input.length < 10) throw new TypeError("invalid GIF") + return { width: input.readUInt16LE(6), height: input.readUInt16LE(8) } + } + + if (input[0] === 0xff && input[1] === 0xd8) { + for (let offset = 2; offset < input.length; ) { + if (input[offset] !== 0xff) throw new TypeError("invalid JPEG") + while (input[offset] === 0xff) offset++ + const marker = input[offset++] + if (marker === undefined || marker === 0xd9 || marker === 0xda) break + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue + if (offset + 2 > input.length) throw new TypeError("invalid JPEG") + const length = input.readUInt16BE(offset) + if (length < 2 || offset + length > input.length) throw new TypeError("invalid JPEG") + const frame = + (marker >= 0xc0 && marker <= 0xc3) || + (marker >= 0xc5 && marker <= 0xc7) || + (marker >= 0xc9 && marker <= 0xcb) || + (marker >= 0xcd && marker <= 0xcf) + if (frame) { + if (length < 7) throw new TypeError("invalid JPEG") + return { width: input.readUInt16BE(offset + 5), height: input.readUInt16BE(offset + 3) } + } + offset += length + } + throw new TypeError("invalid JPEG") + } + + if (input.toString("ascii", 0, 4) === "RIFF" && input.toString("ascii", 8, 12) === "WEBP") { + const chunk = input.toString("ascii", 12, 16) + if (chunk === "VP8X" && input.length >= 30) { + return { width: input.readUIntLE(24, 3) + 1, height: input.readUIntLE(27, 3) + 1 } + } + if (chunk === "VP8L" && input.length >= 25 && input[20] === 0x2f) { + const b1 = input[21] ?? 0 + const b2 = input[22] ?? 0 + const b3 = input[23] ?? 0 + const b4 = input[24] ?? 0 + return { + width: 1 + b1 + ((b2 & 0x3f) << 8), + height: 1 + ((b2 & 0xc0) >> 6) + (b3 << 2) + ((b4 & 0x0f) << 10), + } + } + if (chunk === "VP8 " && input.length >= 30 && input[23] === 0x9d && input[24] === 0x01 && input[25] === 0x2a) { + return { width: input.readUInt16LE(26) & 0x3fff, height: input.readUInt16LE(28) & 0x3fff } + } + throw new TypeError("invalid WebP") + } + + throw new TypeError("unsupported image") +} + +export function allowed(size: { width: number; height: number }) { + return ( + size.width > 0 && + size.height > 0 && + size.width <= MAX_DIMENSION && + size.height <= MAX_DIMENSION && + size.width * size.height <= MAX_PIXELS + ) +} diff --git a/packages/core/src/kilocode/search-target.ts b/packages/core/src/kilocode/search-target.ts new file mode 100644 index 00000000000..8ef90b679a9 --- /dev/null +++ b/packages/core/src/kilocode/search-target.ts @@ -0,0 +1,39 @@ +import path from "path" +import { Effect, Option } from "effect" +import { FSUtil } from "../fs-util" +import { ToolOutputStore } from "../tool-output-store" + +export interface Target { + readonly path: string + readonly type: "file" | "directory" + readonly dev: number + readonly ino: number +} + +export const inspect = Effect.fn("SearchTarget.inspect")(function* (fs: FSUtil.Interface, input: string) { + const target = yield* fs.realPath(input) + const info = yield* fs.stat(target) + if ((yield* fs.realPath(input)) !== target) + return yield* Effect.fail(new Error("Search target changed during inspection")) + const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined + const ino = Option.getOrUndefined(info.ino) + if (!type || ino === undefined) return yield* Effect.fail(new Error("Search target identity is unavailable")) + return { path: target, type, dev: info.dev, ino } satisfies Target +}) + +export const validate = Effect.fn("SearchTarget.validate")(function* (fs: FSUtil.Interface, target: Target) { + const info = yield* fs.stat(target.path) + const type = info.type === "File" ? "file" : info.type === "Directory" ? "directory" : undefined + if (type === target.type && info.dev === target.dev && Option.getOrUndefined(info.ino) === target.ino) return + yield* Effect.fail(new Error("Search target changed after approval")) +}) + +export const managed = Effect.fn("SearchTarget.managed")(function* ( + fs: FSUtil.Interface, + data: string, + target: Target, +) { + if (target.type !== "file" || !path.basename(target.path).startsWith("tool_")) return false + const directory = yield* fs.realPath(path.join(data, ToolOutputStore.MANAGED_DIRECTORY)) + return path.dirname(target.path) === directory +}) diff --git a/packages/core/src/kilocode/session-message.ts b/packages/core/src/kilocode/session-message.ts new file mode 100644 index 00000000000..74f89af193a --- /dev/null +++ b/packages/core/src/kilocode/session-message.ts @@ -0,0 +1,51 @@ +import { StoredToolContent } from "@opencode-ai/llm" +import { Schema } from "effect" + +const decode = Schema.decodeUnknownSync(StoredToolContent) +const encodeContent = Schema.encodeUnknownSync(StoredToolContent) + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +export function normalize(value: unknown): unknown { + if (!record(value)) return value + // New readers recover the canonical summary while old readers receive recent context inline. + if (value.type === "compaction" && typeof value.kilo_summary === "string") { + return { ...value, summary: value.kilo_summary } + } + if (value.type !== "assistant" || !Array.isArray(value.content)) return value + return { + ...value, + content: value.content.map((item) => { + if (!record(item) || item.type !== "tool" || !record(item.state)) return item + const status = item.state.status + if (status !== "running" && status !== "completed" && status !== "error") return item + if (!Array.isArray(item.state.content)) return item + return { ...item, state: { ...item.state, content: item.state.content.map((entry) => decode(entry)) } } + }), + } +} + +export function encode(value: unknown): unknown { + if (!record(value)) return value + // Preserve current semantics while making released compaction rows self-contained. + if (value.type === "compaction" && typeof value.summary === "string" && typeof value.recent === "string") { + return { + ...value, + summary: [value.summary, value.recent ? `Recent context:\n${value.recent}` : ""].filter(Boolean).join("\n\n"), + kilo_summary: value.summary, + } + } + if (value.type !== "assistant" || !Array.isArray(value.content)) return value + return { + ...value, + content: value.content.map((item) => { + if (!record(item) || item.type !== "tool" || !record(item.state)) return item + const status = item.state.status + if (status !== "running" && status !== "completed" && status !== "error") return item + if (!Array.isArray(item.state.content)) return item + return { ...item, state: { ...item.state, content: item.state.content.map((entry) => encodeContent(entry)) } } + }), + } +} diff --git a/packages/core/src/kilocode/spawn-validation.ts b/packages/core/src/kilocode/spawn-validation.ts new file mode 100644 index 00000000000..cb540deb81d --- /dev/null +++ b/packages/core/src/kilocode/spawn-validation.ts @@ -0,0 +1,15 @@ +import type { Effect } from "effect" +import type { ChildProcess } from "effect/unstable/process" + +const effects = new WeakMap>() + +export function attach(command: ChildProcess.StandardCommand, effect: Effect.Effect) { + effects.set(command, effect) + return command +} + +export function take(command: ChildProcess.StandardCommand) { + const effect = effects.get(command) + effects.delete(command) + return effect +} diff --git a/packages/core/src/location-layer.ts b/packages/core/src/location-layer.ts index e96f242c4a4..39a3948a596 100644 --- a/packages/core/src/location-layer.ts +++ b/packages/core/src/location-layer.ts @@ -1,15 +1,16 @@ -import { Layer, LayerMap } from "effect" +import { Effect, Layer, LayerMap } from "effect" import { Location } from "./location" import { Policy } from "./policy" import { Config } from "./config" import { PluginV2 } from "./plugin" import { Catalog } from "./catalog" +import { Connector } from "./connector" import { CommandV2 } from "./command" import { AgentV2 } from "./agent" import { PluginBoot } from "./plugin/boot" import { Project } from "./project" import { EventV2 } from "./event" -import { Auth } from "./auth" +import { Credential } from "./credential" import { Npm } from "./npm" import { ModelsDev } from "./models-dev" import { FSUtil } from "./fs-util" @@ -18,21 +19,22 @@ import { Database } from "./database/database" import { PermissionV2 } from "./permission" import { PermissionSaved } from "./permission/saved" import { FileSystem } from "./filesystem" +import { Ripgrep } from "./ripgrep" import { Watcher } from "./filesystem/watcher" import { LocationMutation } from "./location-mutation" -import { LocationSearch } from "./location-search" import { FileMutation } from "./file-mutation" -import { ProjectReference } from "./project-reference" +import { Reference } from "./reference" +import { ReferenceGuidance } from "./reference/guidance" import { RepositoryCache } from "./repository-cache" import { Pty } from "./pty" import { SkillV2 } from "./skill" import { SkillGuidance } from "./skill/guidance" import { BuiltInTools } from "./tool/builtins" +import { Image } from "./image" import { ToolRegistry } from "./tool/registry" import { ApplicationTools } from "./tool/application-tools" import { ToolOutputStore } from "./tool-output-store" import { AppProcess } from "./process" -import { Ripgrep } from "./ripgrep" import { SessionStore } from "./session/store" import { SessionTodo } from "./session/todo" import { QuestionV2 } from "./question" @@ -40,22 +42,24 @@ import { LLMClient } from "@opencode-ai/llm" import { RequestExecutor } from "@opencode-ai/llm/route" import * as SessionRunnerLLM from "./session/runner/llm" import { SessionRunnerModel } from "./session/runner/model" -import { SessionRunCoordinator } from "./session/run-coordinator" import { SystemContextBuiltIns } from "./system-context/builtins" import { FetchHttpClient } from "effect/unstable/http" export class LocationServiceMap extends LayerMap.Service()("@opencode/example/LocationServiceMap", { lookup: (ref: Location.Ref) => { + const boot = Layer.effectDiscard( + Effect.logInfo("booting location services", { directory: ref.directory, workspaceID: ref.workspaceID }), + ) const location = Location.layer(ref) - const permissionsAndTools = ToolRegistry.layer.pipe(Layer.provideMerge(PermissionV2.locationLayer)) const systemContext = SystemContextBuiltIns.locationLayer - const services = Layer.mergeAll( + const base = Layer.mergeAll( location, Policy.locationLayer, Config.locationLayer, - ProjectReference.locationLayer, + Reference.locationLayer, PluginV2.locationLayer, Catalog.locationLayer, + Connector.locationLayer, CommandV2.locationLayer, AgentV2.locationLayer, PluginBoot.locationLayer, @@ -64,53 +68,61 @@ export class LocationServiceMap extends LayerMap.Service()(" Pty.locationLayer, SkillV2.locationLayer, systemContext, - permissionsAndTools, LocationMutation.locationLayer.pipe(Layer.orDie), ).pipe(Layer.provideMerge(location)) - const commits = FileMutation.locationLayer.pipe(Layer.provide(services)) - const searches = LocationSearch.layer.pipe(Layer.provide(Ripgrep.layer), Layer.provide(services)) + const resources = ToolOutputStore.layer.pipe(Layer.provide(base)) + const permissionsAndTools = ToolRegistry.layer.pipe( + Layer.provideMerge(PermissionV2.locationLayer), + Layer.provide(resources), + Layer.provide(base), + ) + const services = Layer.mergeAll(base, resources, permissionsAndTools) + const image = Image.layer.pipe(Layer.provide(services)) + const mutation = FileMutation.locationLayer.pipe(Layer.provide(services)) const skillGuidance = SkillGuidance.locationLayer.pipe(Layer.provide(services)) - const resources = ToolOutputStore.layer.pipe(Layer.provide(services)) + const referenceGuidance = ReferenceGuidance.locationLayer.pipe(Layer.provide(services)) const todos = SessionTodo.layer.pipe(Layer.provide(services)) const questions = QuestionV2.locationLayer.pipe(Layer.provide(services)) const builtInTools = BuiltInTools.locationLayer.pipe( Layer.provide(services), - Layer.provide(commits), - Layer.provide(searches), + Layer.provide(mutation), Layer.provide(resources), Layer.provide(todos), Layer.provide(questions), + Layer.provide(image), ) const model = SessionRunnerModel.locationLayer.pipe(Layer.provide(services)) const runner = SessionRunnerLLM.defaultLayer.pipe( Layer.provide(services), Layer.provide(model), Layer.provide(skillGuidance), + Layer.provide(referenceGuidance), ) - const coordinator = SessionRunCoordinator.layer.pipe(Layer.provide(runner)) return Layer.mergeAll( + boot, services, - commits, - searches, + image, + mutation, resources, todos, questions, model, runner, - coordinator, builtInTools, + referenceGuidance, ).pipe(Layer.fresh) }, idleTimeToLive: "60 minutes", dependencies: [ Project.defaultLayer, EventV2.defaultLayer, - Auth.defaultLayer, + Credential.defaultLayer, Npm.defaultLayer, ModelsDev.defaultLayer, FSUtil.defaultLayer, AppProcess.defaultLayer, Global.defaultLayer, + Ripgrep.defaultLayer, Database.defaultLayer, SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)), PermissionSaved.defaultLayer, diff --git a/packages/core/src/location-mutation.ts b/packages/core/src/location-mutation.ts index 3d5364898ac..a620c66e31f 100644 --- a/packages/core/src/location-mutation.ts +++ b/packages/core/src/location-mutation.ts @@ -1,7 +1,7 @@ export * as LocationMutation from "./location-mutation" import path from "path" -import { Context, Effect, Layer, Option, Schema } from "effect" +import { Context, Effect, Layer, Schema } from "effect" import { FSUtil } from "./fs-util" import { Location } from "./location" @@ -22,30 +22,9 @@ export type ResolveInput = typeof ResolveInput.Type export class PathError extends Schema.TaggedErrorClass()("LocationMutation.PathError", { path: Schema.String, - reason: Schema.Literals([ - "relative_escape", - "location_escape", - "non_directory_ancestor", - "unresolved_symlink", - "location_identity_changed", - ]), + reason: Schema.Literals(["relative_escape", "location_escape", "non_directory_ancestor"]), }) {} -export class RevalidationError extends Schema.TaggedErrorClass()( - "LocationMutation.RevalidationError", - { - path: Schema.String, - reason: Schema.String, - }, -) {} - -export interface Identity { - /** Canonical path for this saved filesystem identity. */ - readonly canonical: string - readonly dev: number - readonly ino?: number -} - export interface ExternalDirectoryAuthorization { readonly action: "external_directory" /** Canonical existing directory used as the external approval boundary. */ @@ -53,11 +32,8 @@ export interface ExternalDirectoryAuthorization { /** `external_directory` permission resource. */ readonly resource: string readonly save: string - /** Saved identity checked again after approval to detect swaps. */ - readonly authority: Identity } -/** Build the `external_directory` permission request. */ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({ action: input.action, resources: [input.resource], @@ -67,61 +43,34 @@ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorizatio export interface Target { /** Canonical existing path, or missing path below a canonical directory. */ readonly canonical: string - readonly exists: boolean - readonly type?: - | "File" - | "Directory" - | "SymbolicLink" - | "BlockDevice" - | "CharacterDevice" - | "FIFO" - | "Socket" - | "Unknown" /** Permission resource: Location-relative for internal paths, canonical for external paths. */ readonly resource: string readonly externalDirectory?: ExternalDirectoryAuthorization } -/** - * A path checked before permission approval. - * - * resolve(path) -> Plan -> approve -> revalidate(plan) -> mutate immediately - * - * Tools must approve `target.externalDirectory`, when present, and their normal - * mutation action before calling `revalidate`. Revalidation rejects escapes, - * symlinks in missing suffixes, and changes made while approval is pending. It - * cannot be atomic with the next filesystem call, so mutate immediately afterward. - */ -export interface Plan { - readonly input: ResolveInput - readonly target: Target - /** Saved identity of the existing target or nearest existing ancestor. */ - readonly authority: Identity -} - export interface Interface { /** - * Check a path before approval and derive its permission resources. Relative - * paths must stay inside the Location. Absolute paths outside it require - * separate `external_directory` approval. This does not approve the tool's - * mutation action. + * Resolve a path and derive its permission resources. Relative paths must + * stay inside the Location. Absolute paths outside it require separate + * `external_directory` approval. This does not approve the mutation. */ - readonly resolve: (input: ResolveInput) => Effect.Effect - /** - * Check the plan again immediately before mutation. Reject changes to the - * target, its saved identity, or approval resources. Mutate the returned - * target immediately. - */ - readonly revalidate: (plan: Plan) => Effect.Effect + readonly resolve: (input: ResolveInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/LocationMutation") {} interface ResolvedPath { readonly canonical: string - readonly exists: boolean - readonly type?: Target["type"] - readonly authority: Identity + readonly type?: + | "File" + | "Directory" + | "SymbolicLink" + | "BlockDevice" + | "CharacterDevice" + | "FIFO" + | "Socket" + | "Unknown" + readonly directory: string } const slash = (value: string) => value.replaceAll("\\", "/") @@ -132,76 +81,19 @@ export const layer = Layer.effect( const fs = yield* FSUtil.Service const location = yield* Location.Service const locationRoot = yield* fs.realPath(location.directory) - const locationAuthority = yield* identity(locationRoot) - - function identityFrom(canonical: string, info: Effect.Success>): Identity { - return { - canonical, - dev: info.dev, - ino: Option.getOrUndefined(info.ino), - } - } - - function identity(canonical: string) { - return fs.stat(canonical).pipe(Effect.map((info) => identityFrom(canonical, info))) - } function notFound(effect: Effect.Effect) { return effect.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) } - function sameIdentity(left: Identity, right: Identity) { - return left.canonical === right.canonical && left.dev === right.dev && left.ino === right.ino - } - - /** Check whether a saved path still points to the same filesystem object. */ - const assertIdentity = Effect.fnUntraced(function* (expected: Identity) { - const canonical = yield* notFound(fs.realPath(expected.canonical)) - if (canonical === undefined) return false - const actual = yield* notFound(identity(canonical)) - if (actual === undefined) return false - return canonical === expected.canonical && sameIdentity(expected, actual) - }) - - const assertLocationIdentity = Effect.fnUntraced(function* (requested: string) { - if (yield* assertIdentity(locationAuthority)) return - return yield* new PathError({ path: requested, reason: "location_identity_changed" }) - }) - - const hasUnresolvedSymlink = Effect.fnUntraced(function* (anchor: string, suffix: string) { - let current = anchor - for (const part of suffix.split(path.sep)) { - if (!part) continue - current = path.join(current, part) - if ( - yield* fs.readLink(current).pipe( - Effect.as(true), - Effect.catch(() => Effect.succeed(false)), - ) - ) - return true - } - return false - }) - - /** - * Resolve a path to a canonical target and save an existing filesystem - * identity for later revalidation. - * - * existing path -> save target identity - * missing path -> save nearest existing directory identity - * - * Missing suffixes must not contain symlinks. - */ const resolvePath = Effect.fnUntraced(function* (absolute: string) { const existing = yield* notFound(fs.realPath(absolute)) if (existing !== undefined) { const info = yield* fs.stat(existing) return { canonical: existing, - exists: true, type: info.type, - authority: identityFrom(existing, info), + directory: info.type === "Directory" ? existing : path.dirname(existing), } satisfies ResolvedPath } @@ -210,16 +102,12 @@ export const layer = Layer.effect( const canonical = yield* notFound(fs.realPath(anchor)) if (canonical !== undefined) { const info = yield* fs.stat(canonical) - if (info.type !== "Directory") + if (info.type !== "Directory") { return yield* new PathError({ path: absolute, reason: "non_directory_ancestor" }) - const suffix = path.relative(anchor, absolute) - if (yield* hasUnresolvedSymlink(anchor, suffix)) { - return yield* new PathError({ path: absolute, reason: "unresolved_symlink" }) } return { - canonical: path.resolve(canonical, suffix), - exists: false, - authority: identityFrom(canonical, info), + canonical: path.resolve(canonical, path.relative(anchor, absolute)), + directory: canonical, } satisfies ResolvedPath } const parent = path.dirname(anchor) @@ -228,30 +116,7 @@ export const layer = Layer.effect( } }) - /** - * Choose the existing directory used for separate external approval. - * - * existing directory target -> "/*" - * file or missing target -> "/*" - */ - const externalDirectory = Effect.fnUntraced(function* (resolved: ResolvedPath, kind: Kind) { - const candidate = - kind === "directory" && resolved.type === "Directory" ? resolved.canonical : path.dirname(resolved.canonical) - const boundary = yield* resolvePath(candidate) - const directory = - boundary.exists && boundary.type === "Directory" ? boundary.canonical : boundary.authority.canonical - const resource = slash(path.join(directory, "*")) - return { - action: "external_directory" as const, - directory, - resource, - save: resource, - authority: boundary.authority, - } - }) - const resolve = Effect.fn("LocationMutation.resolve")(function* (input: ResolveInput) { - yield* assertLocationIdentity(input.path) const relative = !path.isAbsolute(input.path) const absolute = path.resolve(location.directory, input.path) const lexicallyInternal = FSUtil.contains(location.directory, absolute) @@ -266,45 +131,24 @@ export const layer = Layer.effect( const resource = external ? slash(resolved.canonical) : slash(path.relative(locationRoot, resolved.canonical) || ".") - const target: Target = { + const externalDirectory = + input.kind === "directory" && resolved.type === "Directory" ? resolved.canonical : resolved.directory + const externalResource = slash(path.join(externalDirectory, "*")) + return { canonical: resolved.canonical, - exists: resolved.exists, - type: resolved.type, resource, - externalDirectory: external ? yield* externalDirectory(resolved, input.kind ?? "file") : undefined, - } - return { input, target, authority: resolved.authority } satisfies Plan - }) - - /** - * Re-resolve a plan immediately before mutation and reject any changed - * identity, target, or approval resource. This reduces the race window but - * cannot make the next filesystem call atomic. - */ - const revalidate = Effect.fn("LocationMutation.revalidate")(function* (plan: Plan) { - const invalid = (reason: string) => new RevalidationError({ path: plan.input.path, reason }) - const fresh = yield* resolve(plan.input).pipe( - Effect.mapError((error) => (error instanceof PathError ? invalid(error.reason) : error)), - ) - if (!sameIdentity(fresh.authority, plan.authority)) return yield* invalid("mutation authority changed") - if (fresh.target.canonical !== plan.target.canonical) return yield* invalid("canonical mutation target changed") - if (fresh.target.resource !== plan.target.resource) return yield* invalid("mutation resource changed") - if (Boolean(fresh.target.externalDirectory) !== Boolean(plan.target.externalDirectory)) { - return yield* invalid("external directory authority changed") - } - if ( - fresh.target.externalDirectory && - plan.target.externalDirectory && - (fresh.target.externalDirectory.directory !== plan.target.externalDirectory.directory || - fresh.target.externalDirectory.resource !== plan.target.externalDirectory.resource || - !sameIdentity(fresh.target.externalDirectory.authority, plan.target.externalDirectory.authority)) - ) { - return yield* invalid("external directory authority changed") - } - return fresh.target + externalDirectory: external + ? { + action: "external_directory", + directory: externalDirectory, + resource: externalResource, + save: externalResource, + } + : undefined, + } satisfies Target }) - return Service.of({ resolve, revalidate }) + return Service.of({ resolve }) }), ) diff --git a/packages/core/src/location-search.ts b/packages/core/src/location-search.ts deleted file mode 100644 index 1d312e7e5b4..00000000000 --- a/packages/core/src/location-search.ts +++ /dev/null @@ -1,198 +0,0 @@ -export * as LocationSearch from "./location-search" - -import path from "path" -import { Context, Effect, Layer, Option, Schema } from "effect" -import { FileSystem } from "./filesystem" -import { FSUtil } from "./fs-util" -import { Ripgrep } from "./ripgrep" -import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" - -/** - * Location-scoped raw search substrate. Search authority is selected only by - * FileSystem, preserving Location-relative paths and named read - * references. Model formatting, leaf-tool permissions, and HTTP transport stay - * outside this service so future GlobTool, GrepTool, and HTTP consumers can - * share the same bounded filesystem behavior. - * - * TODO: Expose this substrate through HTTP fs.search/fs.grep endpoints. - * TODO: Reuse this substrate for instruction and skill discovery where suitable. - */ - -export const DEFAULT_RESULT_LIMIT = 100 -export const MAX_RESULT_LIMIT = 100 -export const MAX_LINE_PREVIEW_LENGTH = 2_000 - -export const ResultLimit = PositiveInt.check(Schema.isLessThanOrEqualTo(MAX_RESULT_LIMIT)) - -const RootInput = { - path: RelativePath.pipe(Schema.optional), - reference: Schema.NonEmptyString.pipe(Schema.optional), -} - -export const FilesInput = Schema.Struct({ - pattern: Schema.String, - ...RootInput, - limit: ResultLimit.pipe(Schema.optional), -}) -export type FilesInput = typeof FilesInput.Type & { readonly signal?: AbortSignal } - -export const GrepInput = Schema.Struct({ - pattern: Schema.String, - include: Schema.String.pipe(Schema.optional), - ...RootInput, - limit: ResultLimit.pipe(Schema.optional), -}) -export type GrepInput = typeof GrepInput.Type & { readonly signal?: AbortSignal } - -export class File extends Schema.Class("LocationSearch.File")({ - path: RelativePath, - canonical: Schema.String, - resource: Schema.String, - mtime: Schema.Number, -}) {} - -export class Submatch extends Schema.Class("LocationSearch.Submatch")({ - text: Schema.String, - start: NonNegativeInt, - end: NonNegativeInt, -}) {} - -export class Match extends Schema.Class("LocationSearch.Match")({ - path: RelativePath, - canonical: Schema.String, - resource: Schema.String, - lines: Schema.String, - linePreviewTruncated: Schema.Boolean, - line: PositiveInt, - offset: NonNegativeInt, - submatches: Schema.Array(Submatch), - mtime: Schema.Number, -}) {} - -export class FilesResult extends Schema.Class("LocationSearch.FilesResult")({ - items: Schema.Array(File), - truncated: Schema.Boolean, - partial: Schema.Boolean, -}) {} - -export class GrepResult extends Schema.Class("LocationSearch.GrepResult")({ - items: Schema.Array(Match), - truncated: Schema.Boolean, - partial: Schema.Boolean, -}) {} - -export interface Interface { - readonly files: (input: FilesInput, root?: FileSystem.RootTarget) => Effect.Effect - readonly grep: ( - input: GrepInput, - root?: FileSystem.RootTarget, - ) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/v2/LocationSearch") {} - -const slash = (value: string) => value.replaceAll("\\", "/") -const cap = (limit?: number) => Math.min(limit ?? DEFAULT_RESULT_LIMIT, MAX_RESULT_LIMIT) - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const filesystem = yield* FileSystem.Service - const ripgrep = yield* Ripgrep.Service - - const candidate = Effect.fnUntraced(function* (root: FileSystem.RootTarget, cwd: string, value: string) { - const absolute = path.resolve(cwd, value) - const lexicallyContained = - root.type === "directory" ? FSUtil.contains(root.real, absolute) : absolute === root.real - if (!lexicallyContained) return - const canonical = yield* fs.realPath(absolute).pipe(Effect.catch(() => Effect.void)) - if (!canonical || !FSUtil.contains(root.root, canonical)) return - const info = yield* fs.stat(canonical).pipe(Effect.catch(() => Effect.void)) - if (!info || info.type !== "File") return - const relative = slash(path.relative(root.root, canonical)) - return { - path: RelativePath.make(relative), - canonical, - resource: root.reference === undefined ? relative : `${root.reference}:${relative}`, - mtime: info.mtime.pipe( - Option.map((date) => date.getTime()), - Option.getOrElse(() => 0), - ), - } - }) - - return Service.of({ - files: Effect.fn("LocationSearch.files")(function* (input, approvedRoot) { - const root = yield* filesystem.revalidateRoot(approvedRoot ?? (yield* filesystem.resolveRoot(input))) - if (root.type !== "directory") - return yield* Effect.die(new globalThis.Error("Files search path must be a directory")) - const result = yield* ripgrep.files({ - cwd: root.real, - pattern: input.pattern, - limit: cap(input.limit), - signal: input.signal, - }) - const mapped = yield* Effect.forEach(result.items, (item) => candidate(root, root.real, item), { - concurrency: 16, - }) - const items = mapped.filter((item): item is File => item !== undefined).map((item) => new File(item)) - // TODO: Decide result ordering policy: V1 mtime sorting versus stable path ordering. - // TODO: Report inaccessible paths discovered after bounded ripgrep termination when practical. - return new FilesResult({ - items, - truncated: result.truncated, - partial: result.partial || items.length !== result.items.length, - }) - }), - grep: Effect.fn("LocationSearch.grep")(function* (input, approvedRoot) { - const root = yield* filesystem.revalidateRoot(approvedRoot ?? (yield* filesystem.resolveRoot(input))) - const cwd = root.type === "directory" ? root.real : path.dirname(root.real) - const result = yield* ripgrep.grep({ - cwd, - pattern: input.pattern, - include: input.include, - file: root.type === "file" ? path.basename(root.real) : undefined, - limit: cap(input.limit), - signal: input.signal, - }) - const candidates = new Map>() - for (const item of result.items) { - if (!candidates.has(item.path.text)) { - candidates.set(item.path.text, yield* Effect.cached(candidate(root, cwd, item.path.text))) - } - } - const mapped = yield* Effect.forEach( - result.items, - (item) => - candidates.get(item.path.text)!.pipe( - Effect.map( - (file) => - file && - new Match({ - ...file, - lines: item.lines.text.slice(0, MAX_LINE_PREVIEW_LENGTH), - linePreviewTruncated: item.lines.text.length > MAX_LINE_PREVIEW_LENGTH, - line: item.line_number, - offset: item.absolute_offset, - submatches: item.submatches.map( - (submatch) => - new Submatch({ text: submatch.match.text, start: submatch.start, end: submatch.end }), - ), - }), - ), - ), - { concurrency: 16 }, - ) - const items = mapped.filter((item): item is Match => item !== undefined) - // TODO: Decide result ordering policy: V1 mtime sorting versus stable path ordering. - // TODO: Report inaccessible paths discovered after bounded ripgrep termination when practical. - return new GrepResult({ - items, - truncated: result.truncated, - partial: result.partial || items.length !== result.items.length, - }) - }), - }) - }), -) diff --git a/packages/core/src/location.ts b/packages/core/src/location.ts index b8020b3c78b..ebfb096f130 100644 --- a/packages/core/src/location.ts +++ b/packages/core/src/location.ts @@ -5,11 +5,10 @@ import { WorkspaceV2 } from "./workspace" export * as Location from "./location" -export const Ref = Schema.Struct({ +export class Ref extends Schema.Class("Location.Ref")({ directory: AbsolutePath, - workspaceID: Schema.optional(WorkspaceV2.ID), -}).annotate({ identifier: "Location.Ref" }) -export type Ref = typeof Ref.Type + workspaceID: Schema.optional(WorkspaceV2.ID).pipe(Schema.withConstructorDefault(Effect.succeed(undefined))), +}) {} export class Info extends Schema.Class("Location.Info")({ directory: AbsolutePath, diff --git a/packages/core/src/model-request.ts b/packages/core/src/model-request.ts new file mode 100644 index 00000000000..f9f4f56936d --- /dev/null +++ b/packages/core/src/model-request.ts @@ -0,0 +1,124 @@ +export * as ModelRequest from "./model-request" + +import { Effect, Schema } from "effect" + +export const Generation = Schema.Struct({ + maxTokens: Schema.Number.pipe(Schema.optional), + temperature: Schema.Number.pipe(Schema.optional), + topP: Schema.Number.pipe(Schema.optional), + topK: Schema.Number.pipe(Schema.optional), + frequencyPenalty: Schema.Number.pipe(Schema.optional), + presencePenalty: Schema.Number.pipe(Schema.optional), + seed: Schema.Number.pipe(Schema.optional), + stop: Schema.String.pipe(Schema.Array, Schema.mutable, Schema.optional), +}) +export type Generation = typeof Generation.Type + +export const Request = Schema.Struct({ + headers: Schema.Record(Schema.String, Schema.String), + body: Schema.Record(Schema.String, Schema.Any), + generation: Generation.pipe( + Schema.optionalKey, + Schema.withConstructorDefault(Effect.succeed({})), + Schema.withDecodingDefaultKey(Effect.succeed({})), + ), + options: Schema.Record(Schema.String, Schema.Any).pipe( + Schema.optionalKey, + Schema.withConstructorDefault(Effect.succeed({})), + Schema.withDecodingDefaultKey(Effect.succeed({})), + ), +}) +export type Request = typeof Request.Type + +interface MutableRequest { + headers: Record + body: Record + generation?: Generation + options?: Record +} + +const generationKeys = new Map([ + ["maxOutputTokens", "maxTokens"], + ["maxTokens", "maxTokens"], + ["temperature", "temperature"], + ["topP", "topP"], + ["topK", "topK"], + ["frequencyPenalty", "frequencyPenalty"], + ["presencePenalty", "presencePenalty"], + ["seed", "seed"], + ["stopSequences", "stop"], + ["stop", "stop"], +]) + +interface Profile { + readonly namespace: string + readonly semantics: ReadonlyMap +} + +const profiles = new Map([ + [ + "@ai-sdk/openai", + { + namespace: "openai", + semantics: new Map([ + ["store", "store"], + ["promptCacheKey", "promptCacheKey"], + ["reasoningEffort", "reasoningEffort"], + ["reasoningSummary", "reasoningSummary"], + ["include", "include"], + ["textVerbosity", "textVerbosity"], + ["serviceTier", "serviceTier"], + ["service_tier", "serviceTier"], + ]), + }, + ], + [ + "@ai-sdk/openai-compatible", + { + namespace: "openai", + semantics: new Map([ + ["store", "store"], + ["promptCacheKey", "promptCacheKey"], + ["reasoningEffort", "reasoningEffort"], + ["reasoning_effort", "reasoningEffort"], + ]), + }, + ], + ["@ai-sdk/anthropic", { namespace: "anthropic", semantics: new Map([["thinking", "thinking"]]) }], +]) + +export const namespace = (packageName: string) => profiles.get(packageName)?.namespace + +export const merge = (base: Request, override: Partial) => ({ + headers: { ...base.headers, ...override.headers }, + body: { ...base.body, ...override.body }, + generation: { ...base.generation, ...override.generation }, + options: { ...base.options, ...override.options }, +}) + +export const assign = (target: MutableRequest, override: Partial) => { + Object.assign(target.headers, override.headers) + Object.assign(target.body, override.body) + Object.assign((target.generation ??= {}), override.generation) + Object.assign((target.options ??= {}), override.options) +} + +/** Partitions AI-SDK-shaped request options before they enter the Catalog. */ +export function normalizeAiSdkOptions(packageName: string | undefined, input: Readonly>) { + const generation: Record> = {} + const options: Record = {} + const body: Record = {} + const semantics = profiles.get(packageName ?? "")?.semantics + + for (const [key, value] of Object.entries(input)) { + const generationKey = generationKeys.get(key) + if (generationKey === "stop" && Array.isArray(value) && value.every((item) => typeof item === "string")) + generation[generationKey] = value + else if (generationKey !== undefined && generationKey !== "stop" && typeof value === "number") + generation[generationKey] = value + else if (semantics?.has(key)) options[semantics.get(key)!] = value + else body[key] = value + } + + return { generation, options, body } +} diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index a57647c4da0..3b0beece55f 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -1,6 +1,7 @@ import { DateTime, Schema } from "effect" import { DateTimeUtcFromMillis } from "effect/Schema" import { ProviderV2 } from "./provider" +import { ModelRequest } from "./model-request" export const ID = Schema.String.pipe(Schema.brand("ModelV2.ID")) export type ID = typeof ID.Type @@ -60,12 +61,12 @@ export class Info extends Schema.Class("ModelV2.Info")({ api: Api, capabilities: Capabilities, request: Schema.Struct({ - ...ProviderV2.Request.fields, + ...ModelRequest.Request.fields, variant: Schema.String.pipe(Schema.optional), }), variants: Schema.Struct({ id: VariantID, - ...ProviderV2.Request.fields, + ...ModelRequest.Request.fields, }).pipe(Schema.Array), time: Schema.Struct({ released: DateTimeUtcFromMillis, @@ -97,6 +98,8 @@ export class Info extends Schema.Class("ModelV2.Info")({ request: { headers: {}, body: {}, + generation: {}, + options: {}, }, variants: [], time: { diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 06fef6e82f1..8fff339ac1b 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -9,6 +9,8 @@ import { FSUtil } from "./fs-util" import { InstallationChannel, InstallationVersion } from "./installation/version" import * as ModelsRefresh from "./kilocode/models-refresh" // kilocode_change import { EventV2 } from "./event" +import { LayerNode } from "./effect/layer-node" +import { httpClient } from "./effect/layer-node-platform" export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) export type CatalogModelStatus = typeof CatalogModelStatus.Type @@ -55,7 +57,7 @@ export const Model = Schema.Struct({ Schema.Union([ Schema.Literal(true), Schema.Struct({ - field: Schema.Literals(["reasoning_content", "reasoning_details"]), + field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), }), ]), ), @@ -235,9 +237,7 @@ export const layer = Layer.effect( yield* events.publish(Event.Refreshed, {}) }), ).pipe( - Effect.tapCause((cause) => - Effect.logError("Failed to fetch models.dev").pipe(Effect.annotateLogs("cause", cause)), - ), + Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })), Effect.ignore, ) }) @@ -256,5 +256,6 @@ export const defaultLayer = layer.pipe( Layer.provide(FSUtil.defaultLayer), Layer.provide(EventV2.defaultLayer), ) +export const node = LayerNode.make(layer, [FSUtil.node, EventV2.node, httpClient]) export * as ModelsDev from "./models-dev" diff --git a/packages/core/src/npm.ts b/packages/core/src/npm.ts index 759e0487051..f3398e83911 100644 --- a/packages/core/src/npm.ts +++ b/packages/core/src/npm.ts @@ -7,6 +7,8 @@ import { NodeFileSystem } from "@effect/platform-node" import { FSUtil } from "./fs-util" import { Global } from "./global" import { EffectFlock } from "./util/effect-flock" +import { LayerNode } from "./effect/layer-node" +import { filesystem } from "./effect/layer-node-platform" import { makeRuntime } from "./effect/runtime" import { NpmConfig } from "./npm-config" @@ -250,6 +252,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Global.layer), Layer.provide(NodeFileSystem.layer), ) +export const node = LayerNode.make(layer, [FSUtil.node, Global.node, filesystem, EffectFlock.node]) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/core/src/observability.ts b/packages/core/src/observability.ts new file mode 100644 index 00000000000..faffb273333 --- /dev/null +++ b/packages/core/src/observability.ts @@ -0,0 +1,21 @@ +export * as Observability from "./observability" + +import { NodeFileSystem } from "@effect/platform-node" +import { Effect, Layer, Logger, References } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import { OtlpSerialization } from "effect/unstable/observability" +import { Logging } from "./observability/logging" +import { Otlp } from "./observability/otlp" + +export const layer = Layer.unwrap( + Effect.gen(function* () { + const logs = Logger.layer([...Logging.loggers(), ...Otlp.loggers()], { mergeWithExisting: false }).pipe( + Layer.provide(NodeFileSystem.layer), + Layer.provide(OtlpSerialization.layerJson), + Layer.provide(FetchHttpClient.layer), + Layer.orDie, + Layer.merge(Layer.succeed(References.MinimumLogLevel, Logging.minimumLogLevel())), + ) + return Layer.merge(logs, yield* Effect.promise(Otlp.tracingLayer)) + }), +) diff --git a/packages/core/src/observability/logging.ts b/packages/core/src/observability/logging.ts new file mode 100644 index 00000000000..e8576fcd9f2 --- /dev/null +++ b/packages/core/src/observability/logging.ts @@ -0,0 +1,71 @@ +import { Formatter, Logger, type LogLevel } from "effect" +import path from "path" +import { Global } from "../global" +import { runID } from "./shared" + +function formatter(id: string = runID) { + return Logger.map(Logger.formatStructured, (output) => { + const messages = Array.isArray(output.message) ? output.message : [output.message] + return [ + ["timestamp", output.timestamp], + ["level", output.level], + ["run", id], + ...messages.flatMap((value) => (plain(value) ? flatten(value) : [["message", value] as const])), + ...(output.cause === undefined ? [] : [["cause", output.cause] as const]), + ...flatten(output.spans), + ...flatten(output.annotations), + ] + .map(([key, value]) => `${key}=${format(value)}`) + .join(" ") + }) +} + +function flatten( + input: Record, + prefix = "", + seen = new WeakSet(), +): Array { + if (seen.has(input)) return [[prefix, "[Circular]"]] + seen.add(input) + const entries = Object.entries(input) + if (entries.length === 0 && prefix) return [[prefix, input]] + return entries.flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key + return plain(value) ? flatten(value, path, seen) : [[path, value] as const] + }) +} + +function plain(input: unknown): input is Record { + if (input === null || typeof input !== "object" || Array.isArray(input)) return false + const prototype = Object.getPrototypeOf(input) + return prototype === Object.prototype || prototype === null +} + +function format(input: unknown) { + const value = typeof input === "string" ? input : Formatter.format(input) + return /^[^\s="\\]+$/.test(value) ? value : JSON.stringify(value) +} + +export function fileLogger(file = path.join(Global.Path.log, "opencode.log"), id: string = runID) { + // Do not set batchWindow to 0; it causes high idle CPU usage. + return Logger.toFile(formatter(id), file, { flag: "a" }) +} + +const stderrLogger = Logger.make((options) => process.stderr.write(formatter().log(options) + "\n")) + +export function minimumLogLevel() { + const value = process.env.KILO_LOG_LEVEL?.toUpperCase() + const levels = { + DEBUG: "Debug", + INFO: "Info", + WARN: "Warn", + ERROR: "Error", + } as const satisfies Record + return value && value in levels ? levels[value as keyof typeof levels] : levels.INFO +} + +export function loggers() { + return process.env.KILO_PRINT_LOGS === "1" ? [fileLogger(), stderrLogger] : [fileLogger()] +} + +export * as Logging from "./logging" diff --git a/packages/core/src/observability/otlp.ts b/packages/core/src/observability/otlp.ts new file mode 100644 index 00000000000..f2f49015fa8 --- /dev/null +++ b/packages/core/src/observability/otlp.ts @@ -0,0 +1,79 @@ +import { Layer } from "effect" +import { OtlpLogger } from "effect/unstable/observability" +import { Flag } from "../flag/flag" +import { InstallationChannel, InstallationVersion } from "../installation/version" +import { runID } from "./shared" + +const endpoint = Flag.OTEL_EXPORTER_OTLP_ENDPOINT + +const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS + ? Flag.OTEL_EXPORTER_OTLP_HEADERS.split(",").reduce( + (acc, entry) => { + const [key, ...value] = entry.split("=") + acc[key] = value.join("=") + return acc + }, + {} as Record, + ) + : undefined + +function resourceAttributes() { + const value = process.env.OTEL_RESOURCE_ATTRIBUTES + if (!value) return {} + try { + return Object.fromEntries( + value.split(",").map((entry) => { + const index = entry.indexOf("=") + if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry") + return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))] + }), + ) + } catch { + return {} + } +} + +export function resource(): { serviceName: string; serviceVersion: string; attributes: Record } { + return { + serviceName: "opencode", + serviceVersion: InstallationVersion, + attributes: { + ...resourceAttributes(), + "deployment.environment.name": InstallationChannel, + "opencode.client": Flag.KILO_CLIENT, + "opencode.run": runID, + "service.instance.id": runID, + }, + } +} + +export function loggers() { + if (!endpoint) return [] + return [OtlpLogger.make({ url: `${endpoint}/v1/logs`, resource: resource(), headers })] +} + +export async function tracingLayer() { + if (!endpoint) return Layer.empty + const NodeSdk = await import("@effect/opentelemetry/NodeSdk") + const OTLP = await import("@opentelemetry/exporter-trace-otlp-http") + const SdkBase = await import("@opentelemetry/sdk-trace-base") + const { AsyncLocalStorageContextManager } = await import("@opentelemetry/context-async-hooks") + const { context } = await import("@opentelemetry/api") + + // The Effect Node SDK does not register a global context manager, but the AI SDK uses it to parent spans. + const manager = new AsyncLocalStorageContextManager() + manager.enable() + context.setGlobalContextManager(manager) + + return NodeSdk.layer(() => ({ + resource: resource(), + spanProcessor: new SdkBase.BatchSpanProcessor( + new OTLP.OTLPTraceExporter({ + url: `${endpoint}/v1/traces`, + headers, + }), + ), + })) +} + +export * as Otlp from "./otlp" diff --git a/packages/core/src/observability/shared.ts b/packages/core/src/observability/shared.ts new file mode 100644 index 00000000000..76393aacce5 --- /dev/null +++ b/packages/core/src/observability/shared.ts @@ -0,0 +1 @@ +export const runID = crypto.randomUUID().slice(0, 8) diff --git a/packages/core/src/plugin.ts b/packages/core/src/plugin.ts index 9cd9d6820a6..aaef65d3227 100644 --- a/packages/core/src/plugin.ts +++ b/packages/core/src/plugin.ts @@ -25,14 +25,6 @@ type HookSpec = { input: Catalog.Editor output: {} } - "account.switched": { - input: { - serviceID: import("./auth").Auth.ServiceID - from?: import("./auth").Auth.ID - to?: import("./auth").Auth.ID - } - output: {} - } "aisdk.language": { input: { model: ModelV2.Info diff --git a/packages/core/src/plugin/account.ts b/packages/core/src/plugin/account.ts deleted file mode 100644 index 803ae38d327..00000000000 --- a/packages/core/src/plugin/account.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Effect, Scope, Stream } from "effect" -import { EventV2 } from "../event" -import { PluginV2 } from "../plugin" -import { Auth } from "../auth" - -// Depending on what account is active, enable matching providers for that -// service -export const AccountPlugin = PluginV2.define({ - id: PluginV2.ID.make("account"), - effect: Effect.gen(function* () { - const accounts = yield* Auth.Service - const events = yield* EventV2.Service - const scope = yield* Scope.Scope - - yield* events.subscribe(Auth.Event.Switched).pipe( - Stream.runForEach((event) => - PluginV2.Service.use((plugin) => plugin.trigger("account.switched", event.data, {})).pipe(Effect.asVoid), - ), - Effect.forkIn(scope, { startImmediately: true }), - ) - - return { - "catalog.transform": Effect.fn(function* (evt) { - const active = yield* accounts.activeAll().pipe(Effect.orDie) - if (active.size === 0) return - for (const item of evt.provider.list()) { - const account = active.get(Auth.ServiceID.make(item.provider.id)) - if (!account) continue - evt.provider.update(item.provider.id, (provider) => { - provider.enabled = { - via: "account", - service: account.serviceID, - } - if (account.credential.type === "api") { - provider.request.body.apiKey = account.credential.key - Object.assign(provider.request.body, account.credential.metadata ?? {}) - } - if (account.credential.type === "oauth") { - provider.request.body.apiKey = account.credential.access - // kilocode_change start - if (provider.id === "kilo" && account.credential.accountId) { - provider.request.body.kilocodeOrganizationId = account.credential.accountId - } - // kilocode_change end - } - }) - } - }), - "account.switched": Effect.fn(function* () {}), - } - }), -}) diff --git a/packages/core/src/plugin/agent.ts b/packages/core/src/plugin/agent.ts index 1e4dee5b328..e8a8d8bc9d6 100644 --- a/packages/core/src/plugin/agent.ts +++ b/packages/core/src/plugin/agent.ts @@ -79,7 +79,7 @@ Your output must be: "implement rate limiting" -> Rate limiting implementation "how do I connect postgres to my API" -> Postgres API connection "best practices for React hooks" -> React hooks best practices -"@src/auth.ts can you add refresh token support" -> Auth refresh token support +"@src/credential.ts can you add refresh token support" -> Credential refresh token support "@utils/parser.ts this is broken" -> Parser bug fix "look at @config.json" -> Config review "@App.tsx add dark mode toggle" -> Dark mode toggle in App diff --git a/packages/core/src/plugin/boot.ts b/packages/core/src/plugin/boot.ts index fe05ef22f1d..6235fe9af26 100644 --- a/packages/core/src/plugin/boot.ts +++ b/packages/core/src/plugin/boot.ts @@ -1,7 +1,8 @@ export * as PluginBoot from "./boot" import { Context, Deferred, Effect, Layer } from "effect" -import { Auth } from "../auth" +import { Credential } from "../credential" +import { Connector } from "../connector" import { AgentV2 } from "../agent" import { Catalog } from "../catalog" import { CommandV2 } from "../command" @@ -9,6 +10,7 @@ import { Config } from "../config" import { ConfigAgentPlugin } from "../config/plugin/agent" import { ConfigCommandPlugin } from "../config/plugin/command" import { ConfigSkillPlugin } from "../config/plugin/skill" +import { ConfigReferencePlugin } from "../config/plugin/reference" import { EventV2 } from "../event" import { FSUtil } from "../fs-util" import { Global } from "../global" @@ -16,7 +18,6 @@ import { Location } from "../location" import { ModelsDev } from "../models-dev" import { Npm } from "../npm" import { PluginV2 } from "../plugin" -import { AccountPlugin } from "./account" import { AgentPlugin } from "./agent" import { CommandPlugin } from "./command" import { ConfigProviderPlugin } from "../config/plugin/provider" @@ -24,13 +25,15 @@ import { EnvPlugin } from "./env" import { ModelsDevPlugin } from "./models-dev" import { ProviderPlugins } from "./provider" import { SkillV2 } from "../skill" +import { Reference } from "../reference" type Plugin = { id: PluginV2.ID effect: PluginV2.Effect< | Catalog.Service | CommandV2.Service - | Auth.Service + | Credential.Service + | Connector.Service | AgentV2.Service | Npm.Service | EventV2.Service @@ -41,6 +44,7 @@ type Plugin = { | Config.Service | ModelsDev.Service | SkillV2.Service + | Reference.Service > } @@ -56,7 +60,8 @@ export const layer = Layer.effect( const catalog = yield* Catalog.Service const commands = yield* CommandV2.Service const plugin = yield* PluginV2.Service - const accounts = yield* Auth.Service + const credentials = yield* Credential.Service + const connectors = yield* Connector.Service const agents = yield* AgentV2.Service const config = yield* Config.Service const location = yield* Location.Service @@ -66,6 +71,7 @@ export const layer = Layer.effect( const fs = yield* FSUtil.Service const global = yield* Global.Service const skill = yield* SkillV2.Service + const references = yield* Reference.Service const done = yield* Deferred.make() const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) { @@ -74,7 +80,8 @@ export const layer = Layer.effect( effect: input.effect.pipe( Effect.provideService(Catalog.Service, catalog), Effect.provideService(CommandV2.Service, commands), - Effect.provideService(Auth.Service, accounts), + Effect.provideService(Credential.Service, credentials), + Effect.provideService(Connector.Service, connectors), Effect.provideService(AgentV2.Service, agents), Effect.provideService(Config.Service, config), Effect.provideService(Location.Service, location), @@ -84,6 +91,7 @@ export const layer = Layer.effect( Effect.provideService(FSUtil.Service, fs), Effect.provideService(Global.Service, global), Effect.provideService(SkillV2.Service, skill), + Effect.provideService(Reference.Service, references), Effect.provideService(PluginV2.Service, plugin), ), }) @@ -91,7 +99,6 @@ export const layer = Layer.effect( const boot = Effect.gen(function* () { yield* add(EnvPlugin) - yield* add(AccountPlugin) yield* add(AgentPlugin.Plugin) yield* add(CommandPlugin.Plugin) // kilocode_change - Kilo's CLI registry supplies `kilo-config`; do not register the redundant opencode skill. @@ -103,6 +110,7 @@ export const layer = Layer.effect( yield* add(ConfigAgentPlugin.Plugin) yield* add(ConfigCommandPlugin.Plugin) yield* add(ConfigSkillPlugin.Plugin) + yield* add(ConfigReferencePlugin.Plugin) }).pipe(Effect.withSpan("PluginBoot.boot")) yield* boot.pipe( @@ -118,9 +126,11 @@ export const layer = Layer.effect( ) export const locationLayer = layer.pipe( + Layer.provideMerge(Connector.locationLayer), Layer.provideMerge(Catalog.locationLayer), Layer.provideMerge(CommandV2.locationLayer), Layer.provideMerge(Config.locationLayer), Layer.provideMerge(AgentV2.locationLayer), Layer.provideMerge(SkillV2.locationLayer), + Layer.provideMerge(Reference.locationLayer), ) diff --git a/packages/core/src/plugin/models-dev.ts b/packages/core/src/plugin/models-dev.ts index d223e59f65b..b406a1aaf02 100644 --- a/packages/core/src/plugin/models-dev.ts +++ b/packages/core/src/plugin/models-dev.ts @@ -1,7 +1,10 @@ import { DateTime, Effect, Scope, Stream } from "effect" import { Catalog } from "../catalog" +import { Connector } from "../connector" +import { Credential } from "../credential" import { EventV2 } from "../event" import { ModelV2 } from "../model" +import { ModelRequest } from "../model-request" import { ModelsDev } from "../models-dev" import { PluginV2 } from "../plugin" import { ProviderV2 } from "../provider" @@ -38,24 +41,45 @@ function cost(input: ModelsDev.Model["cost"]) { ] } -function variants(model: ModelsDev.Model) { - return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => ({ - id: ModelV2.VariantID.make(id), - headers: { ...(item.provider?.headers ?? {}) }, - body: { ...(item.provider?.body ?? {}) }, - })) +function variants(model: ModelsDev.Model, packageName?: string) { + return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => { + const request = ModelRequest.normalizeAiSdkOptions(packageName, item.provider?.body ?? {}) + return { + id: ModelV2.VariantID.make(id), + headers: { ...(item.provider?.headers ?? {}) }, + ...request, + } + }) } export const ModelsDevPlugin = PluginV2.define({ id: PluginV2.ID.make("models-dev"), effect: Effect.gen(function* () { const catalog = yield* Catalog.Service + const connectors = yield* Connector.Service const modelsDev = yield* ModelsDev.Service const events = yield* EventV2.Service const scope = yield* Scope.Scope const transform = yield* catalog.transform() + const connectorTransform = yield* connectors.transform() const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () { const data = yield* modelsDev.get() + yield* connectorTransform((connectors) => { + for (const item of Object.values(data)) { + if (item.env.length === 0) continue + const connectorID = Connector.ID.make(item.id) + connectors.update(connectorID, (connector) => (connector.name = item.name)) + connectors.method.update({ + connectorID, + method: new Connector.KeyMethod({ + id: Connector.MethodID.make("api-key"), + type: "key", + label: "API Key", + }), + authorize: (key: string) => Effect.succeed(new Credential.Key({ type: "key", key })), + }) + } + }) yield* transform((catalog) => { for (const item of Object.values(data)) { const providerID = ProviderV2.ID.make(item.id) @@ -98,7 +122,7 @@ export const ModelsDevPlugin = PluginV2.define({ input: [...(model.modalities?.input ?? [])], output: [...(model.modalities?.output ?? [])], } - draft.variants = variants(model) + draft.variants = variants(model, model.provider?.npm ?? item.npm) draft.time.released = released(model.release_date) draft.cost = cost(model.cost) draft.status = model.status ?? "active" diff --git a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts index 1cd974ee953..afb3a183e50 100644 --- a/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts +++ b/packages/core/src/plugin/provider/cloudflare-ai-gateway.ts @@ -45,7 +45,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) function gatewayConfig(options: Record): GatewayConfig | undefined { const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId") - // AccountPlugin copies CLI prompt metadata into options. The prompt stores the + // Credential projection copies key metadata into options. The prompt stores the // gateway as gatewayId, while older config examples may use gateway. const gatewayId = process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway") diff --git a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts index ca19e63c015..10f3f5200a9 100644 --- a/packages/core/src/plugin/provider/cloudflare-workers-ai.ts +++ b/packages/core/src/plugin/provider/cloudflare-workers-ai.ts @@ -24,9 +24,15 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({ if (evt.model.providerID !== providerID) return if (evt.package !== "@ai-sdk/openai-compatible") return - if (!hasWorkersEndpoint(evt.model.api)) return + const accountId = resolveAccountId(evt.options) + if (!hasWorkersEndpoint(evt.model.api) && !accountId) return const mod = yield* Effect.promise(() => import("@ai-sdk/openai-compatible")) - evt.sdk = mod.createOpenAICompatible(sdkOptions(evt.options) as any) + evt.sdk = mod.createOpenAICompatible( + sdkOptions({ + ...evt.options, + baseURL: evt.options.baseURL ?? (accountId ? workersEndpoint(accountId) : undefined), + }) as any, + ) }), "aisdk.language": Effect.fn(function* (evt) { if (evt.model.providerID !== providerID) return diff --git a/packages/core/src/plugin/provider/google-vertex.ts b/packages/core/src/plugin/provider/google-vertex.ts index d996a1f24e0..a7168d59add 100644 --- a/packages/core/src/plugin/provider/google-vertex.ts +++ b/packages/core/src/plugin/provider/google-vertex.ts @@ -63,7 +63,10 @@ export const GoogleVertexPlugin = PluginV2.define({ if (item.provider.api.type !== "aisdk") continue if ( item.provider.api.package !== "@ai-sdk/google-vertex" && - !item.provider.api.package.includes("@ai-sdk/openai-compatible") + !( + item.provider.id === ProviderV2.ID.googleVertex && + item.provider.api.package.includes("@ai-sdk/openai-compatible") + ) ) continue const project = resolveProject(item.provider.request.body) diff --git a/packages/core/src/plugin/provider/openai-auth.ts b/packages/core/src/plugin/provider/openai-auth.ts new file mode 100644 index 00000000000..61fea09f3c7 --- /dev/null +++ b/packages/core/src/plugin/provider/openai-auth.ts @@ -0,0 +1,258 @@ +import { createServer } from "node:http" +import { Deferred, Effect } from "effect" +import { Connector } from "../../connector" +import { Credential } from "../../credential" +import { InstallationVersion } from "../../installation/version" + +const clientID = "app_EMoamEEZ73f0CkXaXp7hrann" +const issuer = "https://auth.openai.com" +const callbackPort = 1455 +const pollingSafetyMargin = 3000 + +type Pkce = { + verifier: string + challenge: string +} + +type TokenResponse = { + id_token: string + access_token: string + refresh_token: string + expires_in?: number +} + +type Claims = { + chatgpt_account_id?: string + organizations?: Array<{ id: string }> + "https://api.openai.com/auth"?: { chatgpt_account_id?: string } +} + +export const browser = { + connectorID: Connector.ID.make("openai"), + method: new Connector.OAuthMethod({ + id: Connector.MethodID.make("chatgpt-browser"), + type: "oauth", + label: "ChatGPT Pro/Plus (browser)", + }), + authorize: () => + Effect.gen(function* () { + const pkce = yield* Effect.promise(generatePKCE) + const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) + const code = yield* Deferred.make() + const redirect = `http://localhost:${callbackPort}/auth/callback` + const server = createServer((request, response) => { + const url = new URL(request.url ?? "/", `http://localhost:${callbackPort}`) + if (url.pathname !== "/auth/callback") { + response.writeHead(404).end("Not found") + return + } + // kilocode_change start - unrelated localhost requests must not terminate the active OAuth attempt + if (url.searchParams.get("state") !== state) { + response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage("Invalid OAuth state")) + return + } + // kilocode_change end + const error = url.searchParams.get("error_description") ?? url.searchParams.get("error") + const value = url.searchParams.get("code") + if (error) { + Effect.runFork(Deferred.fail(code, new Error(error))) + response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(error)) + return + } + if (!value) { + const message = "Missing authorization code" + Effect.runFork(Deferred.fail(code, new Error(message))) + response.writeHead(400, { "Content-Type": "text/html" }).end(errorPage(message)) + return + } + Effect.runFork(Deferred.succeed(code, value)) + response.writeHead(200, { "Content-Type": "text/html" }).end(successPage) + }) + yield* Effect.callback((resume) => { + server.once("error", (error) => resume(Effect.fail(error))) + server.listen(callbackPort, "localhost", () => resume(Effect.void)) + }) + yield* Effect.addFinalizer(() => + Effect.sync(() => { + server.close() + }), + ) + return { + mode: "auto" as const, + url: authorizeURL(redirect, pkce, state), + instructions: "Complete authorization in your browser. This window will close automatically.", + callback: Deferred.await(code).pipe( + Effect.flatMap((value) => exchange(value, redirect, pkce)), + Effect.map(credential), + ), + } + }), + refresh: (value) => refresh(value), +} satisfies Connector.OAuthImplementation + +export const headless = { + connectorID: Connector.ID.make("openai"), + method: new Connector.OAuthMethod({ + id: Connector.MethodID.make("chatgpt-headless"), + type: "oauth", + label: "ChatGPT Pro/Plus (headless)", + }), + authorize: () => + Effect.gen(function* () { + const device = yield* request<{ device_auth_id: string; user_code: string; interval: string }>( + `${issuer}/api/accounts/deviceauth/usercode`, + { + method: "POST", + headers: headers("application/json"), + body: JSON.stringify({ client_id: clientID }), + }, + ) + const interval = Math.max(Number.parseInt(device.interval) || 5, 1) * 1000 + return { + mode: "auto" as const, + url: `${issuer}/codex/device`, + instructions: `Enter code: ${device.user_code}`, + callback: Effect.gen(function* () { + while (true) { + const response = yield* Effect.tryPromise({ + try: (signal) => + fetch(`${issuer}/api/accounts/deviceauth/token`, { + method: "POST", + headers: headers("application/json"), + body: JSON.stringify({ device_auth_id: device.device_auth_id, user_code: device.user_code }), + signal, + }), + catch: (cause) => cause, + }) + if (response.ok) { + const data = (yield* Effect.promise(() => response.json())) as { + authorization_code: string + code_verifier: string + } + return credential( + yield* exchange(data.authorization_code, `${issuer}/deviceauth/callback`, { + verifier: data.code_verifier, + challenge: "", + }), + ) + } + if (response.status !== 403 && response.status !== 404) { + return yield* Effect.fail(new Error(`Device authorization failed: ${response.status}`)) + } + yield* Effect.sleep(interval + pollingSafetyMargin) + } + }), + } + }), + refresh: (value) => refresh(value), +} satisfies Connector.OAuthImplementation + +function headers(contentType: string) { + return { "Content-Type": contentType, "User-Agent": `kilo/${InstallationVersion}` } // kilocode_change +} + +function exchange(code: string, redirect: string, pkce: Pkce) { + return request(`${issuer}/oauth/token`, { + method: "POST", + headers: headers("application/x-www-form-urlencoded"), + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirect, + client_id: clientID, + code_verifier: pkce.verifier, + }).toString(), + }) +} + +function refresh(value: Credential.OAuth) { + return request(`${issuer}/oauth/token`, { + method: "POST", + headers: headers("application/x-www-form-urlencoded"), + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: value.refresh, + client_id: clientID, + }).toString(), + }).pipe( + Effect.map((tokens) => { + const next = credential(tokens) + return new Credential.OAuth({ + ...next, + metadata: next.metadata ?? value.metadata, + }) + }), + ) +} + +function request(url: string, init: RequestInit) { + return Effect.tryPromise({ + try: async (signal) => { + const response = await fetch(url, { ...init, signal }) + if (!response.ok) throw new Error(`Request failed: ${response.status}`) + return response.json() as Promise + }, + catch: (cause) => cause, + }) +} + +function credential(tokens: TokenResponse) { + const accountID = extractAccountID(tokens) + return new Credential.OAuth({ + type: "oauth", + refresh: tokens.refresh_token, + access: tokens.access_token, + expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, + metadata: accountID ? { accountID } : undefined, + }) +} + +async function generatePKCE(): Promise { + const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("") + const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))) + return { verifier, challenge } +} + +function base64UrlEncode(buffer: ArrayBuffer) { + return Buffer.from(buffer).toString("base64url") +} + +function authorizeURL(redirect: string, pkce: Pkce, state: string) { + return `${issuer}/oauth/authorize?${new URLSearchParams({ + response_type: "code", + client_id: clientID, + redirect_uri: redirect, + scope: "openid profile email offline_access", + code_challenge: pkce.challenge, + code_challenge_method: "S256", + id_token_add_organizations: "true", + codex_cli_simplified_flow: "true", + state, + originator: "kilo", // kilocode_change + })}` +} + +function extractAccountID(tokens: TokenResponse) { + return claim(tokens.id_token) ?? claim(tokens.access_token) +} + +function claim(token: string) { + const part = token.split(".")[1] + if (!part) return + try { + const claims = JSON.parse(Buffer.from(part, "base64url").toString()) as Claims + return ( + claims.chatgpt_account_id ?? + claims["https://api.openai.com/auth"]?.chatgpt_account_id ?? + claims.organizations?.[0]?.id + ) + } catch { + return + } +} + +const successPage = + "Kilo

Authorization successful

You can close this window.

" // kilocode_change +const errorPage = (message: string) => + `Kilo

Authorization failed

${message.replace(/[&<>"']/g, "")}

` // kilocode_change diff --git a/packages/core/src/plugin/provider/openai.ts b/packages/core/src/plugin/provider/openai.ts index 1218625a471..1142a39b4f0 100644 --- a/packages/core/src/plugin/provider/openai.ts +++ b/packages/core/src/plugin/provider/openai.ts @@ -2,10 +2,17 @@ import { Effect } from "effect" import { ModelV2 } from "../../model" import { PluginV2 } from "../../plugin" import { ProviderV2 } from "../../provider" +import { Connector } from "../../connector" +import { browser, headless } from "./openai-auth" export const OpenAIPlugin = PluginV2.define({ id: PluginV2.ID.make("openai"), effect: Effect.gen(function* () { + const connectors = yield* Connector.Service + yield* connectors.update((editor) => { + editor.method.update(browser) + editor.method.update(headless) + }) return { "aisdk.sdk": Effect.fn(function* (evt) { if (evt.package !== "@ai-sdk/openai") return diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 29b48e825bf..67fd7816a3f 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -14,7 +14,7 @@ export const OpencodePlugin = PluginV2.define({ process.env.OPENCODE_API_KEY || item.provider.env.some((env) => process.env[env]) || item.provider.request.body.apiKey || - (item.provider.enabled && item.provider.enabled.via === "account"), + (item.provider.enabled && item.provider.enabled.via === "credential"), ) evt.provider.update(item.provider.id, (provider) => { if (!hasKey) provider.request.body.apiKey = "public" diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md index 5b51f8f2ab7..1c1cbdf3c29 100644 --- a/packages/core/src/plugin/skill/customize-opencode.md +++ b/packages/core/src/plugin/skill/customize-opencode.md @@ -73,6 +73,19 @@ Every field is optional. "urls": ["https://example.com/.well-known/skills/"] }, + "references": { + "docs": { + "path": "../docs", + "description": "Use for product behavior and documentation conventions" + }, + "sdk": { + "repository": "owner/sdk", + "branch": "main", + "description": "Use for SDK implementation details", + "hidden": true + } + }, + "agent": { "my-agent": { "model": "anthropic/claude-sonnet-4-6", @@ -136,6 +149,7 @@ Shape notes worth being explicit about: - `model` always carries a provider prefix: `"anthropic/claude-sonnet-4-6"`. - `skills` is an object with `paths` and/or `urls`, not an array. +- `references` is an object keyed by alias. Each value is a local path, Git repository, or string shorthand. - `agent` is an object keyed by agent name, not an array. - `plugin` is an array of strings or `[name, options]` tuples, not an object. - `mcp[name].command` is an array of strings, never a single string. `type` is required. @@ -172,6 +186,38 @@ Register skills from non-default locations via `skills.paths` (scanned recursively for `**/SKILL.md`) and `skills.urls` (each URL serves a list of skills). +## References + +References make local directories and Git repositories outside the active +project available as supporting context. Configure them under `references`, +keyed by the alias used in `@` autocomplete: + +```json +{ + "references": { + "docs": { + "path": "../product-docs", + "description": "Use for product behavior and terminology" + }, + "effect": { + "repository": "Effect-TS/effect", + "branch": "main", + "description": "Use for Effect implementation details" + } + } +} +``` + +Local `path` values may be relative to the declaring config, absolute, or use +`~/`. Git `repository` values accept Git URLs, host/path references, and GitHub +`owner/repo` shorthand; `branch` is optional. Both forms support optional +`description` and `hidden` fields. + +- Only references with a `description` are advertised to agents in system context. +- `hidden: true` removes a reference from TUI `@` autocomplete only. It remains available to agents and by direct path. +- Reference directories are automatically allowed through the external-directory boundary; normal read/edit/tool permissions still apply. +- String shorthand is supported: use `"docs": "../docs"` for local paths or `"effect": "Effect-TS/effect"` for Git repositories. + ## Agents Two ways to define an agent. Use the file form for anything non-trivial. @@ -303,7 +349,7 @@ Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, "type": "remote", "url": "https://...", "enabled": true, - "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" } + "headers": { "Authorization": "Bearer {env:GITHUB_TOKEN}" } }, "old-server": { "enabled": false } } @@ -311,7 +357,9 @@ Special object-shaped (not callbacks): `tool: { my_tool: { ... } }`, ``` `command` is an array of strings. `type` is required. Use `enabled: false` to -disable a server inherited from a parent config. +disable a server inherited from a parent config. String values such as header +tokens support `{env:VAR}` interpolation (and `{file:path}`); the shell-style +`${VAR}` is not substituted. ## Permissions diff --git a/packages/core/src/process.ts b/packages/core/src/process.ts index 4555b28017a..44418d74c1b 100644 --- a/packages/core/src/process.ts +++ b/packages/core/src/process.ts @@ -3,6 +3,7 @@ import type { PlatformError } from "effect/PlatformError" import { ChildProcess } from "effect/unstable/process" import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" import { CrossSpawnSpawner } from "./cross-spawn-spawner" +import { LayerNode } from "./effect/layer-node" export class AppProcessError extends Schema.TaggedErrorClass()("AppProcessError", { command: Schema.String, @@ -230,5 +231,6 @@ export const layer = Layer.effect( ) export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer)) +export const node = LayerNode.make(layer, [CrossSpawnSpawner.node]) export * as AppProcess from "./process" diff --git a/packages/core/src/project-reference.ts b/packages/core/src/project-reference.ts deleted file mode 100644 index f0d284b4671..00000000000 --- a/packages/core/src/project-reference.ts +++ /dev/null @@ -1,241 +0,0 @@ -export * as ProjectReference from "./project-reference" - -import path from "path" -import { Context, Effect, Layer } from "effect" -import { Config } from "./config" -import { ConfigReference } from "./config/reference" -import { FSUtil } from "./fs-util" -import { Flag } from "./flag/flag" -import { Global } from "./global" -import { Location } from "./location" -import { Repository } from "./repository" -import { RepositoryCache } from "./repository-cache" - -export type Resolved = - | { readonly name: string; readonly kind: "local"; readonly path: string } - | { - readonly name: string - readonly kind: "git" - readonly repository: string - readonly reference: Repository.RemoteReference - readonly path: string - readonly branch?: string - } - | { readonly name: string; readonly kind: "invalid"; readonly repository?: string; readonly message: string } - -type Valid = Exclude - -export type Mention = - | { - readonly name: string - readonly kind: "reference" - readonly reference: Valid - readonly target?: string - readonly path: string - } - | { readonly name: string; readonly kind: "invalid"; readonly target?: string; readonly message: string } - | { - readonly name: string - readonly kind: "missing" - readonly target: string - readonly path: string - readonly message: string - } - -export interface Interface { - readonly list: () => Effect.Effect - readonly get: (name: string) => Effect.Effect - readonly resolveMention: (value: string) => Effect.Effect - readonly ensurePath: (target?: string) => Effect.Effect - readonly containsManagedPath: (target?: string) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/ProjectReference") {} - -type Materializer = { - readonly name: string - readonly repository: string - readonly path: string - readonly run: Effect.Effect -} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - if (!Flag.KILO_EXPERIMENTAL_REFERENCES) return Service.of(inert) - - const config = yield* Config.Service - const fs = yield* FSUtil.Service - const global = yield* Global.Service - const location = yield* Location.Service - const cache = yield* RepositoryCache.Service - const references = resolveAll({ - references: ConfigReference.normalize( - Object.assign( - {}, - ...(yield* config.entries()) - .filter((entry): entry is Config.Document => entry.type === "document") - .map((document) => document.info.references ?? {}), - ), - ), - directory: location.project.directory, - home: global.home, - repos: global.repos, - }) - const materializers = yield* Effect.forEach( - uniqueGitReferences(references), - Effect.fnUntraced(function* (reference) { - return { - name: reference.name, - repository: reference.repository, - path: reference.path, - run: yield* Effect.cached( - cache - .ensure({ reference: reference.reference, branch: reference.branch, refresh: true }) - .pipe(Effect.asVoid), - ), - } - }), - ) - - yield* Effect.forEach( - materializers, - (materializer) => - materializer.run.pipe( - Effect.catchCause((cause) => - Effect.logWarning("failed to materialize project reference").pipe( - Effect.annotateLogs({ name: materializer.name, repository: materializer.repository, cause }), - ), - ), - ), - { concurrency: 4, discard: true }, - ).pipe(Effect.forkScoped) - - const ensurePath = Effect.fn("ProjectReference.ensurePath")(function* (target?: string) { - const normalized = normalizePath(target) - if (!normalized) - return yield* Effect.forEach(materializers, (materializer) => materializer.run, { discard: true }) - yield* materializers.find((materializer) => contains(materializer.path, normalized))?.run ?? Effect.void - }) - - return Service.of({ - list: Effect.fn("ProjectReference.list")(function* () { - return references - }), - get: Effect.fn("ProjectReference.get")(function* (name: string) { - return references.find((reference) => reference.name === name) - }), - ensurePath, - containsManagedPath: Effect.fn("ProjectReference.containsManagedPath")(function* (target?: string) { - const normalized = normalizePath(target) - return normalized - ? references.some((reference) => reference.kind === "git" && contains(reference.path, normalized)) - : false - }), - resolveMention: Effect.fn("ProjectReference.resolveMention")(function* (value: string) { - const [name, ...rest] = value.split("/") - const target = rest.length ? rest.join("/") : undefined - const reference = references.find((reference) => reference.name === name) - if (!reference) return - if (reference.kind === "invalid") return { name, kind: "invalid", target, message: reference.message } - if (reference.kind === "git") yield* ensurePath(reference.path) - if (!target) return { name, kind: "reference", reference, path: reference.path } - - const resolved = path.resolve(reference.path, target) - if (!FSUtil.contains(reference.path, resolved)) - return { name, kind: "invalid", target, message: "Reference target escapes its root" } - if (!(yield* fs.existsSafe(resolved))) - return { name, kind: "missing", target, path: resolved, message: "Reference target does not exist" } - return { name, kind: "reference", reference, target, path: resolved } - }), - }) - }), -) - -export const locationLayer = layer.pipe(Layer.provideMerge(Config.locationLayer)) - -const inert: Interface = { - list: () => Effect.succeed([]), - get: () => Effect.succeed(undefined), - resolveMention: () => Effect.succeed(undefined), - ensurePath: () => Effect.void, - containsManagedPath: () => Effect.succeed(false), -} - -export function resolveAll(input: { - references: ConfigReference.NormalizedInfo - directory: string - home: string - repos: string -}) { - const seen = new Map() - return Object.entries(input.references).map(([name, reference]): Resolved => { - const resolved = resolve({ name, reference, directory: input.directory, home: input.home, repos: input.repos }) - if (resolved.kind !== "git") return resolved - const existing = seen.get(resolved.path) - if (!existing) { - seen.set(resolved.path, { name, branch: resolved.branch }) - return resolved - } - if (existing.branch === resolved.branch) return resolved - return { - name, - kind: "invalid", - repository: resolved.repository, - message: `Reference conflicts with @${existing.name}: both use ${resolved.path}, but @${existing.name} requests ${existing.branch ?? "default branch"} and @${name} requests ${resolved.branch ?? "default branch"}`, - } - }) -} - -export function resolve(input: { - name: string - reference: ConfigReference.NormalizedEntry - directory: string - home: string - repos: string -}): Resolved { - if (input.reference.kind === "invalid") return { name: input.name, kind: "invalid", message: input.reference.message } - if (input.reference.kind === "local") { - return { name: input.name, kind: "local", path: localPath(input.directory, input.home, input.reference.path) } - } - const reference = Repository.parse(input.reference.repository) - if (!reference || !Repository.isRemote(reference)) { - return { - name: input.name, - kind: "invalid", - repository: input.reference.repository, - message: "Repository must be a git URL, host/path reference, or GitHub owner/repo shorthand", - } - } - return { - name: input.name, - kind: "git", - repository: input.reference.repository, - reference, - path: Repository.cachePath(input.repos, reference), - branch: input.reference.branch, - } -} - -function localPath(directory: string, home: string, value: string) { - if (value.startsWith("~/")) return path.join(home, value.slice(2)) - return path.isAbsolute(value) ? value : path.resolve(directory, value) -} - -function uniqueGitReferences(references: Resolved[]) { - const seen = new Set() - return references.filter((reference): reference is Extract => { - if (reference.kind !== "git" || seen.has(reference.path)) return false - seen.add(reference.path) - return true - }) -} - -function normalizePath(target?: string) { - if (!target) return - return process.platform === "win32" ? FSUtil.normalizePath(target) : target -} - -function contains(parent: string, child: string) { - return FSUtil.contains(normalizePath(parent) ?? parent, normalizePath(child) ?? child) -} diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts index 3102b7fda33..41c78ee92f8 100644 --- a/packages/core/src/project.ts +++ b/packages/core/src/project.ts @@ -2,12 +2,13 @@ export * as ProjectV2 from "./project" export * as Project from "./project" import { Context, Effect, Layer, Schema } from "effect" -import { eq } from "drizzle-orm" +import { asc, desc, eq } from "drizzle-orm" import path from "path" import { AbsolutePath, withStatics } from "./schema" import { FSUtil } from "./fs-util" import { Database } from "./database/database" import { Git } from "./git" +import { LayerNode } from "./effect/layer-node" import { Hash } from "./util/hash" import { ProjectDirectoryTable } from "./project/sql" @@ -36,7 +37,12 @@ export const DirectoriesInput = Schema.Struct({ }).annotate({ identifier: "Project.DirectoriesInput" }) export type DirectoriesInput = typeof DirectoriesInput.Type -export const Directories = Schema.Array(AbsolutePath).annotate({ identifier: "Project.Directories" }) +export const Directories = Schema.Array( + Schema.Struct({ + directory: AbsolutePath, + type: Schema.Literals(["main", "root", "git_worktree"]), + }), +).annotate({ identifier: "Project.Directories" }) export type Directories = typeof Directories.Type export interface Interface { @@ -73,14 +79,13 @@ export const layer = Layer.effect( const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) { const rows = yield* db - .select({ directory: ProjectDirectoryTable.directory }) + .select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type }) .from(ProjectDirectoryTable) .where(eq(ProjectDirectoryTable.project_id, input.projectID)) + .orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory)) .all() .pipe(Effect.orDie) - return rows - .toSorted((a, b) => a.directory.localeCompare(b.directory)) - .map((row) => AbsolutePath.make(row.directory)) + return rows.map((row) => ({ directory: AbsolutePath.make(row.directory), type: row.type })) }) const cached = Effect.fnUntraced(function* (dir: string) { @@ -155,3 +160,4 @@ export const defaultLayer = layer.pipe( Layer.provide(FSUtil.defaultLayer), Layer.provide(Git.defaultLayer), ) +export const node = LayerNode.make(layer, [Database.node, FSUtil.node, Git.node]) diff --git a/packages/core/src/project/copy-strategies.ts b/packages/core/src/project/copy-strategies.ts index a83bcd4cc21..3e67ea66d3f 100644 --- a/packages/core/src/project/copy-strategies.ts +++ b/packages/core/src/project/copy-strategies.ts @@ -19,10 +19,10 @@ export function makeStrategies(input: { yield* input.git.worktreeCreate({ repo: repo(options.sourceDirectory), directory: options.directory }) return { directory: yield* input.canonical(options.directory) } }), - remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (directory) { - const found = yield* input.git.find(directory) - if (!found) return yield* new DirectoryUnavailableError({ directory }) - yield* input.git.worktreeRemove({ repo: found, directory }) + remove: Effect.fn("ProjectCopy.GitWorktree.remove")(function* (options) { + const found = yield* input.git.find(options.directory) + if (!found) return yield* new DirectoryUnavailableError({ directory: options.directory }) + yield* input.git.worktreeRemove({ repo: found, directory: options.directory, force: options.force }) }), list: Effect.fn("ProjectCopy.GitWorktree.list")(function* (directory) { const found = yield* input.git.find(directory) diff --git a/packages/core/src/project/copy.ts b/packages/core/src/project/copy.ts index 07b8eb4000a..de2beda9816 100644 --- a/packages/core/src/project/copy.ts +++ b/packages/core/src/project/copy.ts @@ -8,6 +8,7 @@ import { FSUtil } from "../fs-util" import { Git } from "../git" import { Database } from "../database/database" import { EventV2 } from "../event" +import { LayerNode } from "../effect/layer-node" import { Project } from "../project" import { ProjectDirectoryTable } from "./sql" import { makeStrategies } from "./copy-strategies" @@ -34,6 +35,7 @@ export type CreateInput = typeof CreateInput.Type export const RemoveInput = Schema.Struct({ projectID: Project.ID, directory: AbsolutePath, + force: Schema.Boolean, }).annotate({ identifier: "ProjectCopy.RemoveInput" }) export type RemoveInput = typeof RemoveInput.Type @@ -82,7 +84,10 @@ export interface Strategy { sourceDirectory: AbsolutePath directory: AbsolutePath }) => Effect.Effect - readonly remove: (directory: AbsolutePath) => Effect.Effect + readonly remove: (input: { + directory: AbsolutePath + force: boolean + }) => Effect.Effect readonly list: (directory: AbsolutePath) => Effect.Effect readonly detect: (directory: AbsolutePath) => Effect.Effect } @@ -209,7 +214,7 @@ export const layer = Layer.effect( const copyDirectory = yield* canonical(input.directory) const id = yield* detect({ directory: copyDirectory }) if (!id) return yield* new StrategyNotFoundError({ directory: copyDirectory }) - yield* strategy(id).remove(copyDirectory) + yield* strategy(id).remove({ directory: copyDirectory, force: input.force }) yield* changed(input.projectID, yield* removeStored(input.projectID, copyDirectory)) }) @@ -271,3 +276,4 @@ export const defaultLayer = layer.pipe( Layer.provide(Git.defaultLayer), Layer.provide(EventV2.defaultLayer), ) +export const node = LayerNode.make(layer, [FSUtil.node, Git.node, EventV2.node, Database.node]) diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index 878e4556552..58b5e9f813d 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -2,6 +2,7 @@ export * as ProviderV2 from "./provider" import { withStatics } from "./schema" import { Schema } from "effect" +import { Credential } from "./credential" export const ID = Schema.String.pipe( Schema.brand("ProviderV2.ID"), @@ -55,8 +56,8 @@ export class Info extends Schema.Class("ProviderV2.Info")({ name: Schema.String, }), Schema.Struct({ - via: Schema.Literal("account"), - service: Schema.String, + via: Schema.Literal("credential"), + credentialID: Credential.ID, }), Schema.Struct({ via: Schema.Literal("custom"), diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index 7793d6c5e1a..79ddc99b639 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -8,9 +8,7 @@ import { NonNegativeInt, PositiveInt } from "./schema" import { PtyID } from "./pty/schema" import { SessionSchema } from "./session/schema" // kilocode_change import { lazy } from "./util/lazy" -import * as Log from "./util/log" -const log = Log.create({ service: "pty" }) const BUFFER_LIMIT = 1024 * 1024 * 2 const BUFFER_CHUNK = 64 * 1024 const encoder = new TextEncoder() @@ -161,7 +159,7 @@ export const layer = Layer.effect( const session = sessions.get(id) if (!session) return false sessions.delete(id) - log.info("removing session", { id }) + yield* Effect.logInfo("removing session", { id }) teardown(session) yield* events.publish(Event.Deleted, { id: session.info.id }) return true @@ -182,7 +180,7 @@ export const layer = Layer.effect( const create = Effect.fn("Pty.create")(function* (input: PreparedCreate) { const id = PtyID.ascending() - log.info("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd }) + yield* Effect.logInfo("creating session", { id, cmd: input.command, args: input.args, cwd: input.cwd }) const { spawn } = yield* Effect.promise(() => pty()) // kilocode_change - expose the pty id to the spawned shell so a nested `kilo tui`/`kilo run` can // detect it is running inside a kilo-spawned terminal (read via process.env.KILO_PTY_ID) @@ -237,7 +235,7 @@ export const layer = Layer.effect( if (session.info.status === "exited") return runFork( Effect.gen(function* () { - log.info("session exited", { id, exitCode }) + yield* Effect.logInfo("session exited", { id, exitCode }) session.info.status = "exited" yield* events.publish(Event.Exited, { id, exitCode }) yield* removeSession(id) @@ -272,7 +270,7 @@ export const layer = Layer.effect( const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) { const session = yield* requireSession(id).pipe(Effect.tapError(() => Effect.sync(() => ws.close()))) - log.info("client connected to session", { id, directory: location.directory }) + yield* Effect.logInfo("client connected to session", { id, directory: location.directory }) const sub = sock(ws) session.subscribers.delete(sub) session.subscribers.set(sub, ws) @@ -308,7 +306,6 @@ export const layer = Layer.effect( session.process.write(typeof message === "string" ? message : new TextDecoder().decode(message)) }, onClose: () => { - log.info("client disconnected from session", { id }) cleanup() }, } diff --git a/packages/core/src/pty/ticket.ts b/packages/core/src/pty/ticket.ts index 1d2452cda56..c625390be02 100644 --- a/packages/core/src/pty/ticket.ts +++ b/packages/core/src/pty/ticket.ts @@ -4,6 +4,7 @@ import { WorkspaceV2 } from "../workspace" import { PositiveInt } from "../schema" import { PtyID } from "./schema" import { Cache, Context, Duration, Effect, Layer, Schema } from "effect" +import { LayerNode } from "../effect/layer-node" const DEFAULT_TTL = Duration.seconds(60) const CAPACITY = 10_000 @@ -56,3 +57,4 @@ export const make = (ttl: Duration.Input = DEFAULT_TTL) => export const layer = Layer.effect(Service, make()) export const defaultLayer = layer +export const node = LayerNode.make(layer, []) diff --git a/packages/core/src/public/opencode.ts b/packages/core/src/public/opencode.ts index 8ec58de4d55..7388705d8c4 100644 --- a/packages/core/src/public/opencode.ts +++ b/packages/core/src/public/opencode.ts @@ -1,9 +1,11 @@ export * as OpenCode from "./opencode" import { Context, Effect, Layer } from "effect" +import { Catalog } from "../catalog" import { Database } from "../database/database" import { EventV2 } from "../event" import { LocationServiceMap } from "../location-layer" +import { PluginBoot } from "../plugin/boot" import { ProjectV2 } from "../project" import { SessionV2 } from "../session" import * as SessionExecutionLocal from "../session/execution/local" @@ -15,32 +17,77 @@ import { Tool } from "./tool" export interface Interface { readonly sessions: Session.Interface - readonly tools: Tool.Service + readonly tools: Tool.Interface } /** Intentional public native API for Effect applications embedding OpenCode. */ export class Service extends Context.Service()("@opencode/public/OpenCode") {} -const SessionsLayer = SessionV2.layer.pipe( - Layer.provide(SessionProjector.layer), - Layer.provide(SessionExecutionLocal.layer), - Layer.provide(LocationServiceMap.layer), - Layer.provide(SessionStore.layer), - Layer.provide(EventV2.layer), - Layer.provide(Database.defaultLayer), - Layer.provide(ProjectV2.defaultLayer), - Layer.orDie, -) +class SessionModelValidation extends Context.Service< + SessionModelValidation, + { + readonly validate: ( + input: Session.SwitchModelInput & { readonly location: Session.Info["location"] }, + ) => Effect.Effect + } +>()("@opencode/public/OpenCode/SessionModelValidation") {} + const ApplicationToolsLayer = ApplicationTools.layer +const LocationServicesLayer = LocationServiceMap.layer.pipe(Layer.provide(ApplicationToolsLayer)) +const SessionModelValidationLayer = Layer.effect( + SessionModelValidation, + Effect.gen(function* () { + const locations = yield* LocationServiceMap + return SessionModelValidation.of({ + validate: Effect.fn("OpenCode.sessions.validateModel")(function* (input) { + yield* Effect.gen(function* () { + yield* (yield* PluginBoot.Service).wait() + const catalog = yield* Catalog.Service + const model = (yield* catalog.model.available()).find( + (model) => model.providerID === input.model.providerID && model.id === input.model.id, + ) + if (!model) + return yield* new Session.ModelUnavailableError({ + providerID: input.model.providerID, + modelID: input.model.id, + }) + if ( + input.model.variant !== undefined && + input.model.variant !== "default" && + !model.variants.some((variant) => variant.id === input.model.variant) + ) + return yield* new Session.VariantUnavailableError({ + providerID: input.model.providerID, + modelID: input.model.id, + variant: input.model.variant, + }) + }).pipe(Effect.provide(locations.get(input.location))) + }), + }) + }), +) +const SessionsLayer = Layer.merge( + SessionV2.layer.pipe( + Layer.provide(SessionProjector.layer), + Layer.provide(SessionExecutionLocal.layer), + Layer.provide(SessionStore.layer), + Layer.provide(EventV2.layer), + Layer.provide(Database.defaultLayer), + Layer.provide(ProjectV2.defaultLayer), + Layer.orDie, + ), + SessionModelValidationLayer, +).pipe(Layer.provide(LocationServicesLayer)) // TODO: Accept explicit storage so tests and embeddings can select disposable or application-owned persistence. export const layer = Layer.effect( Service, Effect.gen(function* () { const sessions = yield* SessionV2.Service const tools = yield* ApplicationTools.Service + const validation = yield* SessionModelValidation return Service.of({ - tools: { attach: tools.attach }, + tools: { register: tools.register }, sessions: { create: (input) => sessions.create({ @@ -51,6 +98,12 @@ export const layer = Layer.effect( }), get: sessions.get, list: sessions.list, + switchModel: Effect.fn("OpenCode.sessions.switchModel")(function* (input) { + const session = yield* sessions.get(input.sessionID) + yield* validation.validate({ ...input, location: session.location }) + yield* sessions.switchModel(input) + }), + interrupt: sessions.interrupt, prompt: (input) => sessions.prompt({ id: input.id, diff --git a/packages/core/src/public/session.ts b/packages/core/src/public/session.ts index f66fe2b0881..6c61aff3b6d 100644 --- a/packages/core/src/public/session.ts +++ b/packages/core/src/public/session.ts @@ -1,7 +1,8 @@ export * as Session from "./session" -import { Effect, Stream } from "effect" +import { Effect, Schema, Stream } from "effect" import { EventV2 } from "../event" +import { ModelV2 } from "../model" import { SessionV2 } from "../session" import { MessageDecodeError } from "../session/error" import { SessionEvent } from "../session/event" @@ -43,6 +44,23 @@ export type NotFoundError = SessionV2.NotFoundError export const PromptConflictError = SessionV2.PromptConflictError export type PromptConflictError = SessionV2.PromptConflictError +export class ModelUnavailableError extends Schema.TaggedErrorClass()( + "Session.ModelUnavailableError", + { + providerID: Model.Ref.fields.providerID, + modelID: Model.Ref.fields.id, + }, +) {} + +export class VariantUnavailableError extends Schema.TaggedErrorClass()( + "Session.VariantUnavailableError", + { + providerID: Model.Ref.fields.providerID, + modelID: Model.Ref.fields.id, + variant: ModelV2.VariantID, + }, +) {} + export { MessageDecodeError } export interface CreateInput { @@ -59,6 +77,11 @@ export interface PromptInput { readonly delivery?: Delivery } +export interface SwitchModelInput { + readonly sessionID: ID + readonly model: Model.Ref +} + export interface MessagesInput { readonly sessionID: ID readonly limit?: number @@ -84,6 +107,11 @@ export interface Interface { readonly get: (sessionID: ID) => Effect.Effect readonly list: (input?: ListInput) => Effect.Effect readonly prompt: (input: PromptInput) => Effect.Effect + readonly switchModel: ( + input: SwitchModelInput, + ) => Effect.Effect + /** Interrupt the active V2 execution chain for one Session on this process. Interrupting an idle or missing Session is a no-op. */ + readonly interrupt: (sessionID: ID) => Effect.Effect readonly messages: (input: MessagesInput) => Effect.Effect readonly message: (input: MessageInput) => Effect.Effect readonly context: (sessionID: ID) => Effect.Effect diff --git a/packages/core/src/public/tool.ts b/packages/core/src/public/tool.ts index 427d18cb35a..97b436fed92 100644 --- a/packages/core/src/public/tool.ts +++ b/packages/core/src/public/tool.ts @@ -1,17 +1,17 @@ export * as Tool from "./tool" import { Effect, Scope } from "effect" -import type { NativeTool } from "../tool/native" +import type { AnyTool, RegistrationError } from "../tool/tool" -export { Failure, make } from "../tool/native" -export type { Any, Content, Context, Executable } from "../tool/native" +export { Failure, RegistrationError, make } from "../tool/tool" +export type { AnyTool, Content, Context, Definition } from "../tool/tool" -export interface Service { +export interface Interface { /** - * Attach same-process tools to this OpenCode instance for the current Scope. + * Register same-process tools on this OpenCode instance for the current Scope. * Location tools with the same name take precedence where they are installed. * Closing the Scope removes the tools immediately, so calls that have not * started settling may fail because the tool is no longer available. */ - readonly attach: (tools: Readonly>) => Effect.Effect + readonly register: (tools: Readonly>) => Effect.Effect } diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts new file mode 100644 index 00000000000..a7890243c50 --- /dev/null +++ b/packages/core/src/reference.ts @@ -0,0 +1,148 @@ +export * as Reference from "./reference" + +import { Context, Effect, Layer, Schema, Scope } from "effect" +import { castDraft } from "immer" +import { Global } from "./global" +import { EventV2 } from "./event" +import { Repository } from "./repository" +import { RepositoryCache } from "./repository-cache" +import { AbsolutePath } from "./schema" +import { State } from "./state" + +export class LocalSource extends Schema.Class("Reference.LocalSource")({ + type: Schema.Literal("local"), + path: AbsolutePath, + description: Schema.String.pipe(Schema.optional), + hidden: Schema.Boolean.pipe(Schema.optional), +}) {} + +export class GitSource extends Schema.Class("Reference.GitSource")({ + type: Schema.Literal("git"), + repository: Schema.String, + branch: Schema.String.pipe(Schema.optional), + description: Schema.String.pipe(Schema.optional), + hidden: Schema.Boolean.pipe(Schema.optional), +}) {} + +export const Source = Schema.Union([LocalSource, GitSource]).pipe(Schema.toTaggedUnion("type")) +export type Source = typeof Source.Type + +export const Event = { + Updated: EventV2.define({ type: "reference.updated", schema: {} }), +} + +export class Info extends Schema.Class("Reference.Info")({ + name: Schema.String, + path: AbsolutePath, + description: Schema.String.pipe(Schema.optional), + hidden: Schema.Boolean.pipe(Schema.optional), + source: Source, +}) {} + +type Data = { + sources: Map +} + +type Editor = { + add(name: string, source: Source): void + remove(name: string): void + list(): readonly [string, Source][] +} + +export interface Interface { + readonly transform: State.Interface["transform"] + readonly replace: (sources: readonly (readonly [string, Source])[]) => Effect.Effect // kilocode_change + readonly list: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Reference") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const global = yield* Global.Service + const events = yield* EventV2.Service + const cache = yield* RepositoryCache.Service + const scope = yield* Scope.Scope + const materialized = new Map() + const state = State.create({ + initial: () => ({ sources: new Map() }), + editor: (draft) => ({ + add: (name, source) => draft.sources.set(name, castDraft(source)), + remove: (name) => draft.sources.delete(name), + list: () => Array.from(draft.sources.entries()) as [string, Source][], + }), + finalize: (editor) => + Effect.gen(function* () { + materialized.clear() + const seen = new Map() + for (const [name, source] of editor.list()) { + if (source.type === "local") { + materialized.set( + name, + new Info({ + name, + path: source.path, + description: source.description, + hidden: source.hidden, + source, + }), + ) + continue + } + const repository = Repository.parse(source.repository) + if (!repository || !Repository.isRemote(repository)) continue + if (source.branch) { + try { + Repository.validateBranch(source.branch) + } catch { + continue + } + } + const target = Repository.cachePath(global.repos, repository) + if (seen.has(target) && seen.get(target) !== source.branch) continue + seen.set(target, source.branch) + materialized.set( + name, + new Info({ + name, + path: AbsolutePath.make(target), + description: source.description, + hidden: source.hidden, + source, + }), + ) + yield* cache.ensure({ reference: repository, branch: source.branch, refresh: true }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to materialize reference", { + name, + repository: source.repository, + cause, + }), + ), + Effect.forkIn(scope), + ) + } + yield* events.publish(Event.Updated, {}) + }), + }) + + return Service.of({ + transform: state.transform, + // kilocode_change start - reconcile Kilo's effective config without a request-scoped transform slot. + replace: (sources) => + state.mutate((editor) => + Effect.sync(() => { + for (const [name] of editor.list()) editor.remove(name) + for (const [name, source] of sources) editor.add(name, source) + }), + ), + // kilocode_change end + list: Effect.fn("Reference.list")(function* () { + return Array.from(materialized.values()) + }), + }) + }), +) + +export const locationLayer = layer diff --git a/packages/core/src/reference/guidance.ts b/packages/core/src/reference/guidance.ts new file mode 100644 index 00000000000..f567264768a --- /dev/null +++ b/packages/core/src/reference/guidance.ts @@ -0,0 +1,69 @@ +export * as ReferenceGuidance from "./guidance" + +import { Context, Effect, Layer, Schema } from "effect" +import { PluginBoot } from "../plugin/boot" +import { Reference } from "../reference" +import { SystemContext } from "../system-context/index" + +const Summary = Schema.Struct({ + name: Schema.String, + path: Schema.String, + description: Schema.String.pipe(Schema.optional), +}) + +const render = (references: ReadonlyArray) => + [ + "Project references provide additional directories that can be accessed when relevant.", + "", + ...references.flatMap((reference) => [ + " ", + ` ${reference.name}`, + ` ${reference.path}`, + ...(reference.description === undefined ? [] : [` ${reference.description}`]), + " ", + ]), + "", + ].join("\n") + +export interface Interface { + readonly load: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/ReferenceGuidance") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const boot = yield* PluginBoot.Service + const references = yield* Reference.Service + + return Service.of({ + load: Effect.fn("ReferenceGuidance.load")(function* () { + yield* boot.wait() + const available = (yield* references.list()) + .filter((reference) => reference.description !== undefined) + .map((reference) => ({ + name: reference.name, + path: reference.path, + description: reference.description, + })) + .toSorted((a, b) => a.name.localeCompare(b.name)) + if (available.length === 0) return SystemContext.empty + return SystemContext.make({ + key: SystemContext.Key.make("core/reference-guidance"), + codec: Schema.toCodecJson(Schema.Array(Summary)), + load: Effect.succeed(available), + baseline: render, + update: (_previous, current) => + [ + "The available project references have changed. This list supersedes the previous reference list.", + render(current), + ].join("\n"), + removed: () => "Project reference guidance is no longer available. Do not use previously listed references.", + }) + }), + }) + }), +) + +export const locationLayer = layer diff --git a/packages/core/src/ripgrep.ts b/packages/core/src/ripgrep.ts index 5d40e688700..d1a58aea22c 100644 --- a/packages/core/src/ripgrep.ts +++ b/packages/core/src/ripgrep.ts @@ -2,20 +2,24 @@ export * as Ripgrep from "./ripgrep" import { Context, Effect, Fiber, Layer, Schema, Stream } from "effect" import { ChildProcess } from "effect/unstable/process" -import { Ripgrep as FileSystemRipgrep } from "./filesystem/ripgrep" +import path from "path" +import { Entry, Match } from "./filesystem/schema" +import { FSUtil } from "./fs-util" +import * as SpawnValidation from "./kilocode/spawn-validation" // kilocode_change import { AppProcess, collectStream, waitForAbort } from "./process" -import { NonNegativeInt, PositiveInt } from "./schema" +import { NonNegativeInt, PositiveInt, RelativePath } from "./schema" +import { RipgrepBinary } from "./ripgrep/binary" /** * Small core-owned ripgrep execution adapter. It deliberately exposes raw - * process-oriented rows, not model text or permission behavior. LocationSearch - * supplies read authority and bounded substrate results; future leaf tools own + * process-oriented rows, not model text or permission behavior. Search maps + * these rows into filesystem results; leaf tools own * presentation and permission prompts. */ const ERROR_BYTES = 8 * 1024 -export const MAX_RECORD_BYTES = 64 * 1024 -export const MAX_SUBMATCHES = 100 +const MAX_RECORD_BYTES = 64 * 1024 +const MAX_SUBMATCHES = 100 const RawMatch = Schema.Struct({ type: Schema.Literal("match"), @@ -34,7 +38,7 @@ const RawMatch = Schema.Struct({ }), }) -export type Match = (typeof RawMatch.Type)["data"] +type RawMatchData = (typeof RawMatch.Type)["data"] export class Error extends Schema.TaggedErrorClass()("Ripgrep.Error", { message: Schema.String, @@ -46,17 +50,24 @@ export class InvalidPatternError extends Schema.TaggedErrorClass { - readonly items: A[] - readonly truncated: boolean - readonly partial: boolean +export interface FindInput { + readonly cwd: string + readonly pattern: string + readonly limit: number + readonly hidden?: boolean + readonly follow?: boolean + readonly signal?: AbortSignal + readonly onEntry?: (entry: Entry) => Effect.Effect } -export interface FilesInput { +export interface GlobInput { readonly cwd: string readonly pattern: string readonly limit: number + readonly hidden?: boolean + readonly follow?: boolean readonly signal?: AbortSignal + readonly validate?: Effect.Effect // kilocode_change - bind approved searches at spawn } export interface GrepInput { @@ -66,13 +77,23 @@ export interface GrepInput { readonly include?: string readonly limit: number readonly signal?: AbortSignal + readonly validate?: Effect.Effect // kilocode_change - bind approved searches at spawn } export interface Interface { - readonly files: (input: FilesInput) => Effect.Effect, Error> - readonly grep: (input: GrepInput) => Effect.Effect, Error | InvalidPatternError> + readonly find: (input: FindInput) => Effect.Effect + readonly glob: (input: GlobInput) => Effect.Effect, Error> // kilocode_change + readonly grep: (input: GrepInput) => Effect.Effect, Error | InvalidPatternError> // kilocode_change } +// kilocode_change start - retain truncation state through model-facing tools +export interface SearchResult
{ + readonly items: readonly A[] + readonly truncated: boolean + readonly partial: boolean +} +// kilocode_change end + export class Service extends Context.Service()("@opencode/v2/Ripgrep") {} const failure = (message: string, cause?: unknown) => new Error({ message, cause }) @@ -84,7 +105,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const process = yield* AppProcess.Service - const binary = yield* FileSystemRipgrep.Service + const binary = yield* RipgrepBinary.Service const run = (input: { readonly cwd: string @@ -93,21 +114,36 @@ export const layer = Layer.effect( readonly signal?: AbortSignal readonly parse: (line: string) => Effect.Effect readonly pattern?: string + readonly onItem?: (item: A) => Effect.Effect + readonly validate?: Effect.Effect // kilocode_change - spawn-bound target validation }) => { const program = Effect.scoped( Effect.gen(function* () { + const filepath = yield* binary.filepath + // kilocode_change start - validate approved targets after all spawn preparation + const command = ChildProcess.make(filepath, input.args, { + cwd: input.cwd, + extendEnv: true, + stdin: "ignore", + }) const handle = yield* process.spawn( - ChildProcess.make(yield* binary.filepath, input.args, { cwd: input.cwd, extendEnv: true, stdin: "ignore" }), + input.validate ? SpawnValidation.attach(command, input.validate) : command, ) + // kilocode_change end const stderrFiber = yield* collectStream(handle.stderr, ERROR_BYTES).pipe( Effect.map((output) => output.buffer.toString("utf8")), Effect.forkScoped, ) + let observed = 0 const rows = yield* Stream.decodeText(handle.stdout).pipe( Stream.splitLines, Stream.filter((line) => line.length > 0), Stream.mapEffect(input.parse), Stream.filter((row): row is A => row !== undefined), + Stream.tap((row) => { + if (!input.onItem || observed++ >= input.limit) return Effect.void + return input.onItem(row) + }), Stream.take(input.limit + 1), Stream.runCollect, Effect.map((chunk) => [...chunk]), @@ -137,31 +173,86 @@ export const layer = Layer.effect( } return Service.of({ - files: (input) => + glob: (input) => run({ - ...input, + cwd: input.cwd, + limit: input.limit, + signal: input.signal, + validate: input.validate, // kilocode_change - preserve spawn-bound target validation args: [ "--no-config", "--files", - "--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure. + ...(input.hidden ? ["--hidden"] : []), + ...(input.follow ? ["--follow"] : []), `--glob=${input.pattern}`, - "--glob=!.*", - "--glob=!**/.*", + "--glob=!**/.git/**", + ".", + ], + parse: (line) => + Effect.succeed( + line + .replace(/^(?:\.[\\/])+/u, "") + .replace(/^[\\/]+/u, "") + .replaceAll("\\", "/"), + ), + }).pipe( + // kilocode_change start - retain spawn metadata after mapping paths + Effect.map((result) => ({ + ...result, + items: result.items.map((relative) => { + const absolute = path.resolve(input.cwd, relative) + return new Entry({ + path: RelativePath.make(relative), + type: "file", + mime: FSUtil.mimeType(absolute), + }) + }), + })), + // kilocode_change end + Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))), + ), + find: (input) => + run({ + cwd: input.cwd, + limit: input.limit, + signal: input.signal, + args: [ + "--no-config", + "--files", + ...(input.hidden ? ["--hidden"] : []), + ...(input.follow ? ["--follow"] : []), + ...(input.pattern === "*" ? [] : [`--glob=${input.pattern}`]), + "--glob=!**/.git/**", ".", ], - parse: (line) => Effect.succeed(line.replace(/^\.\//, "")), - }).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))), + parse: (line) => { + const relative = line + .replace(/^(?:\.[\\/])+/u, "") + .replace(/^[\\/]+/u, "") + .replaceAll("\\", "/") + return Effect.succeed( + new Entry({ + path: RelativePath.make(relative), + type: "file", + mime: FSUtil.mimeType(path.resolve(input.cwd, relative)), + }), + ) + }, + onItem: input.onEntry, + }).pipe( + Effect.map((result) => result.items), + Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause))), + ), grep: (input) => - run({ + run({ ...input, args: [ "--no-config", "--json", - "--glob=!.git/*", // TODO: Review .git exclusion policy before leaf tool exposure. + "--hidden", "--no-messages", ...(input.include ? [`--glob=${input.include}`] : []), - "--glob=!.*", - "--glob=!**/.*", + "--glob=!**/.git/**", "--", input.pattern, input.file ?? ".", @@ -180,13 +271,44 @@ export const layer = Layer.effect( return Schema.decodeUnknownEffect(RawMatch)(json).pipe( Effect.map((match) => ({ ...match.data, + path: { text: match.data.path.text.replace(/^\.[\\/]/, "") }, submatches: match.data.submatches.slice(0, MAX_SUBMATCHES), })), Effect.mapError((cause) => failure("Invalid ripgrep match output", cause)), ) }), ), - }), + }).pipe( + // kilocode_change start - retain spawn metadata after mapping matches + Effect.map((result) => ({ + ...result, + items: result.items.map((match) => { + const relative = match.path.text + .replace(/^(?:\.[\\/])+/u, "") + .replace(/^[\\/]+/u, "") + .replaceAll("\\", "/") + const absolute = path.resolve(input.cwd, relative) + return new Match({ + entry: new Entry({ + path: RelativePath.make(relative), + type: "file", + mime: FSUtil.mimeType(absolute), + }), + line: match.line_number, + offset: match.absolute_offset, + text: match.lines.text.length > 2_000 ? match.lines.text.slice(0, 2_000) + "..." : match.lines.text, + submatches: match.submatches.map((submatch) => ({ + text: submatch.match.text, + start: submatch.start, + end: submatch.end, + })), + }) + }), + })), + // kilocode_change end + ), }) }), -).pipe(Layer.provide(FileSystemRipgrep.defaultLayer)) +) + +export const defaultLayer = layer.pipe(Layer.provide(Layer.merge(RipgrepBinary.defaultLayer, AppProcess.defaultLayer))) diff --git a/packages/core/src/ripgrep/binary.ts b/packages/core/src/ripgrep/binary.ts new file mode 100644 index 00000000000..a7cf7ae615c --- /dev/null +++ b/packages/core/src/ripgrep/binary.ts @@ -0,0 +1,131 @@ +import path from "path" +import { Context, Effect, Layer, Stream } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { ChildProcess } from "effect/unstable/process" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" +import { CrossSpawnSpawner } from "../cross-spawn-spawner" +import { FSUtil } from "../fs-util" +import { Global } from "../global" +import { which } from "../util/which" + +export namespace RipgrepBinary { + const VERSION = "15.1.0" + const PLATFORM = { + "arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" }, + "arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" }, + "x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" }, + "x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" }, + "arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" }, + "ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" }, + "x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" }, + } as const + + interface Interface { + readonly filepath: Effect.Effect + } + + export class Service extends Context.Service()("@opencode/RipgrepBinary") {} + + export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient) + const spawner = yield* ChildProcessSpawner + + const run = Effect.fnUntraced(function* (command: string, args: string[]) { + const handle = yield* spawner.spawn(ChildProcess.make(command, args, { extendEnv: true, stdin: "ignore" })) + const [stdout, stderr, code] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ) + return { stdout, stderr, code } + }, Effect.scoped) + + const extract = Effect.fnUntraced(function* ( + archive: string, + config: (typeof PLATFORM)[keyof typeof PLATFORM], + target: string, + ) { + const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" }) + + if (config.extension === "zip") { + const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe" + const result = yield* run(shell, [ + "-NoProfile", + "-NonInteractive", + "-Command", + `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`, + ]) + if (result.code !== 0) + throw new Error( + result.stderr.trim() || result.stdout.trim() || `ripgrep extraction failed with code ${result.code}`, + ) + } + + if (config.extension === "tar.gz") { + const result = yield* run("tar", ["-xzf", archive, "-C", dir]) + if (result.code !== 0) + throw new Error( + result.stderr.trim() || result.stdout.trim() || `ripgrep extraction failed with code ${result.code}`, + ) + } + + const extracted = path.join( + dir, + `ripgrep-${VERSION}-${config.platform}`, + process.platform === "win32" ? "rg.exe" : "rg", + ) + if (!(yield* fs.isFile(extracted))) throw new Error(`ripgrep archive did not contain executable: ${extracted}`) + + yield* fs.copyFile(extracted, target) + if (process.platform !== "win32") yield* fs.chmod(target, 0o755) + }, Effect.scoped) + + return Service.of({ + filepath: yield* Effect.cached( + Effect.gen(function* () { + // kilocode_change - Git for Windows may expose an incompatible MSYS rg.exe + const system = yield* Effect.sync(() => (process.platform === "win32" ? undefined : which("rg"))) + if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system + + const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`) + if (yield* fs.isFile(target).pipe(Effect.orDie)) return target + + const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM + const config = PLATFORM[platformKey] + if (!config) throw new Error(`unsupported platform for ripgrep: ${platformKey}`) + + const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}` + const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}` + const archive = path.join(Global.Path.bin, filename) + + yield* Effect.logInfo("downloading ripgrep", { url }) + yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie) + const bytes = yield* HttpClientRequest.get(url).pipe( + http.execute, + Effect.flatMap((response) => response.arrayBuffer), + Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))), + ) + if (bytes.byteLength === 0) throw new Error(`failed to download ripgrep from ${url}`) + + yield* fs.writeWithDirs(archive, new Uint8Array(bytes)) + yield* extract(archive, config, target) + yield* fs.remove(archive, { force: true }).pipe(Effect.ignore) + return target + }), + ), + }) + }), + ) + + export const defaultLayer = layer.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + ) +} diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 42727f095dc..e629108994d 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -1,7 +1,7 @@ export * as SessionV2 from "./session" export * from "./session/schema" -import { Cause, Effect, Layer, Schema, Context, Stream } from "effect" +import { Cause, DateTime, Effect, Layer, Schema, Context, Stream } from "effect" import { and, asc, desc, eq, gt, isNotNull, like, lt, or, type SQL } from "drizzle-orm" // kilocode_change import { ProjectV2 } from "./project" import { WorkspaceV2 } from "./workspace" @@ -25,9 +25,11 @@ import { fromRow } from "./session/info" import { SessionRunner } from "./session/runner/index" import { SessionStore } from "./session/store" import { SessionExecution } from "./session/execution" +import { logFailure } from "./session/logging" import { MessageDecodeError } from "./session/error" import { SessionEvent } from "./session/event" import { SessionInput } from "./session/input" +import { normalize } from "./kilocode/session-message" // kilocode_change // get project -> project.locations // @@ -88,7 +90,7 @@ export class NotFoundError extends Schema.TaggedErrorClass()("Ses export class OperationUnavailableError extends Schema.TaggedErrorClass()( "Session.OperationUnavailableError", { - operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "switchModel", "compact", "wait"]), + operation: Schema.Literals(["move", "shell", "skill", "switchAgent", "compact", "wait"]), }, ) {} @@ -132,7 +134,7 @@ export interface Interface { readonly switchModel: (input: { sessionID: SessionSchema.ID model: ModelV2.Ref - }) => Effect.Effect + }) => Effect.Effect readonly prompt: (input: { id?: SessionMessage.ID sessionID: SessionSchema.ID @@ -155,6 +157,7 @@ export interface Interface { readonly compact: (input: CompactInput) => Effect.Effect readonly wait: (id: SessionSchema.ID) => Effect.Effect readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect + readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Session") {} @@ -171,15 +174,12 @@ export const layer = Layer.effect( const isDurableSessionEvent = Schema.is(SessionEvent.Durable) const scope = yield* Effect.scope - const enqueueWake = (sessionID: SessionSchema.ID) => - execution.wake(sessionID).pipe( + const enqueueWake = (admitted: SessionInput.Admitted) => + execution.wake(admitted.sessionID, admitted.admittedSeq).pipe( Effect.tapCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.void - : Effect.logError("Failed to wake Session").pipe( - Effect.annotateLogs("sessionID", sessionID), - Effect.annotateLogs("cause", cause), - ), + : logFailure("Failed to wake Session", admitted.sessionID, cause), ), Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), @@ -187,7 +187,8 @@ export const layer = Layer.effect( ) const decode = (row: typeof SessionMessageTable.$inferSelect) => - decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe( + decodeMessage(normalize({ ...row.data, id: row.id, type: row.type })).pipe( + // kilocode_change - normalize released tool content on paginated reads Effect.mapError( () => new MessageDecodeError({ @@ -356,7 +357,7 @@ export const layer = Layer.effect( Effect.gen(function* () { yield* result.get(input.sessionID) const returnPrompt = Effect.fnUntraced(function* (admitted: SessionInput.Admitted) { - if (input.resume !== false) yield* enqueueWake(input.sessionID) + if (input.resume !== false) yield* enqueueWake(admitted) return admitted }, Effect.uninterruptible) const messageID = input.id ?? SessionMessage.ID.create() @@ -389,8 +390,14 @@ export const layer = Layer.effect( switchAgent: Effect.fn("V2Session.switchAgent")(function* () { return yield* new OperationUnavailableError({ operation: "switchAgent" }) }), - switchModel: Effect.fn("V2Session.switchModel")(function* () { - return yield* new OperationUnavailableError({ operation: "switchModel" }) + switchModel: Effect.fn("V2Session.switchModel")(function* (input) { + yield* result.get(input.sessionID) + yield* events.publish(SessionEvent.ModelSwitched, { + sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + model: input.model, + }) }), compact: Effect.fn("V2Session.compact")(function* (input) { yield* result.get(input.sessionID) @@ -404,26 +411,34 @@ export const layer = Layer.effect( yield* result.get(sessionID) yield* execution.resume(sessionID) }), + interrupt: Effect.fn("V2Session.interrupt")((sessionID) => + Effect.uninterruptible( + Effect.gen(function* () { + const session = yield* store.get(sessionID) + if (!session) return yield* execution.interrupt(sessionID) + // kilocode_change start - keep interrupt operational while preserving released durable event compatibility. + const seq = yield* SessionInput.latestSeq(db, sessionID) + yield* events.publish(SessionEvent.InterruptRequested, { + sessionID, + timestamp: yield* DateTime.now, + }) + yield* execution.interrupt(sessionID, seq) + // kilocode_change end + }), + ), + ), }) return result }), ) -const DefaultDatabase = Database.defaultLayer -const DefaultEvents = EventV2.layer.pipe(Layer.provide(DefaultDatabase)) -const DefaultProjector = SessionProjector.layer.pipe(Layer.provide(DefaultEvents), Layer.provide(DefaultDatabase)) -const DefaultStore = SessionStore.layer.pipe(Layer.provide(DefaultDatabase)) export const defaultLayer = layer.pipe( - Layer.provide( - Layer.mergeAll( - DefaultDatabase, - DefaultEvents, - DefaultProjector, - DefaultStore, - SessionExecution.noopLayer, - ProjectV2.defaultLayer, - ), - ), + Layer.provide(SessionExecution.noopLayer), + Layer.provide(SessionStore.defaultLayer), + Layer.provide(SessionProjector.defaultLayer), + Layer.provide(EventV2.defaultLayer), + Layer.provide(Database.defaultLayer), + Layer.provide(ProjectV2.defaultLayer), Layer.orDie, ) diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts new file mode 100644 index 00000000000..1c0cc62fea8 --- /dev/null +++ b/packages/core/src/session/compaction.ts @@ -0,0 +1,247 @@ +export * as SessionCompaction from "./compaction" + +import { LLM, LLMError, LLMEvent, Message, type LLMRequest, type Model } from "@opencode-ai/llm" +import { DateTime, Effect, Stream } from "effect" +import type { Config } from "../config" +import type { EventV2 } from "../event" +import { SessionEvent } from "./event" +import { SessionMessage } from "./message" +import { SessionSchema } from "./schema" +import { Token } from "../util/token" + +const DEFAULT_BUFFER = 20_000 +const DEFAULT_KEEP_TOKENS = 8_000 +const TOOL_OUTPUT_MAX_CHARS = 2_000 +const SUMMARY_OUTPUT_TOKENS = 4_096 +const SUMMARY_TEMPLATE = `Output exactly the Markdown structure shown inside