mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge branch 'main' into analyze-jetbrains-vscode-setting-parity
This commit is contained in:
+2
-4
@@ -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
|
||||
|
||||
+41
-16
@@ -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
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
v1.16.2
|
||||
v1.17.4
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+26
@@ -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
|
||||
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-SiTkoVUEnR1ugnP39xrw+khD3+TN+3wQ733gw8go76Y=",
|
||||
"aarch64-linux": "sha256-9czWyKgE0xqJiUl6Aprampob49cHe8awMQOmxpX1FB8=",
|
||||
"aarch64-darwin": "sha256-yRs6Mincbja/s2EJ7Ln+WjO/f/uwg6xD4d/SkmqM+OY=",
|
||||
"x86_64-darwin": "sha256-4DbNm21/+pNGVLfgdNLtotqqcwzr70sbYWCqxftcbAo="
|
||||
"x86_64-linux": "sha256-611oft4OE2TByjNpiGBm8ovkqkrTPZh7gWW90CZ2B6U=",
|
||||
"aarch64-linux": "sha256-NNFZV21HTDNLwdjdm9XYqS5EqdyG9+a6cxJn8odwKaQ=",
|
||||
"aarch64-darwin": "sha256-jYnaqpcU+wyncxM4o5vsXchBBSLMeo8FOwS/fC7G7I8=",
|
||||
"x86_64-darwin": "sha256-vGXN1BUdFs8w/U7Y3JrMkfmJosyNdBF5orJXP0LWuMM="
|
||||
}
|
||||
}
|
||||
|
||||
+7
-6
@@ -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",
|
||||
|
||||
+45
-138
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
File diff suppressed because it is too large
Load Diff
+109
-2
@@ -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": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -63,7 +63,7 @@ export type Editor = {
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly update: (update: State.Transform<Editor>) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
readonly default: () => Effect.Effect<Info | undefined>
|
||||
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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<OAuthCredential>("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<ApiKeyCredential>("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<typeof Credential>
|
||||
|
||||
export class Info extends Schema.Class<Info>("Auth.Info")({
|
||||
id: ID,
|
||||
serviceID: ServiceID,
|
||||
description: Schema.String,
|
||||
credential: Credential,
|
||||
}) {}
|
||||
|
||||
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("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<string, Info>
|
||||
active: Record<string, ID>
|
||||
}
|
||||
|
||||
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
|
||||
|
||||
function migrate(old: Record<string, unknown>): Writable {
|
||||
const accounts: Record<string, Info> = {}
|
||||
const active: Record<string, ID> = {}
|
||||
for (const [serviceID, value] of Object.entries(old)) {
|
||||
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
|
||||
const parsed = (decoded as Record<string, Credential>)[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<Info | undefined, Error>
|
||||
readonly all: () => Effect.Effect<Info[], Error>
|
||||
readonly create: (input: {
|
||||
serviceID: ServiceID
|
||||
credential: Credential
|
||||
description?: string
|
||||
}) => Effect.Effect<Info | undefined, Error>
|
||||
readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
|
||||
readonly remove: (id: ID) => Effect.Effect<void, Error>
|
||||
readonly activate: (id: ID) => Effect.Effect<void, Error>
|
||||
readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
|
||||
readonly activeAll: () => Effect.Effect<Map<ServiceID, Info>, Error>
|
||||
readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<string, unknown>) {
|
||||
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<Writable, Error> = 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<string, unknown>)
|
||||
}
|
||||
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<string, unknown>)
|
||||
|
||||
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<string, unknown>)
|
||||
}
|
||||
|
||||
// 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<ServiceID, Info>()
|
||||
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),
|
||||
)
|
||||
@@ -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<ConnectorSchema.ID, Credential.Info>) => {
|
||||
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<ModelV2.Info>()
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ export type Editor = {
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Directory>("Config.Directory")({
|
||||
|
||||
export type Entry = Document | Directory
|
||||
|
||||
export function latest<K extends keyof Info>(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<Entry[]>
|
||||
@@ -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.
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Schema } from "effect"
|
||||
import { NonNegativeInt } from "../schema"
|
||||
|
||||
export class Keep extends Schema.Class<Keep>("ConfigV2.Compaction.Keep")({
|
||||
turns: NonNegativeInt.pipe(Schema.optional),
|
||||
tokens: NonNegativeInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ import { PositiveInt } from "../schema"
|
||||
export class Local extends Schema.Class<Local>("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),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<string, Reference.Source>()
|
||||
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)
|
||||
}
|
||||
@@ -5,10 +5,14 @@ import { Schema } from "effect"
|
||||
export class Git extends Schema.Class<Git>("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<Local>("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<string, NormalizedEntry>
|
||||
|
||||
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)]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<TextPrompt>("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<SelectPrompt>("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<OAuthMethod>("Connector.OAuthMethod")({
|
||||
id: MethodID,
|
||||
type: Schema.Literal("oauth"),
|
||||
label: Schema.String,
|
||||
prompts: Schema.optional(Schema.Array(Prompt)),
|
||||
}) {}
|
||||
|
||||
export class KeyMethod extends Schema.Class<KeyMethod>("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<Info>("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<Credential.Value, unknown>
|
||||
}
|
||||
| {
|
||||
readonly mode: "code"
|
||||
readonly callback: (code: string) => Effect.Effect<Credential.Value, unknown>
|
||||
}
|
||||
)
|
||||
|
||||
export interface OAuthImplementation {
|
||||
readonly connectorID: ID
|
||||
readonly method: OAuthMethod
|
||||
readonly authorize: (inputs: Inputs) => Effect.Effect<OAuthAuthorization, unknown, Scope.Scope>
|
||||
readonly refresh?: (credential: Credential.OAuth) => Effect.Effect<Credential.OAuth, unknown>
|
||||
}
|
||||
|
||||
export interface KeyImplementation {
|
||||
readonly connectorID: ID
|
||||
readonly method: KeyMethod
|
||||
readonly authorize: (key: string, inputs: Inputs) => Effect.Effect<Credential.Key, unknown>
|
||||
}
|
||||
|
||||
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<Attempt>("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<CodeRequiredError>()("Connector.CodeRequired", {
|
||||
attemptID: AttemptID,
|
||||
}) {}
|
||||
|
||||
export class AuthorizationError extends Schema.TaggedErrorClass<AuthorizationError>()("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<MethodID, Implementation>
|
||||
}
|
||||
|
||||
type Data = {
|
||||
connectors: Map<ID, Entry>
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
list: () => readonly Info[]
|
||||
get: (id: ID) => Info | undefined
|
||||
update: (id: ID, update: (connector: Draft<Omit<Info, "methods">>) => 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<Data, Editor>["transform"]
|
||||
/** Registers and immediately applies a scoped connector registry update. */
|
||||
readonly update: State.Interface<Data, Editor>["update"]
|
||||
/** Returns one connector with its serializable login methods. */
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Returns all connectors with their serializable login methods. */
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
/** Refreshes an OAuth credential with its originating method. */
|
||||
readonly refresh: (credentialID: Credential.ID) => Effect.Effect<void, AuthorizationError>
|
||||
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<void, AuthorizationError>
|
||||
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<Attempt, AuthorizationError>
|
||||
/** Returns the current state of an OAuth attempt. */
|
||||
readonly status: (attemptID: AttemptID) => Effect.Effect<AttemptStatus>
|
||||
/** 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<void, CodeRequiredError | AuthorizationError>
|
||||
/** Cancels an attempt and releases its resources. */
|
||||
readonly cancel: (attemptID: AttemptID) => Effect.Effect<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<AttemptID, AttemptEntry>())
|
||||
const refreshLocks = KeyedMutex.makeUnsafe<Credential.ID>()
|
||||
const state = State.create<Data, Editor>({
|
||||
initial: () => ({ connectors: new Map<ID, Entry>() }),
|
||||
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<MethodID, Implementation>(),
|
||||
})
|
||||
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 = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
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<unknown>) => {
|
||||
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<Credential.Value, AuthorizationError>,
|
||||
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)
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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<OAuth>("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<Key>("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<typeof Value>
|
||||
|
||||
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<Info>("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<Info | undefined>
|
||||
readonly all: () => Effect.Effect<Info[]>
|
||||
readonly create: (input: {
|
||||
connectorID: ConnectorSchema.ID
|
||||
methodID: ConnectorSchema.MethodID
|
||||
value: Value
|
||||
label?: string
|
||||
}) => Effect.Effect<Info>
|
||||
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
|
||||
readonly remove: (id: ID) => Effect.Effect<void>
|
||||
readonly activate: (id: ID) => Effect.Effect<void>
|
||||
readonly active: (connectorID: ConnectorSchema.ID) => Effect.Effect<Info | undefined>
|
||||
readonly activeAll: () => Effect.Effect<Map<ConnectorSchema.ID, Info>>
|
||||
readonly forConnector: (connectorID: ConnectorSchema.ID) => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<ConnectorSchema.ID, Info>())
|
||||
: 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<ConnectorSchema.ID, Info>())
|
||||
}
|
||||
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<ConnectorSchema.ID, Info>()),
|
||||
),
|
||||
),
|
||||
)
|
||||
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<string, unknown> =
|
||||
typeof raw === "object" && raw !== null && !Array.isArray(raw)
|
||||
? { ...(raw as Record<string, unknown>) }
|
||||
: {}
|
||||
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),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -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<Credential.ID>().primaryKey(),
|
||||
connector_id: text().$type<ConnectorSchema.ID>().notNull(),
|
||||
method_id: text().$type<ConnectorSchema.MethodID>().notNull(),
|
||||
label: text().notNull(),
|
||||
value: text({ mode: "json" }).$type<Credential.Value>().notNull(),
|
||||
active: integer({ mode: "boolean" }).notNull().default(false),
|
||||
...Timestamps,
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("credential_connector_active_idx")
|
||||
.on(table.connector_id)
|
||||
.where(sql`${table.active} = 1`),
|
||||
],
|
||||
)
|
||||
@@ -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<ChildProcessSpawner, never, FileSystem.FileSyste
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
||||
export const node = LayerNode.make(layer, [filesystem, path])
|
||||
|
||||
export * as CrossSpawnSpawner from "./cross-spawn-spawner"
|
||||
|
||||
@@ -9,6 +9,7 @@ import { isAbsolute, join } from "path"
|
||||
import { existsSync } from "fs" // kilocode_change
|
||||
import { DatabaseMigration } from "./migration"
|
||||
import { InstallationChannel } from "../installation/version"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
|
||||
const makeDatabase = EffectDrizzleSqlite.makeWithDefaults()
|
||||
type DatabaseShape = Effect.Success<typeof makeDatabase>
|
||||
@@ -65,3 +66,5 @@ export const defaultLayer = Layer.unwrap(
|
||||
return layerFromPath(path())
|
||||
}),
|
||||
).pipe(Layer.provide(Global.defaultLayer))
|
||||
|
||||
export const node = LayerNode.make(layerFromPath(path()), [])
|
||||
|
||||
+1
@@ -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[]
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Layer } from "effect"
|
||||
|
||||
type RuntimeLayer = Layer.Layer<never, unknown, unknown>
|
||||
type AnyNode = Node<unknown, unknown>
|
||||
type NodeList = readonly [] | readonly [AnyNode, ...AnyNode[]]
|
||||
type Output<Item> = [Item] extends [never] ? never : Item extends Node<infer A, unknown> ? A : never
|
||||
type Error<Item> = [Item] extends [never] ? never : Item extends Node<unknown, infer E> ? E : never
|
||||
type Missing<Required, Dependencies extends NodeList> = Exclude<Required, Output<Dependencies[number]>>
|
||||
type CheckDependencies<Implementation extends Layer.Any, Dependencies extends NodeList> = [
|
||||
Missing<Layer.Services<Implementation>, Dependencies>,
|
||||
] extends [never]
|
||||
? unknown
|
||||
: { readonly "Missing dependencies": Missing<Layer.Services<Implementation>, Dependencies> }
|
||||
declare const $OutputType: unique symbol
|
||||
declare const $ErrorType: unique symbol
|
||||
|
||||
export type Node<A, E = never> = {
|
||||
readonly kind: "layer" | "group"
|
||||
readonly implementation?: Layer.Any
|
||||
readonly dependencies: readonly AnyNode[]
|
||||
readonly [$OutputType]?: () => A
|
||||
readonly [$ErrorType]?: () => E
|
||||
}
|
||||
|
||||
export function make<const Implementation extends Layer.Any, const Items extends NodeList>(
|
||||
implementation: Implementation,
|
||||
dependencies: Items & CheckDependencies<Implementation, NoInfer<Items>>,
|
||||
): Node<Layer.Success<Implementation>, Layer.Error<Implementation> | Error<Items[number]>> {
|
||||
return { kind: "layer", implementation: implementation as Layer.Any, dependencies }
|
||||
}
|
||||
|
||||
export function group<const Items extends NodeList>(
|
||||
dependencies: Items,
|
||||
): Node<Output<Items[number]>, Error<Items[number]>> {
|
||||
return { kind: "group", dependencies }
|
||||
}
|
||||
|
||||
export type Replacement<A = unknown> = {
|
||||
readonly source: Node<A, unknown>
|
||||
readonly replacement: Node<A, unknown>
|
||||
}
|
||||
|
||||
type CheckReplacementErrors<SourceError, ReplacementError> = [Exclude<ReplacementError, SourceError>] extends [never]
|
||||
? unknown
|
||||
: { readonly "New replacement errors": Exclude<ReplacementError, SourceError> }
|
||||
|
||||
export function replaceWithNode<A, E, E2>(
|
||||
source: Node<A, E>,
|
||||
replacement: Node<NoInfer<A>, E2> & CheckReplacementErrors<E, NoInfer<E2>>,
|
||||
): Replacement<A> {
|
||||
return { source, replacement }
|
||||
}
|
||||
|
||||
export function replace<A, E, E2>(
|
||||
source: Node<A, E>,
|
||||
replacement: Layer.Layer<NoInfer<A>, E2, never> & CheckReplacementErrors<E, NoInfer<E2>>,
|
||||
): Replacement<A> {
|
||||
return { source, replacement: make(replacement as Layer.Layer<A, E2>, []) }
|
||||
}
|
||||
|
||||
export function buildLayer<A, E>(node: Node<A, E>, options?: { readonly replacements?: readonly Replacement[] }) {
|
||||
const replacements = new Map(options?.replacements?.map((item) => [item.source, item.replacement]))
|
||||
const cache = new Map<AnyNode, RuntimeLayer>()
|
||||
const visiting = new Set<AnyNode>()
|
||||
const stack: AnyNode[] = []
|
||||
const ids = new Map<AnyNode, number>()
|
||||
|
||||
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<A, E, never>
|
||||
}
|
||||
|
||||
export * as LayerNode from "./layer-node"
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Cause, Effect, Logger, References } from "effect"
|
||||
import * as Log from "../util/log"
|
||||
|
||||
type Fields = Record<string, unknown>
|
||||
|
||||
const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key)
|
||||
|
||||
export interface Handle {
|
||||
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
|
||||
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
|
||||
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
|
||||
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
|
||||
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<void>, 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 }),
|
||||
})
|
||||
@@ -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<string, string>,
|
||||
)
|
||||
: undefined
|
||||
|
||||
export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
|
||||
const processMetadata = ensureProcessMetadata("main")
|
||||
const attributes: Record<string, string> = (() => {
|
||||
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 }
|
||||
@@ -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<I, S, E>(service: Context.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
||||
|
||||
@@ -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<Type extends string = string, DataSchema extends Schema.T
|
||||
readonly sync?: {
|
||||
readonly version: number
|
||||
readonly aggregate: string
|
||||
readonly codec?: Schema.Codec<unknown, unknown, never, never> // kilocode_change - storage-only compatibility decoder
|
||||
}
|
||||
readonly data: DataSchema
|
||||
}
|
||||
@@ -90,13 +92,16 @@ type SyncDefinition = Definition & {
|
||||
const syncRegistry = new Map<string, SyncDefinition>()
|
||||
|
||||
// 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<unknown, unknown, never, never>
|
||||
// kilocode_change - keep persistence compatibility codecs out of public event schemas
|
||||
const syncCodec = (definition: Definition) =>
|
||||
definition.sync?.codec ?? (definition.data as Schema.Codec<unknown, unknown, never, never>)
|
||||
|
||||
export function define<const Type extends string, Fields extends Schema.Struct.Fields>(input: {
|
||||
readonly type: Type
|
||||
readonly sync?: {
|
||||
readonly version: number
|
||||
readonly aggregate: string
|
||||
readonly codec?: Schema.Codec<unknown, unknown, never, never> // kilocode_change
|
||||
}
|
||||
readonly schema: Fields
|
||||
}): Schema.Schema<Payload<Definition<Type, Schema.Struct<Fields>>>> & Definition<Type, Schema.Struct<Fields>> {
|
||||
@@ -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))
|
||||
|
||||
@@ -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<StaleContentError>()("FileMutation.StaleContentError", {
|
||||
@@ -34,143 +38,131 @@ export class TargetExistsError extends Schema.TaggedErrorClass<TargetExistsError
|
||||
|
||||
export interface WriteResult {
|
||||
readonly operation: "write"
|
||||
/** Canonical target actually passed to the filesystem mutation. */
|
||||
readonly target: string
|
||||
/** Permission resource captured during planning. */
|
||||
readonly resource: string
|
||||
readonly existed: boolean
|
||||
}
|
||||
|
||||
export interface RemoveResult {
|
||||
readonly operation: "remove"
|
||||
/** Canonical target actually passed to the filesystem mutation. */
|
||||
readonly target: string
|
||||
/** Permission resource captured during planning. */
|
||||
readonly resource: string
|
||||
readonly existed: boolean
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/** Create only while the planned target remains absent. */
|
||||
readonly create: (
|
||||
input: WriteInput,
|
||||
) => Effect.Effect<WriteResult, TargetExistsError | LocationMutation.RevalidationError | FSUtil.Error>
|
||||
/** Write after immediately revalidating the planned target. */
|
||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
|
||||
/** Create without replacing an existing target. */
|
||||
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | FSUtil.Error>
|
||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||
/** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
|
||||
readonly writeTextPreservingBom: (
|
||||
input: TextWriteInput,
|
||||
) => Effect.Effect<WriteResult, LocationMutation.RevalidationError | FSUtil.Error>
|
||||
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
|
||||
/** Commit only if an existing target still has the expected bytes. */
|
||||
readonly writeIfUnchanged: (
|
||||
input: ConditionalWriteInput,
|
||||
) => Effect.Effect<WriteResult, StaleContentError | LocationMutation.RevalidationError | FSUtil.Error>
|
||||
/** Remove after immediately revalidating the planned target. */
|
||||
readonly remove: (
|
||||
input: RemoveInput,
|
||||
) => Effect.Effect<RemoveResult, LocationMutation.RevalidationError | FSUtil.Error>
|
||||
) => Effect.Effect<WriteResult, StaleContentError | FSUtil.Error>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<RemoveResult, FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<string>()
|
||||
const withTargetLock =
|
||||
(target: string) =>
|
||||
(target: Target) =>
|
||||
<A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
locks.withLock(target)(Effect.uninterruptible(effect))
|
||||
locks.withLock(target.canonical)(Effect.uninterruptible(effect))
|
||||
|
||||
const withValidatedTarget =
|
||||
(plan: LocationMutation.Plan) =>
|
||||
<A, E, R>(commit: (target: LocationMutation.Target) => Effect.Effect<A, E, R>) =>
|
||||
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)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
+93
-509
@@ -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<TextContent>("FileSystem.TextContent")({
|
||||
type: Schema.Literal("text"),
|
||||
export const Content = Schema.Struct({
|
||||
uri: Schema.String,
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
content: Schema.String,
|
||||
encoding: Schema.Literals(["utf8", "base64"]),
|
||||
mime: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class BinaryContent extends Schema.Class<BinaryContent>("FileSystem.BinaryContent")({
|
||||
type: Schema.Literal("binary"),
|
||||
content: Schema.String,
|
||||
encoding: Schema.Literal("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<TextPage>("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<ReadTarget>("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<ListTarget>("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<RootTarget>("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<Entry>("FileSystem.Entry")({
|
||||
path: RelativePath,
|
||||
uri: Schema.String,
|
||||
type: Schema.Literals(["file", "directory"]),
|
||||
mime: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class ListPage extends Schema.Class<ListPage>("FileSystem.ListPage")({
|
||||
entries: Schema.Array(Entry),
|
||||
truncated: Schema.Boolean,
|
||||
next: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const FindInput = Schema.Struct({
|
||||
export class FindInput extends Schema.Class<FindInput>("FileSystem.FindInput")({
|
||||
query: Schema.String,
|
||||
type: Schema.Literals(["file", "directory"]).pipe(Schema.optional),
|
||||
limit: PositiveInt.pipe(Schema.optional),
|
||||
})
|
||||
export type FindInput = typeof FindInput.Type
|
||||
}) {}
|
||||
|
||||
export const GrepInput = Schema.Struct({
|
||||
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 class GlobInput extends Schema.Class<GlobInput>("FileSystem.GlobInput")({
|
||||
pattern: Schema.String,
|
||||
path: RelativePath.pipe(Schema.optional),
|
||||
limit: PositiveInt.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class GrepInput extends Schema.Class<GrepInput>("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<GrepMatch>("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<Content>
|
||||
readonly resolveReadPath: (input: ReadInput) => Effect.Effect<ReadPathTarget>
|
||||
readonly resolveRead: (input: ReadInput) => Effect.Effect<ReadTarget>
|
||||
readonly readResolved: (target: ReadTarget, maximumBytes?: number) => Effect.Effect<Content>
|
||||
readonly readTextPageResolved: (target: ReadTarget, page?: TextPageInput) => Effect.Effect<TextPage>
|
||||
readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Entry[]>
|
||||
/** Select a contained canonical read root without asserting leaf policy. */
|
||||
readonly resolveRoot: (input?: ListInput) => Effect.Effect<RootTarget>
|
||||
readonly revalidateRoot: (target: RootTarget) => Effect.Effect<RootTarget>
|
||||
readonly resolveList: (input?: ListInput) => Effect.Effect<ListTarget>
|
||||
readonly listResolved: (target: ListTarget) => Effect.Effect<Entry[]>
|
||||
readonly listPage: (input?: ListPageInput) => Effect.Effect<ListPage>
|
||||
readonly listPageResolved: (
|
||||
target: ListTarget,
|
||||
page?: Pick<ListPageInput, "offset" | "limit">,
|
||||
) => Effect.Effect<ListPage>
|
||||
readonly find: (input: FindInput) => Effect.Effect<Entry[]>
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<GrepMatch[]>
|
||||
readonly isIgnored: (path: RelativePath, type: "file" | "directory") => boolean
|
||||
readonly glob: (input: GlobInput) => Effect.Effect<readonly Entry[]>
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<readonly Match[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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 }
|
||||
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 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),
|
||||
})
|
||||
})
|
||||
|
||||
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<string>()
|
||||
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 })
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
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<ListPageInput, "offset" | "limit"> = {},
|
||||
) {
|
||||
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<Candidate | undefined> => {
|
||||
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({
|
||||
find: search.find,
|
||||
glob: search.glob,
|
||||
grep: search.grep,
|
||||
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)),
|
||||
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 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),
|
||||
}
|
||||
}),
|
||||
)
|
||||
// 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
|
||||
|
||||
@@ -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<T> = { 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<Result<boolean>>
|
||||
refreshGitStatus(): Result<number>
|
||||
fileSearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<Search>
|
||||
glob(
|
||||
pattern: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<Search>
|
||||
directorySearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<DirSearch>
|
||||
mixedSearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<MixedSearch>
|
||||
grep(
|
||||
query: string,
|
||||
opts?: {
|
||||
mode?: "plain" | "regex" | "fuzzy"
|
||||
maxMatchesPerFile?: number
|
||||
timeBudgetMs?: number
|
||||
beforeContext?: number
|
||||
afterContext?: number
|
||||
cursor?: Cursor
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<Grep>
|
||||
trackQuery(query: string, file: string): Result<boolean>
|
||||
getHistoricalQuery(offset: number): Result<string | null>
|
||||
}
|
||||
|
||||
export function available() {
|
||||
return FileFinder.isAvailable()
|
||||
}
|
||||
|
||||
export function create(opts: Init): Result<Picker> {
|
||||
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"
|
||||
@@ -0,0 +1,138 @@
|
||||
export type Result<T> = { 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<Result<boolean>>
|
||||
refreshGitStatus(): Result<number>
|
||||
fileSearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<Search>
|
||||
glob(
|
||||
pattern: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<Search>
|
||||
directorySearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<DirSearch>
|
||||
mixedSearch(
|
||||
query: string,
|
||||
opts?: {
|
||||
currentFile?: string
|
||||
pageIndex?: number
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<MixedSearch>
|
||||
grep(
|
||||
query: string,
|
||||
opts?: {
|
||||
mode?: "plain" | "regex" | "fuzzy"
|
||||
maxMatchesPerFile?: number
|
||||
timeBudgetMs?: number
|
||||
beforeContext?: number
|
||||
afterContext?: number
|
||||
cursor?: Cursor
|
||||
pageSize?: number
|
||||
},
|
||||
): Result<Grep>
|
||||
trackQuery(query: string, file: string): Result<boolean>
|
||||
getHistoricalQuery(offset: number): Result<string | null>
|
||||
}
|
||||
|
||||
export function available() {
|
||||
return false
|
||||
}
|
||||
|
||||
export function create(_opts: Init): Result<Picker> {
|
||||
return { ok: false, error: "fff unavailable on node runtime" }
|
||||
}
|
||||
|
||||
export * as Fff from "./fff.node"
|
||||
@@ -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<typeof Result>
|
||||
export type Match = Schema.Schema.Type<typeof Match>
|
||||
export type Item = Match["data"]
|
||||
export type Begin = Schema.Schema.Type<typeof Begin>
|
||||
export type End = Schema.Schema.Type<typeof End>
|
||||
export type Summary = Schema.Schema.Type<typeof Summary>
|
||||
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<string, Error>
|
||||
readonly files: (input: FilesInput) => Stream.Stream<string, PlatformError | Error>
|
||||
readonly tree: (input: TreeInput) => Effect.Effect<string, PlatformError | Error>
|
||||
readonly search: (input: SearchInput) => Effect.Effect<SearchResult, PlatformError | Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<never, Error>((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<string, PlatformError | Error | Cause.Done>, 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<A, E, R>(effect: Effect.Effect<A, E, R>, signal?: AbortSignal) {
|
||||
return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect
|
||||
}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, FSUtil.Service | ChildProcessSpawner | HttpClient.HttpClient> =
|
||||
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<string, PlatformError | Error>((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<string, Node>
|
||||
}
|
||||
|
||||
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"
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Schema } from "effect"
|
||||
import { NonNegativeInt, PositiveInt, RelativePath } from "../schema"
|
||||
|
||||
export class Entry extends Schema.Class<Entry>("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<Match>("FileSystem.Match")({
|
||||
entry: Entry,
|
||||
line: PositiveInt,
|
||||
offset: NonNegativeInt,
|
||||
text: Schema.String,
|
||||
submatches: Schema.Array(Submatch),
|
||||
}) {}
|
||||
@@ -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<FileSystem.Entry[]>
|
||||
readonly glob: (input: FileSystem.GlobInput) => Effect.Effect<readonly FileSystem.Entry[]>
|
||||
readonly grep: (input: FileSystem.GrepInput) => Effect.Effect<readonly FileSystem.Match[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<string>()
|
||||
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)),
|
||||
)
|
||||
@@ -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({})),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>()("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 {
|
||||
|
||||
@@ -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<WorktreeError>()("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<void, PatchError>
|
||||
readonly softResetChanges: (directory: AbsolutePath) => Effect.Effect<void, PatchError>
|
||||
readonly worktreeCreate: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
|
||||
readonly worktreeRemove: (input: { repo: Repo; directory: AbsolutePath }) => Effect.Effect<void, WorktreeError>
|
||||
readonly worktreeRemove: (input: {
|
||||
repo: Repo
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) => Effect.Effect<void, WorktreeError>
|
||||
readonly worktreeList: (repo: Repo) => Effect.Effect<AbsolutePath[], WorktreeError>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Interface>) =>
|
||||
Layer.effect(
|
||||
|
||||
@@ -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<ResizerUnavailableError>()(
|
||||
"Image.ResizerUnavailableError",
|
||||
{},
|
||||
) {}
|
||||
|
||||
export class DecodeError extends Schema.TaggedErrorClass<DecodeError>()("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<PixelLimitError>()("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<SizeError>()("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<Service, Interface>()("@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))
|
||||
@@ -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<Array<{ width: number; height: number }>>((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<readonly [mime: string, encode: () => 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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<string>()
|
||||
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,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
@@ -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<string, unknown> {
|
||||
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)) } }
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Effect } from "effect"
|
||||
import type { ChildProcess } from "effect/unstable/process"
|
||||
|
||||
const effects = new WeakMap<object, Effect.Effect<void, unknown>>()
|
||||
|
||||
export function attach(command: ChildProcess.StandardCommand, effect: Effect.Effect<void, unknown>) {
|
||||
effects.set(command, effect)
|
||||
return command
|
||||
}
|
||||
|
||||
export function take(command: ChildProcess.StandardCommand) {
|
||||
const effect = effects.get(command)
|
||||
effects.delete(command)
|
||||
return effect
|
||||
}
|
||||
@@ -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<LocationServiceMap>()("@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<LocationServiceMap>()("
|
||||
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,
|
||||
|
||||
@@ -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<PathError>()("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<RevalidationError>()(
|
||||
"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,7 +43,24 @@ export const externalDirectoryPermission = (input: ExternalDirectoryAuthorizatio
|
||||
export interface Target {
|
||||
/** Canonical existing path, or missing path below a canonical directory. */
|
||||
readonly canonical: string
|
||||
readonly exists: boolean
|
||||
/** Permission resource: Location-relative for internal paths, canonical for external paths. */
|
||||
readonly resource: string
|
||||
readonly externalDirectory?: ExternalDirectoryAuthorization
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* 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<Target, PathError | FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationMutation") {}
|
||||
|
||||
interface ResolvedPath {
|
||||
readonly canonical: string
|
||||
readonly type?:
|
||||
| "File"
|
||||
| "Directory"
|
||||
@@ -77,51 +70,7 @@ export interface Target {
|
||||
| "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.
|
||||
*/
|
||||
readonly resolve: (input: ResolveInput) => Effect.Effect<Plan, PathError | FSUtil.Error>
|
||||
/**
|
||||
* 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<Target, RevalidationError | FSUtil.Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/LocationMutation") {}
|
||||
|
||||
interface ResolvedPath {
|
||||
readonly canonical: string
|
||||
readonly exists: boolean
|
||||
readonly type?: Target["type"]
|
||||
readonly authority: Identity
|
||||
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<ReturnType<typeof fs.stat>>): 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<A>(effect: Effect.Effect<A, FSUtil.Error>) {
|
||||
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 -> "<target>/*"
|
||||
* file or missing target -> "<nearest existing parent>/*"
|
||||
*/
|
||||
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
|
||||
externalDirectory: external
|
||||
? {
|
||||
action: "external_directory",
|
||||
directory: externalDirectory,
|
||||
resource: externalResource,
|
||||
save: externalResource,
|
||||
}
|
||||
: undefined,
|
||||
} satisfies Target
|
||||
})
|
||||
|
||||
/**
|
||||
* 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
|
||||
})
|
||||
|
||||
return Service.of({ resolve, revalidate })
|
||||
return Service.of({ resolve })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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<File>("LocationSearch.File")({
|
||||
path: RelativePath,
|
||||
canonical: Schema.String,
|
||||
resource: Schema.String,
|
||||
mtime: Schema.Number,
|
||||
}) {}
|
||||
|
||||
export class Submatch extends Schema.Class<Submatch>("LocationSearch.Submatch")({
|
||||
text: Schema.String,
|
||||
start: NonNegativeInt,
|
||||
end: NonNegativeInt,
|
||||
}) {}
|
||||
|
||||
export class Match extends Schema.Class<Match>("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<FilesResult>("LocationSearch.FilesResult")({
|
||||
items: Schema.Array(File),
|
||||
truncated: Schema.Boolean,
|
||||
partial: Schema.Boolean,
|
||||
}) {}
|
||||
|
||||
export class GrepResult extends Schema.Class<GrepResult>("LocationSearch.GrepResult")({
|
||||
items: Schema.Array(Match),
|
||||
truncated: Schema.Boolean,
|
||||
partial: Schema.Boolean,
|
||||
}) {}
|
||||
|
||||
export interface Interface {
|
||||
readonly files: (input: FilesInput, root?: FileSystem.RootTarget) => Effect.Effect<FilesResult, Ripgrep.Error>
|
||||
readonly grep: (
|
||||
input: GrepInput,
|
||||
root?: FileSystem.RootTarget,
|
||||
) => Effect.Effect<GrepResult, Ripgrep.Error | Ripgrep.InvalidPatternError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<string, ReturnType<typeof candidate>>()
|
||||
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,
|
||||
})
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -5,11 +5,10 @@ import { WorkspaceV2 } from "./workspace"
|
||||
|
||||
export * as Location from "./location"
|
||||
|
||||
export const Ref = Schema.Struct({
|
||||
export class Ref extends Schema.Class<Ref>("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<Info>("Location.Info")({
|
||||
directory: AbsolutePath,
|
||||
|
||||
@@ -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<string, string>
|
||||
body: Record<string, unknown>
|
||||
generation?: Generation
|
||||
options?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const generationKeys = new Map<string, keyof Generation>([
|
||||
["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<string, string>
|
||||
}
|
||||
|
||||
const profiles = new Map<string, Profile>([
|
||||
[
|
||||
"@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<Request>) => ({
|
||||
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<Request>) => {
|
||||
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<Record<string, unknown>>) {
|
||||
const generation: Record<string, number | ReadonlyArray<string>> = {}
|
||||
const options: Record<string, unknown> = {}
|
||||
const body: Record<string, unknown> = {}
|
||||
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 }
|
||||
}
|
||||
@@ -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<Info>("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<Info>("ModelV2.Info")({
|
||||
request: {
|
||||
headers: {},
|
||||
body: {},
|
||||
generation: {},
|
||||
options: {},
|
||||
},
|
||||
variants: [],
|
||||
time: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
}),
|
||||
)
|
||||
@@ -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<string, unknown>,
|
||||
prefix = "",
|
||||
seen = new WeakSet<object>(),
|
||||
): Array<readonly [string, unknown]> {
|
||||
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<string, unknown> {
|
||||
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<string, LogLevel.LogLevel>
|
||||
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"
|
||||
@@ -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<string, string>,
|
||||
)
|
||||
: 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<string, string> } {
|
||||
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"
|
||||
@@ -0,0 +1 @@
|
||||
export const runID = crypto.randomUUID().slice(0, 8)
|
||||
@@ -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
|
||||
|
||||
@@ -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* () {}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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<void>()
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -45,7 +45,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
|
||||
function gatewayConfig(options: Record<string, unknown>): 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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, Error>()
|
||||
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<void, Error>((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<TokenResponse>(`${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<TokenResponse>(`${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<A>(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<A>
|
||||
},
|
||||
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<Pkce> {
|
||||
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 =
|
||||
"<!doctype html><title>Kilo</title><h1>Authorization successful</h1><p>You can close this window.</p>" // kilocode_change
|
||||
const errorPage = (message: string) =>
|
||||
`<!doctype html><title>Kilo</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>` // kilocode_change
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>()("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"
|
||||
|
||||
@@ -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<Resolved, { kind: "invalid" }>
|
||||
|
||||
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<Resolved[]>
|
||||
readonly get: (name: string) => Effect.Effect<Resolved | undefined>
|
||||
readonly resolveMention: (value: string) => Effect.Effect<Mention | undefined, RepositoryCache.Error>
|
||||
readonly ensurePath: (target?: string) => Effect.Effect<void, RepositoryCache.Error>
|
||||
readonly containsManagedPath: (target?: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectReference") {}
|
||||
|
||||
type Materializer = {
|
||||
readonly name: string
|
||||
readonly repository: string
|
||||
readonly path: string
|
||||
readonly run: Effect.Effect<void, RepositoryCache.Error>
|
||||
}
|
||||
|
||||
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<string, { name: string; branch?: string }>()
|
||||
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<string>()
|
||||
return references.filter((reference): reference is Extract<Resolved, { kind: "git" }> => {
|
||||
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)
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Copy, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly remove: (directory: AbsolutePath) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly remove: (input: {
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<Copy[], Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly detect: (directory: AbsolutePath) => Effect.Effect<boolean>
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
@@ -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<Info>("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"),
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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, [])
|
||||
|
||||
@@ -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<Service, Interface>()("@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,
|
||||
)
|
||||
const ApplicationToolsLayer = ApplicationTools.layer
|
||||
class SessionModelValidation extends Context.Service<
|
||||
SessionModelValidation,
|
||||
{
|
||||
readonly validate: (
|
||||
input: Session.SwitchModelInput & { readonly location: Session.Info["location"] },
|
||||
) => Effect.Effect<void, Session.ModelUnavailableError | Session.VariantUnavailableError>
|
||||
}
|
||||
>()("@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,
|
||||
|
||||
@@ -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<ModelUnavailableError>()(
|
||||
"Session.ModelUnavailableError",
|
||||
{
|
||||
providerID: Model.Ref.fields.providerID,
|
||||
modelID: Model.Ref.fields.id,
|
||||
},
|
||||
) {}
|
||||
|
||||
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
||||
"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<Info, NotFoundError>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Info[]>
|
||||
readonly prompt: (input: PromptInput) => Effect.Effect<Admission, NotFoundError | PromptConflictError>
|
||||
readonly switchModel: (
|
||||
input: SwitchModelInput,
|
||||
) => Effect.Effect<void, NotFoundError | ModelUnavailableError | VariantUnavailableError>
|
||||
/** 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<void>
|
||||
readonly messages: (input: MessagesInput) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
|
||||
readonly message: (input: MessageInput) => Effect.Effect<Message | undefined>
|
||||
readonly context: (sessionID: ID) => Effect.Effect<Message[], NotFoundError | MessageDecodeError>
|
||||
|
||||
@@ -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<Record<string, NativeTool.Any>>) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly register: (tools: Readonly<Record<string, AnyTool>>) => Effect.Effect<void, RegistrationError, Scope.Scope>
|
||||
}
|
||||
|
||||
@@ -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<LocalSource>("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<GitSource>("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<Info>("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<string, Source>
|
||||
}
|
||||
|
||||
type Editor = {
|
||||
add(name: string, source: Source): void
|
||||
remove(name: string): void
|
||||
list(): readonly [string, Source][]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly transform: State.Interface<Data, Editor>["transform"]
|
||||
readonly replace: (sources: readonly (readonly [string, Source])[]) => Effect.Effect<void> // kilocode_change
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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<string, Info>()
|
||||
const state = State.create<Data, Editor>({
|
||||
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<string, string | undefined>()
|
||||
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
|
||||
@@ -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<typeof Summary.Type>) =>
|
||||
[
|
||||
"Project references provide additional directories that can be accessed when relevant.",
|
||||
"<available_references>",
|
||||
...references.flatMap((reference) => [
|
||||
" <reference>",
|
||||
` <name>${reference.name}</name>`,
|
||||
` <path>${reference.path}</path>`,
|
||||
...(reference.description === undefined ? [] : [` <description>${reference.description}</description>`]),
|
||||
" </reference>",
|
||||
]),
|
||||
"</available_references>",
|
||||
].join("\n")
|
||||
|
||||
export interface Interface {
|
||||
readonly load: () => Effect.Effect<SystemContext.SystemContext>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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
|
||||
+153
-31
@@ -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<Error>()("Ripgrep.Error", {
|
||||
message: Schema.String,
|
||||
@@ -46,17 +50,24 @@ export class InvalidPatternError extends Schema.TaggedErrorClass<InvalidPatternE
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Result<A> {
|
||||
readonly items: A[]
|
||||
readonly truncated: boolean
|
||||
readonly partial: boolean
|
||||
}
|
||||
|
||||
export interface FilesInput {
|
||||
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<void>
|
||||
}
|
||||
|
||||
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<void, unknown> // 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<void, unknown> // kilocode_change - bind approved searches at spawn
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly files: (input: FilesInput) => Effect.Effect<Result<string>, Error>
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<Result<Match>, Error | InvalidPatternError>
|
||||
readonly find: (input: FindInput) => Effect.Effect<readonly Entry[], Error>
|
||||
readonly glob: (input: GlobInput) => Effect.Effect<SearchResult<Entry>, Error> // kilocode_change
|
||||
readonly grep: (input: GrepInput) => Effect.Effect<SearchResult<Match>, Error | InvalidPatternError> // kilocode_change
|
||||
}
|
||||
|
||||
// kilocode_change start - retain truncation state through model-facing tools
|
||||
export interface SearchResult<A> {
|
||||
readonly items: readonly A[]
|
||||
readonly truncated: boolean
|
||||
readonly partial: boolean
|
||||
}
|
||||
// kilocode_change end
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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 = <A>(input: {
|
||||
readonly cwd: string
|
||||
@@ -93,21 +114,36 @@ export const layer = Layer.effect(
|
||||
readonly signal?: AbortSignal
|
||||
readonly parse: (line: string) => Effect.Effect<A | undefined, Error>
|
||||
readonly pattern?: string
|
||||
readonly onItem?: (item: A) => Effect.Effect<void>
|
||||
readonly validate?: Effect.Effect<void, unknown> // 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<string>({
|
||||
...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(/^\.\//, "")),
|
||||
}).pipe(Effect.catchTag("Ripgrep.InvalidPatternError", (cause) => Effect.fail(failure(cause.message, cause)))),
|
||||
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<Entry>({
|
||||
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) => {
|
||||
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<Match>({
|
||||
run<RawMatchData>({
|
||||
...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)))
|
||||
|
||||
@@ -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<string, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@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),
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user