diff --git a/.changeset/add-alibaba-provider.md b/.changeset/add-alibaba-provider.md deleted file mode 100644 index 256b5d883c9..00000000000 --- a/.changeset/add-alibaba-provider.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-gateway": minor ---- - -Add Alibaba as a supported AI SDK provider for routing Qwen model requests via the native `@ai-sdk/alibaba` adapter. diff --git a/.changeset/agent-manager-message-fork.md b/.changeset/agent-manager-message-fork.md deleted file mode 100644 index 23fc7bdf7f3..00000000000 --- a/.changeset/agent-manager-message-fork.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Support forking Agent Manager sessions from a specific user message. diff --git a/.changeset/alibaba-provider-options.md b/.changeset/alibaba-provider-options.md deleted file mode 100644 index 5b46c9e003a..00000000000 --- a/.changeset/alibaba-provider-options.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Add Alibaba to `kiloProviderOptions` so thinking is enabled correctly when routing through the Kilo gateway with `ai_sdk_provider: "alibaba"`. diff --git a/.changeset/compact-todo-updates.md b/.changeset/compact-todo-updates.md new file mode 100644 index 00000000000..6ef497be78d --- /dev/null +++ b/.changeset/compact-todo-updates.md @@ -0,0 +1,6 @@ +--- +"kilo-code": patch +"@kilocode/cli": patch +--- + +Show compact todo update cards when checking off items in long todo lists. diff --git a/.changeset/custom-provider-delete-sticks.md b/.changeset/custom-provider-delete-sticks.md deleted file mode 100644 index ae10a1ec3f1..00000000000 --- a/.changeset/custom-provider-delete-sticks.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"kilo-code": patch -"@kilocode/cli": patch ---- - -Fix custom provider model and variant deletions being silently reverted on save. Removing a model or reasoning variant from a custom provider now actually removes it from your config. diff --git a/.changeset/fix-worktree-session-routing.md b/.changeset/fix-worktree-session-routing.md deleted file mode 100644 index d0d5fae9181..00000000000 --- a/.changeset/fix-worktree-session-routing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fixed Agent Manager incorrectly duplicating worktree sessions into the Local tab when forking a session or using "Continue in Worktree". diff --git a/.changeset/kiloclaw-chat-panel.md b/.changeset/kiloclaw-chat-panel.md deleted file mode 100644 index 59a35693468..00000000000 --- a/.changeset/kiloclaw-chat-panel.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Add KiloClaw chat panel to the VS Code extension diff --git a/.changeset/permission-apply-patch-diffs.md b/.changeset/permission-apply-patch-diffs.md new file mode 100644 index 00000000000..2d115ae7c20 --- /dev/null +++ b/.changeset/permission-apply-patch-diffs.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Show apply_patch diffs in the permission prompt before approval. diff --git a/.changeset/plan-followup-continue-click.md b/.changeset/plan-followup-continue-click.md deleted file mode 100644 index b3a6be4d396..00000000000 --- a/.changeset/plan-followup-continue-click.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix the "Continue here" button not submitting after a plan is finished. Picking an option on a single-question prompt now sends the reply immediately — matching the CLI behaviour — and the redundant "Type your own answer" row no longer appears on the plan follow-up question. diff --git a/.changeset/question-custom-answer-duplicate.md b/.changeset/question-custom-answer-duplicate.md deleted file mode 100644 index d5f10217329..00000000000 --- a/.changeset/question-custom-answer-duplicate.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix duplicate text shown when typing a custom answer in the question tool. The preview text above the input field is now hidden while editing, so only the input value is visible. diff --git a/.changeset/queued-messages-stay-at-bottom.md b/.changeset/queued-messages-stay-at-bottom.md deleted file mode 100644 index fc28d66d165..00000000000 --- a/.changeset/queued-messages-stay-at-bottom.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix queued follow-up messages so they stay stacked at the bottom of the chat while the previous turn is still being processed, instead of getting interleaved with tool output. diff --git a/.changeset/quiet-review-summary.md b/.changeset/quiet-review-summary.md deleted file mode 100644 index eb3ab9f2aaf..00000000000 --- a/.changeset/quiet-review-summary.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Clarify suggest tool guidance so the assistant writes its final summary before offering a local review. diff --git a/.changeset/restore-virtual-history.md b/.changeset/restore-virtual-history.md deleted file mode 100644 index 433d931ca94..00000000000 --- a/.changeset/restore-virtual-history.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Restore earlier chat history when scrolling through virtualized message lists. diff --git a/.changeset/scroll-up-while-busy.md b/.changeset/scroll-up-while-busy.md deleted file mode 100644 index 67073cf95d3..00000000000 --- a/.changeset/scroll-up-while-busy.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix chat scroll jumping back to the bottom while the session is busy — you can now scroll up to read earlier context while a response is streaming or while a question is waiting for your answer. diff --git a/.changeset/sidebar-message-forks.md b/.changeset/sidebar-message-forks.md deleted file mode 100644 index 3c07a889db7..00000000000 --- a/.changeset/sidebar-message-forks.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": minor ---- - -Support forking sidebar conversations from any user message. diff --git a/.changeset/suggest-tool-stuck-session.md b/.changeset/suggest-tool-stuck-session.md deleted file mode 100644 index 305bc24b2bc..00000000000 --- a/.changeset/suggest-tool-stuck-session.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Fix a stuck session state when the suggest tool was left open. If the suggestion was never accepted or dismissed (for example, because VS Code was closed while it was showing), the session stayed marked as busy and any follow-up messages appeared queued forever. The session is now marked idle while waiting for a response to a suggestion. diff --git a/.changeset/update-anthropic-sdk-3071.md b/.changeset/update-anthropic-sdk-3071.md deleted file mode 100644 index 8b418e09105..00000000000 --- a/.changeset/update-anthropic-sdk-3071.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"@kilocode/kilo-gateway": patch ---- - -Update `@ai-sdk/anthropic` to 3.0.71, adding `xhigh` effort for Opus 4.7 adaptive thinking (3.0.70) and fixing fine-grained tool streaming beta header for Opus 4.7 (3.0.71) diff --git a/.changeset/update-bedrock-vertex-providers.md b/.changeset/update-bedrock-vertex-providers.md deleted file mode 100644 index e64fc61bf4d..00000000000 --- a/.changeset/update-bedrock-vertex-providers.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Update `@ai-sdk/amazon-bedrock` to 4.0.96 and `@ai-sdk/google-vertex` to 4.0.112, both of which include Opus 4.7 support with `xhigh` adaptive thinking effort diff --git a/.changeset/vscode-subagent-spacing.md b/.changeset/vscode-subagent-spacing.md deleted file mode 100644 index 7fe964ed86b..00000000000 --- a/.changeset/vscode-subagent-spacing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Restore spacing between sub-agent output and the following user message in the VS Code chat. diff --git a/.gitattributes b/.gitattributes index 30441642037..a53ef3c352f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,6 +8,7 @@ # Visual regression baseline snapshots packages/kilo-ui/tests/**/*.png filter=lfs diff=lfs merge=lfs -text packages/kilo-vscode/tests/**/*.png filter=lfs diff=lfs merge=lfs -text +packages/kilo-docs/public/img/screenshot-tests/**/*.png filter=lfs diff=lfs merge=lfs -text # Hide non-English localization files by default in GitHub PR diffs. **/i18n/*.ts linguist-generated=true diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index d1e3bfc25d0..9859174a2e3 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -1,5 +1,10 @@ name: "Setup Bun" description: "Setup Bun with caching and install dependencies" +inputs: + install-flags: + description: "Additional flags to pass to 'bun install'" + required: false + default: "" runs: using: "composite" steps: @@ -46,8 +51,8 @@ runs: # e.g. ./patches/ for standard-openapi # https://github.com/oven-sh/bun/issues/28147 if [ "$RUNNER_OS" = "Windows" ]; then - bun install --linker hoisted + bun install --linker hoisted ${{ inputs.install-flags }} else - bun install + bun install ${{ inputs.install-flags }} fi shell: bash diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index ec41b02e950..2b7e92a9d70 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -13,8 +13,8 @@ Some description of HOW you achieved it. Perhaps give a high level description o ## Screenshots | before | after | -| ------ | ----- | -| | | +|---|---| +| | | ## How to Test diff --git a/.github/workflows/check-md-table-padding.yml b/.github/workflows/check-md-table-padding.yml new file mode 100644 index 00000000000..51d6706597d --- /dev/null +++ b/.github/workflows/check-md-table-padding.yml @@ -0,0 +1,24 @@ +name: Check markdown table padding + +on: + pull_request: + paths: + - "**/*.md" + - "script/check-md-table-padding.ts" + - ".github/workflows/check-md-table-padding.yml" + workflow_dispatch: + +jobs: + check: + name: Check markdown table padding + if: github.repository == 'Kilo-Org/kilocode' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - uses: oven-sh/setup-bun@v2 + + - name: Run check + run: bun run script/check-md-table-padding.ts diff --git a/.github/workflows/check-opencode-annotations.yml b/.github/workflows/check-opencode-annotations.yml index b2fc705daea..cde9056873d 100644 --- a/.github/workflows/check-opencode-annotations.yml +++ b/.github/workflows/check-opencode-annotations.yml @@ -1,11 +1,21 @@ -name: Check opencode annotations +name: Check shared upstream annotations on: pull_request: paths: + - ".github/**" + - "github/**" + - "sdks/vscode/**" + - "packages/app/**" + - "packages/desktop/**" + - "packages/desktop-electron/**" + - "packages/extensions/**" - "packages/opencode/**" - - "script/check-opencode-annotations.ts" - - ".github/workflows/check-opencode-annotations.yml" + - "packages/script/**" + - "packages/shared/**" + - "packages/storybook/**" + - "packages/ui/**" + - "script/**" workflow_dispatch: jobs: @@ -21,7 +31,7 @@ jobs: - uses: oven-sh/setup-bun@v2 - - name: Check kilocode_change annotations in shared opencode files + - name: Check kilocode_change annotations in shared upstream files env: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1a419e32fb3..234048c3641 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,11 +22,13 @@ on: description: "Override version (optional)" required: false type: string + # kilocode_change start pre_release: description: "Publish as pre-release (VS Code marketplace + npm rc channel)" required: false type: boolean - default: false + default: true + # kilocode_change end concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }} @@ -93,10 +95,24 @@ jobs: GH_TOKEN: ${{ github.token }} GH_REPO: ${{ github.repository }} + # kilocode_change start - pack dist tree into a single zstd-compressed tar before upload. + # download-artifact@v4's streaming unzip is CPU-bound and processes entries one by one; + # collapsing ~480 files into one tar cuts build-vscode download from >30m to seconds. + # zstd compresses the wasms (~4x) and leaves the already-compact binaries alone, for + # ~1 GB artifact. compression-level: 0 on the outer zip avoids double-compression. + # zstd + GNU tar are preinstalled on both github-hosted and blacksmith ubuntu-2404. + # See actions/upload-artifact#36, actions/toolkit#1533. + - name: Pack CLI dist into tar.zst + run: | + find packages/opencode/dist -name '*.map' -delete + tar --zstd -cf /tmp/kilo-cli.tar.zst -C packages/opencode/dist . + - uses: actions/upload-artifact@v4 with: name: kilo-cli - path: packages/opencode/dist + path: /tmp/kilo-cli.tar.zst + compression-level: 0 + # kilocode_change end outputs: version: ${{ needs.version.outputs.version }} @@ -118,11 +134,17 @@ jobs: - name: Install @vscode/vsce run: bun install -g @vscode/vsce + # kilocode_change start - download into /tmp and extract the tar.zst packed by build-cli - uses: actions/download-artifact@v4 with: name: kilo-cli - path: packages/opencode/dist + path: /tmp + - name: Unpack CLI dist + run: | + mkdir -p packages/opencode/dist + tar --zstd -xf /tmp/kilo-cli.tar.zst -C packages/opencode/dist + # kilocode_change end - name: Build VSIX packages run: bun script/build.ts working-directory: ./packages/kilo-vscode @@ -310,11 +332,17 @@ jobs: kilo-maintainer-app-secret: ${{ secrets.KILO_MAINTAINER_APP_SECRET }} # kilocode_change end + # kilocode_change start - download into /tmp and extract the tar.zst packed by build-cli - uses: actions/download-artifact@v4 with: name: kilo-cli - path: packages/opencode/dist + path: /tmp + - name: Unpack CLI dist + run: | + mkdir -p packages/opencode/dist + tar --zstd -xf /tmp/kilo-cli.tar.zst -C packages/opencode/dist + # kilocode_change end - uses: actions/download-artifact@v4 with: name: kilo-vscode diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 734d589d77c..a041bc72569 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -40,19 +40,12 @@ permissions: jobs: smoke-test: name: Smoke Test - runs-on: ubuntu-24.04 + runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 30 env: KILO_API_KEY: ${{ secrets.KILO_API_KEY }} KILO_ORG_ID: ${{ secrets.KILO_ORG_ID }} steps: - - name: Free disk space - continue-on-error: true - run: | - sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ - /usr/local/share/boost /opt/hostedtoolcache - sudo docker image prune --all --force - - name: Checkout kilo-bench uses: actions/checkout@v4 with: diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 369da658564..4f49c4aa461 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -26,7 +26,7 @@ jobs: - "packages/kilo-vscode/.storybook/**" - "packages/kilo-vscode/tests/visual-regression*" - "packages/kilo-vscode/tests/permission-dock-dropdown*" - - "packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/**" + - "packages/kilo-docs/public/img/screenshot-tests/**" - ".github/workflows/visual-regression.yml" - name: Check if PR is from a fork id: fork-check @@ -127,7 +127,7 @@ jobs: const idx = JSON.parse(fs.readFileSync('storybook-static/index.json', 'utf8')); const stories = Object.values(idx.entries || idx.stories || {}).filter(s => s.id && !s.id.endsWith('--docs')); const expected = new Set(stories.map(s => { const [c,v] = s.id.split('--'); return c + '/' + v + '-chromium-linux.png'; })); - const dir = 'tests/visual-regression.spec.ts-snapshots'; + const dir = '../kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression'; if (!fs.existsSync(dir)) process.exit(0); for (const component of fs.readdirSync(dir)) { const cdir = path.join(dir, component); @@ -143,7 +143,7 @@ jobs: - name: Check for baseline changes (fork PRs) if: needs.check-paths.outputs.is_fork == 'true' run: | - git add packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/ + git add packages/kilo-docs/public/img/screenshot-tests/kilo-ui/ if git diff --cached --quiet; then echo "No visual regression detected." else @@ -157,7 +157,7 @@ jobs: - name: Fail if baselines still changing after auto-update if: needs.check-paths.outputs.is_fork != 'true' && steps.check-baseline-commit.outputs.is_baseline_update == 'true' run: | - git add packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/ + git add packages/kilo-docs/public/img/screenshot-tests/kilo-ui/ if git diff --cached --quiet; then echo "Baselines are stable after auto-update." else @@ -176,7 +176,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/ + git add packages/kilo-docs/public/img/screenshot-tests/kilo-ui/ if git diff --cached --quiet; then echo "No new baselines — nothing to commit." echo "changed=false" >> "$GITHUB_OUTPUT" @@ -291,7 +291,7 @@ jobs: const idx = JSON.parse(fs.readFileSync('storybook-static/index.json', 'utf8')); const stories = Object.values(idx.entries || idx.stories || {}).filter(s => s.id && !s.id.endsWith('--docs')); const expected = new Set(stories.map(s => { const [c,v] = s.id.split('--'); return c + '/' + v + '-chromium-linux.png'; })); - const dir = 'tests/visual-regression.spec.ts-snapshots'; + const dir = '../kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression'; if (!fs.existsSync(dir)) process.exit(0); for (const component of fs.readdirSync(dir)) { const cdir = path.join(dir, component); @@ -307,7 +307,7 @@ jobs: - name: Remove stale baselines for permission-dock-dropdown tests run: | node -e " - const fs = require('fs'), path = require('path'); + const fs = require('fs'); const specPath = 'tests/permission-dock-dropdown.spec.ts'; const expected = new Set(); if (fs.existsSync(specPath)) { @@ -316,20 +316,16 @@ jobs: let m; while ((m = re.exec(spec)) !== null) { const parts = m[1].split(',').map(s => s.trim().replace(/[\"']/g, '')); - expected.add(parts.join('/').replace(/\.png$/, '-chromium-linux.png')); + const file = parts[parts.length - 1]; + if (file) expected.add(file.replace(/\.png$/, '-chromium-linux.png')); } } - const dir = 'tests/permission-dock-dropdown.spec.ts-snapshots'; + const dir = '../kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown'; if (!fs.existsSync(dir)) process.exit(0); - for (const sub of fs.readdirSync(dir)) { - const sdir = path.join(dir, sub); - if (!fs.statSync(sdir).isDirectory()) continue; - for (const file of fs.readdirSync(sdir)) { - const rel = sub + '/' + file; - if (file.endsWith('.png') && !expected.has(rel)) { - console.log('Removing stale baseline:', rel); - fs.unlinkSync(path.join(sdir, file)); - } + for (const file of fs.readdirSync(dir)) { + if (file.endsWith('.png') && !expected.has(file)) { + console.log('Removing stale baseline:', file); + fs.unlinkSync(dir + '/' + file); } } " @@ -338,8 +334,7 @@ jobs: - name: Check for baseline changes (fork PRs) if: needs.check-paths.outputs.is_fork == 'true' run: | - git add packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/ - [ -d packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots ] && git add packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/ + git add packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/ if git diff --cached --quiet; then echo "No visual regression detected." else @@ -353,8 +348,7 @@ jobs: - name: Fail if baselines still changing after auto-update if: needs.check-paths.outputs.is_fork != 'true' && steps.check-baseline-commit-vscode.outputs.is_baseline_update == 'true' run: | - git add packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/ - [ -d packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots ] && git add packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/ + git add packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/ if git diff --cached --quiet; then echo "Baselines are stable after auto-update." else @@ -373,8 +367,7 @@ jobs: run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git add packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/ - [ -d packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots ] && git add packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/ + git add packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/ if git diff --cached --quiet; then echo "No new baselines — nothing to commit." echo "changed=false" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/watch-opencode-releases.yml b/.github/workflows/watch-opencode-releases.yml new file mode 100644 index 00000000000..8762e9481de --- /dev/null +++ b/.github/workflows/watch-opencode-releases.yml @@ -0,0 +1,68 @@ +on: + schedule: + - cron: "7,37 * * * *" # every 30 min at :07 and :37 + workflow_dispatch: + +concurrency: + group: opencode-release-watch + cancel-in-progress: false + +permissions: + contents: read + +jobs: + check-release: + if: vars.OPENCODE_WATCH_ENABLED != 'false' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - id: fetch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + data=$(gh release view --repo anomalyco/opencode --json tagName,name,url 2>/dev/null || echo '{}') + tag=$(echo "$data" | jq -r '.tagName // empty') + [ -z "$tag" ] && { echo "tag=" >> "$GITHUB_OUTPUT"; exit 0; } + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "name=$(echo "$data" | jq -r '.name // .tagName')" >> "$GITHUB_OUTPUT" + echo "url=$(echo "$data" | jq -r '.url')" >> "$GITHUB_OUTPUT" + + - if: steps.fetch.outputs.tag != '' + id: cache + uses: actions/cache/restore@v4 + with: + path: .cache/last-seen + key: last-seen-${{ steps.fetch.outputs.tag }} + restore-keys: last-seen- + + - if: steps.fetch.outputs.tag != '' && steps.cache.outputs.cache-hit != 'true' + name: Trigger opencode sync + env: + URL: ${{ secrets.OPENCODE_WATCH_SYNC_URL }} + SECRET: ${{ secrets.OPENCODE_WATCH_SYNC_SECRET }} + run: | + if [ -n "$URL" ] && [ -n "$SECRET" ]; then + curl -sf -X POST "$URL" -H "x-sync-secret: $SECRET" -H "Content-Type: application/json" -d '{}' || true + fi + + - if: steps.fetch.outputs.tag != '' && steps.cache.outputs.cache-hit != 'true' + name: Notify Slack + uses: slackapi/slack-github-action@v2.0.0 + with: + webhook: ${{ secrets.OPENCODE_WATCH_SLACK_WEBHOOK }} + webhook-type: incoming-webhook + payload: | + name: "${{ steps.fetch.outputs.name }}" + tag: "${{ steps.fetch.outputs.tag }}" + url: "${{ steps.fetch.outputs.url }}" + + - if: steps.fetch.outputs.tag != '' && steps.cache.outputs.cache-hit != 'true' + name: Mark tag as seen + run: mkdir -p .cache/last-seen && echo "${{ steps.fetch.outputs.tag }}" > .cache/last-seen/tag.txt + + - if: success() && steps.fetch.outputs.tag != '' && steps.cache.outputs.cache-hit != 'true' + name: Save seen-tag cache + uses: actions/cache/save@v4 + with: + path: .cache/last-seen + key: last-seen-${{ steps.fetch.outputs.tag }} diff --git a/.kilo/package-lock.json b/.kilo/package-lock.json new file mode 100644 index 00000000000..dff625bc365 --- /dev/null +++ b/.kilo/package-lock.json @@ -0,0 +1,376 @@ +{ + "name": ".kilo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@kilocode/plugin": "7.2.24" + } + }, + "node_modules/@kilocode/plugin": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.24.tgz", + "integrity": "sha512-b2Xh3N/KaGdYj9hZbzZLKlDHXUsdMey0zL8G+RsnxDQCuhNm46LPyixtlJQhbCyir/zEEvy3uJI9kqKCsOWmQg==", + "license": "MIT", + "dependencies": { + "@kilocode/sdk": "7.2.24", + "effect": "4.0.0-beta.48", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.100", + "@opentui/solid": ">=0.1.100" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@kilocode/sdk": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.24.tgz", + "integrity": "sha512-Yos6WeyvMl86OGXLQqQ9YWDm/geAC77xetoptEsS3DLtPVgvz1Nqkvas77fAuAqwkSfGL2mLIiHUTqJUTVflOw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.48", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz", + "integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.6.0", + "find-my-way-ts": "^0.1.6", + "ini": "^6.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^1.11.9", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^13.0.0", + "yaml": "^2.8.3" + } + }, + "node_modules/fast-check": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", + "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", + "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multipasta": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", + "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", + "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.kilocode/package-lock.json b/.kilocode/package-lock.json new file mode 100644 index 00000000000..20f85087ba2 --- /dev/null +++ b/.kilocode/package-lock.json @@ -0,0 +1,376 @@ +{ + "name": ".kilocode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@kilocode/plugin": "7.2.24" + } + }, + "node_modules/@kilocode/plugin": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@kilocode/plugin/-/plugin-7.2.24.tgz", + "integrity": "sha512-b2Xh3N/KaGdYj9hZbzZLKlDHXUsdMey0zL8G+RsnxDQCuhNm46LPyixtlJQhbCyir/zEEvy3uJI9kqKCsOWmQg==", + "license": "MIT", + "dependencies": { + "@kilocode/sdk": "7.2.24", + "effect": "4.0.0-beta.48", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.100", + "@opentui/solid": ">=0.1.100" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@kilocode/sdk": { + "version": "7.2.24", + "resolved": "https://registry.npmjs.org/@kilocode/sdk/-/sdk-7.2.24.tgz", + "integrity": "sha512-Yos6WeyvMl86OGXLQqQ9YWDm/geAC77xetoptEsS3DLtPVgvz1Nqkvas77fAuAqwkSfGL2mLIiHUTqJUTVflOw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.48", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz", + "integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.6.0", + "find-my-way-ts": "^0.1.6", + "ini": "^6.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^1.11.9", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^13.0.0", + "yaml": "^2.8.3" + } + }, + "node_modules/fast-check": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", + "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", + "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multipasta": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", + "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", + "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.kilocode/skills/vscode-visual-regression/SKILL.md b/.kilocode/skills/vscode-visual-regression/SKILL.md index 35b9eda9659..f58550b258e 100644 --- a/.kilocode/skills/vscode-visual-regression/SKILL.md +++ b/.kilocode/skills/vscode-visual-regression/SKILL.md @@ -21,15 +21,15 @@ The test runner at `tests/visual-regression.spec.ts` is fully automatic — it f Stories live in `packages/kilo-vscode/webview-ui/src/stories/`. Existing files and their scope: -| File | Components covered | -| --------------------------- | -------------------------------------------------------- | -| `agent-manager.stories.tsx` | FileTree, DiffPanel, FullScreenDiffView, WorktreeItem | -| `chat.stories.tsx` | ChatView, QuestionDock | -| `composite.stories.tsx` | AssistantMessage with tool cards, permissions, questions | -| `prompt-input.stories.tsx` | PromptInput (sidebar prompt bar) | -| `settings.stories.tsx` | Settings panel, ProvidersTab | -| `history.stories.tsx` | SessionList | -| `shared.stories.tsx` | ModelSelector and shared controls | +| File | Components covered | +|---|---| +| `agent-manager.stories.tsx` | FileTree, DiffPanel, FullScreenDiffView, WorktreeItem | +| `chat.stories.tsx` | ChatView, QuestionDock | +| `composite.stories.tsx` | AssistantMessage with tool cards, permissions, questions | +| `prompt-input.stories.tsx` | PromptInput (sidebar prompt bar) | +| `settings.stories.tsx` | Settings panel, ProvidersTab | +| `history.stories.tsx` | SessionList | +| `shared.stories.tsx` | ModelSelector and shared controls | Add to an existing file if the component fits. Create a new file only for a genuinely new component area. @@ -227,12 +227,12 @@ The title-slug is derived from the meta `title` (lowercased, slashes become hyph Example mapping: -| Meta title | Export name | Snapshot path | -| --------------------- | -------------------- | ----------------------------------------------------------- | -| `"Chat"` | `ChatViewIdle` | `chat/chat-view-idle-chromium-linux.png` | +| Meta title | Export name | Snapshot path | +|---|---|---| +| `"Chat"` | `ChatViewIdle` | `chat/chat-view-idle-chromium-linux.png` | | `"Composite/Webview"` | `GlobWithPermission` | `composite-webview/glob-with-permission-chromium-linux.png` | -| `"Prompt Input"` | `Default200` | `prompt-input/default-200-chromium-linux.png` | -| `"AgentManager"` | `WorktreeItemActive` | `agentmanager/worktree-item-active-chromium-linux.png` | +| `"Prompt Input"` | `Default200` | `prompt-input/default-200-chromium-linux.png` | +| `"AgentManager"` | `WorktreeItemActive` | `agentmanager/worktree-item-active-chromium-linux.png` | # Reference: Playwright config diff --git a/.opencode/agent/translator.md b/.opencode/agent/translator.md deleted file mode 100644 index 9cad228a216..00000000000 --- a/.opencode/agent/translator.md +++ /dev/null @@ -1,900 +0,0 @@ ---- -description: Translate content for a specified locale while preserving technical terms -mode: subagent -model: kilo/google/gemini-3.1-pro-preview ---- - -You are a professional translator and localization specialist. - -Translate the user's content into the requested target locale (language + region, e.g. fr-FR, de-DE). - -Requirements: - -- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure). -- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks. -- Also preserve every term listed in the Do-Not-Translate glossary below. -- Also apply locale-specific guidance from `.opencode/glossary/.md` when available (for example, `zh-cn.md`). -- Do not modify fenced code blocks. -- Output ONLY the translation (no commentary). - -If the target locale is missing, ask the user to provide it. -If no locale-specific glossary exists, use the global glossary only. - ---- - -# Locale-Specific Glossaries - -When a locale glossary exists, use it to: - -- Apply preferred wording for recurring UI/docs terms in that locale -- Preserve locale-specific do-not-translate terms and casing decisions -- Prefer natural phrasing over literal translation when the locale file calls it out -- If the repo uses a locale alias slug, apply that file too (for example, `pt-BR` maps to `br.md` in this repo) - -Locale guidance does not override code/command preservation rules or the global Do-Not-Translate glossary below. - ---- - -# Do-Not-Translate Terms (OpenCode Docs) - -Generated from: `packages/web/src/content/docs/*.mdx` (default English docs) -Generated on: 2026-02-10 - -Use this as a translation QA checklist / glossary. Preserve listed terms exactly (spelling, casing, punctuation). - -General rules (verbatim, even if not listed below): - -- Anything inside inline code (single backticks) or fenced code blocks (triple backticks) -- MDX/JS code in docs: `import ... from "..."`, component tags, identifiers -- CLI commands, flags, config keys/values, file paths, URLs/domains, and env vars - -## Proper nouns and product names - -Additional (not reliably captured via link text): - -```text -Astro -Bun -Chocolatey -Cursor -Docker -Git -GitHub Actions -GitLab CI -GNOME Terminal -Homebrew -Mise -Neovim -Node.js -npm -Obsidian -opencode -opencode-ai -Paru -pnpm -ripgrep -Scoop -SST -Starlight -Visual Studio Code -VS Code -VSCodium -Windsurf -Windows Terminal -Yarn -Zellij -Zed -anomalyco -``` - -Extracted from link labels in the English docs (review and prune as desired): - -```text -@openspoon/subtask2 -302.AI console -ACP progress report -Agent Client Protocol -Agent Skills -Agentic -AGENTS.md -AI SDK -Alacritty -Anthropic -Anthropic's Data Policies -Atom One -Avante.nvim -Ayu -Azure AI Foundry -Azure portal -Baseten -built-in GITHUB_TOKEN -Bun.$ -Catppuccin -Cerebras console -ChatGPT Plus or Pro -Cloudflare dashboard -CodeCompanion.nvim -CodeNomad -Configuring Adapters: Environment Variables -Context7 MCP server -Cortecs console -Deep Infra dashboard -DeepSeek console -Duo Agent Platform -Everforest -Fireworks AI console -Firmware dashboard -Ghostty -GitLab CLI agents docs -GitLab docs -GitLab User Settings > Access Tokens -Granular Rules (Object Syntax) -Grep by Vercel -Groq console -Gruvbox -Helicone -Helicone documentation -Helicone Header Directory -Helicone's Model Directory -Hugging Face Inference Providers -Hugging Face settings -install WSL -IO.NET console -JetBrains IDE -Kanagawa -Kitty -MiniMax API Console -Models.dev -Moonshot AI console -Nebius Token Factory console -Nord -OAuth -Ollama integration docs -OpenAI's Data Policies -OpenChamber -OpenCode -OpenCode config -OpenCode Config -OpenCode TUI with the opencode theme -OpenCode Web - Active Session -OpenCode Web - New Session -OpenCode Web - See Servers -OpenCode Zen -OpenCode-Obsidian -OpenRouter dashboard -OpenWork -OVHcloud panel -Pro+ subscription -SAP BTP Cockpit -Scaleway Console IAM settings -Scaleway Generative APIs -SDK documentation -Sentry MCP server -shell API -Together AI console -Tokyonight -Unified Billing -Venice AI console -Vercel dashboard -WezTerm -Windows Subsystem for Linux (WSL) -WSL -WSL (Windows Subsystem for Linux) -WSL extension -xAI console -Z.AI API console -Zed -ZenMux dashboard -Zod -``` - -## Acronyms and initialisms - -```text -ACP -AGENTS -AI -AI21 -ANSI -API -AST -AWS -BTP -CD -CDN -CI -CLI -CMD -CORS -DEBUG -EKS -ERROR -FAQ -GLM -GNOME -GPT -HTML -HTTP -HTTPS -IAM -ID -IDE -INFO -IO -IP -IRSA -JS -JSON -JSONC -K2 -LLM -LM -LSP -M2 -MCP -MR -NET -NPM -NTLM -OIDC -OS -PAT -PATH -PHP -PR -PTY -README -RFC -RPC -SAP -SDK -SKILL -SSE -SSO -TS -TTY -TUI -UI -URL -US -UX -VCS -VPC -VPN -VS -WARN -WSL -X11 -YAML -``` - -## Code identifiers used in prose (CamelCase, mixedCase) - -```text -apiKey -AppleScript -AssistantMessage -baseURL -BurntSushi -ChatGPT -ClangFormat -CodeCompanion -CodeNomad -DeepSeek -DefaultV2 -FileContent -FileDiff -FileNode -fineGrained -FormatterStatus -GitHub -GitLab -iTerm2 -JavaScript -JetBrains -macOS -mDNS -MiniMax -NeuralNomadsAI -NickvanDyke -NoeFabris -OpenAI -OpenAPI -OpenChamber -OpenCode -OpenRouter -OpenTUI -OpenWork -ownUserPermissions -PowerShell -ProviderAuthAuthorization -ProviderAuthMethod -ProviderInitError -SessionStatus -TabItem -tokenType -ToolIDs -ToolList -TypeScript -typesUrl -UserMessage -VcsInfo -WebView2 -WezTerm -xAI -ZenMux -``` - -## OpenCode CLI commands (as shown in docs) - -```text -opencode -opencode [project] -opencode /path/to/project -opencode acp -opencode agent [command] -opencode agent create -opencode agent list -opencode attach [url] -opencode attach http://10.20.30.40:4096 -opencode attach http://localhost:4096 -opencode auth [command] -opencode auth list -opencode auth login -opencode auth logout -opencode auth ls -opencode export [sessionID] -opencode github [command] -opencode github install -opencode github run -opencode import -opencode import https://opncd.ai/s/abc123 -opencode import session.json -opencode mcp [command] -opencode mcp add -opencode mcp auth [name] -opencode mcp auth list -opencode mcp auth ls -opencode mcp auth my-oauth-server -opencode mcp auth sentry -opencode mcp debug -opencode mcp debug my-oauth-server -opencode mcp list -opencode mcp logout [name] -opencode mcp logout my-oauth-server -opencode mcp ls -opencode models --refresh -opencode models [provider] -opencode models anthropic -opencode run [message..] -opencode run Explain the use of context in Go -opencode serve -opencode serve --cors http://localhost:5173 --cors https://app.example.com -opencode serve --hostname 0.0.0.0 --port 4096 -opencode serve [--port ] [--hostname ] [--cors ] -opencode session [command] -opencode session list -opencode session delete -opencode stats -opencode uninstall -opencode upgrade -opencode upgrade [target] -opencode upgrade v0.1.48 -opencode web -opencode web --cors https://example.com -opencode web --hostname 0.0.0.0 -opencode web --mdns -opencode web --mdns --mdns-domain myproject.local -opencode web --port 4096 -opencode web --port 4096 --hostname 0.0.0.0 -opencode.server.close() -``` - -## Slash commands and routes - -```text -/agent -/auth/:id -/clear -/command -/config -/config/providers -/connect -/continue -/doc -/editor -/event -/experimental/tool?provider=

&model= -/experimental/tool/ids -/export -/file?path= -/file/content?path=

-/file/status -/find?pattern= -/find/file -/find/file?query= -/find/symbol?query= -/formatter -/global/event -/global/health -/help -/init -/instance/dispose -/log -/lsp -/mcp -/mnt/ -/mnt/c/ -/mnt/d/ -/models -/oc -/opencode -/path -/project -/project/current -/provider -/provider/{id}/oauth/authorize -/provider/{id}/oauth/callback -/provider/auth -/q -/quit -/redo -/resume -/session -/session/:id -/session/:id/abort -/session/:id/children -/session/:id/command -/session/:id/diff -/session/:id/fork -/session/:id/init -/session/:id/message -/session/:id/message/:messageID -/session/:id/permissions/:permissionID -/session/:id/prompt_async -/session/:id/revert -/session/:id/share -/session/:id/shell -/session/:id/summarize -/session/:id/todo -/session/:id/unrevert -/session/status -/share -/summarize -/theme -/tui -/tui/append-prompt -/tui/clear-prompt -/tui/control/next -/tui/control/response -/tui/execute-command -/tui/open-help -/tui/open-models -/tui/open-sessions -/tui/open-themes -/tui/show-toast -/tui/submit-prompt -/undo -/Users/username -/Users/username/projects/* -/vcs -``` - -## CLI flags and short options - -```text ---agent ---attach ---command ---continue ---cors ---cwd ---days ---dir ---dry-run ---event ---file ---force ---fork ---format ---help ---hostname ---hostname 0.0.0.0 ---keep-config ---keep-data ---log-level ---max-count ---mdns ---mdns-domain ---method ---model ---models ---port ---print-logs ---project ---prompt ---refresh ---session ---share ---title ---token ---tools ---verbose ---version ---wait - --c --d --f --h --m --n --s --v -``` - -## Environment variables - -```text -AI_API_URL -AI_FLOW_CONTEXT -AI_FLOW_EVENT -AI_FLOW_INPUT -AICORE_DEPLOYMENT_ID -AICORE_RESOURCE_GROUP -AICORE_SERVICE_KEY -ANTHROPIC_API_KEY -AWS_ACCESS_KEY_ID -AWS_BEARER_TOKEN_BEDROCK -AWS_PROFILE -AWS_REGION -AWS_ROLE_ARN -AWS_SECRET_ACCESS_KEY -AWS_WEB_IDENTITY_TOKEN_FILE -AZURE_COGNITIVE_SERVICES_RESOURCE_NAME -AZURE_RESOURCE_NAME -CI_PROJECT_DIR -CI_SERVER_FQDN -CI_WORKLOAD_REF -CLOUDFLARE_ACCOUNT_ID -CLOUDFLARE_API_TOKEN -CLOUDFLARE_GATEWAY_ID -CONTEXT7_API_KEY -GITHUB_TOKEN -GITLAB_AI_GATEWAY_URL -GITLAB_HOST -GITLAB_INSTANCE_URL -GITLAB_OAUTH_CLIENT_ID -GITLAB_TOKEN -GITLAB_TOKEN_OPENCODE -GOOGLE_APPLICATION_CREDENTIALS -GOOGLE_CLOUD_PROJECT -HTTP_PROXY -HTTPS_PROXY -K2_ -MY_API_KEY -MY_ENV_VAR -MY_MCP_CLIENT_ID -MY_MCP_CLIENT_SECRET -NO_PROXY -NODE_ENV -NODE_EXTRA_CA_CERTS -NPM_AUTH_TOKEN -OC_ALLOW_WAYLAND -KILO_API_KEY -KILO_AUTH_JSON -KILO_AUTO_SHARE -KILO_CLIENT -KILO_CONFIG -KILO_CONFIG_CONTENT -KILO_CONFIG_DIR -KILO_DISABLE_AUTOCOMPACT -KILO_DISABLE_AUTOUPDATE -KILO_DISABLE_CLAUDE_CODE -KILO_DISABLE_CLAUDE_CODE_PROMPT -KILO_DISABLE_CLAUDE_CODE_SKILLS -KILO_DISABLE_DEFAULT_PLUGINS -KILO_DISABLE_FILETIME_CHECK -KILO_DISABLE_LSP_DOWNLOAD -KILO_DISABLE_MODELS_FETCH -KILO_DISABLE_PRUNE -KILO_DISABLE_TERMINAL_TITLE -KILO_ENABLE_EXA -KILO_ENABLE_EXPERIMENTAL_MODELS -KILO_EXPERIMENTAL -KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS -KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT -KILO_EXPERIMENTAL_DISABLE_FILEWATCHER -KILO_EXPERIMENTAL_EXA -KILO_EXPERIMENTAL_FILEWATCHER -KILO_EXPERIMENTAL_ICON_DISCOVERY -KILO_EXPERIMENTAL_LSP_TOOL -KILO_EXPERIMENTAL_LSP_TY -KILO_EXPERIMENTAL_MARKDOWN -KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX -KILO_EXPERIMENTAL_OXFMT -KILO_EXPERIMENTAL_PLAN_MODE -KILO_ENABLE_QUESTION_TOOL -KILO_FAKE_VCS -KILO_GIT_BASH_PATH -KILO_MODEL -KILO_MODELS_URL -KILO_PERMISSION -KILO_PORT -KILO_SERVER_PASSWORD -KILO_SERVER_USERNAME -PROJECT_ROOT -RESOURCE_NAME -RUST_LOG -VARIABLE_NAME -VERTEX_LOCATION -XDG_CONFIG_HOME -``` - -## Package/module identifiers - -```text -../../../config.mjs -@astrojs/starlight/components -@opencode-ai/plugin -@opencode-ai/sdk -path -shescape -zod - -@ -@ai-sdk/anthropic -@ai-sdk/cerebras -@ai-sdk/google -@ai-sdk/openai -@ai-sdk/openai-compatible -@File#L37-42 -@modelcontextprotocol/server-everything -@opencode -``` - -## GitHub owner/repo slugs referenced in docs - -```text -24601/opencode-zellij-namer -angristan/opencode-wakatime -anomalyco/opencode -apps/opencode-agent -athal7/opencode-devcontainers -awesome-opencode/awesome-opencode -backnotprop/plannotator -ben-vargas/ai-sdk-provider-opencode-sdk -btriapitsyn/openchamber -BurntSushi/ripgrep -Cluster444/agentic -code-yeongyu/oh-my-opencode -darrenhinde/opencode-agents -different-ai/opencode-scheduler -different-ai/openwork -features/copilot -folke/tokyonight.nvim -franlol/opencode-md-table-formatter -ggml-org/llama.cpp -ghoulr/opencode-websearch-cited.git -H2Shami/opencode-helicone-session -hosenur/portal -jamesmurdza/daytona -jenslys/opencode-gemini-auth -JRedeker/opencode-morph-fast-apply -JRedeker/opencode-shell-strategy -kdcokenny/ocx -kdcokenny/opencode-background-agents -kdcokenny/opencode-notify -kdcokenny/opencode-workspace -kdcokenny/opencode-worktree -login/device -mohak34/opencode-notifier -morhetz/gruvbox -mtymek/opencode-obsidian -NeuralNomadsAI/CodeNomad -nick-vi/opencode-type-inject -NickvanDyke/opencode.nvim -NoeFabris/opencode-antigravity-auth -nordtheme/nord -numman-ali/opencode-openai-codex-auth -olimorris/codecompanion.nvim -panta82/opencode-notificator -rebelot/kanagawa.nvim -remorses/kimaki -sainnhe/everforest -shekohex/opencode-google-antigravity-auth -shekohex/opencode-pty.git -spoons-and-mirrors/subtask2 -sudo-tee/opencode.nvim -supermemoryai/opencode-supermemory -Tarquinen/opencode-dynamic-context-pruning -Th3Whit3Wolf/one-nvim -upstash/context7 -vtemian/micode -vtemian/octto -yetone/avante.nvim -zenobi-us/opencode-plugin-template -zenobi-us/opencode-skillful -``` - -## Paths, filenames, globs, and URLs - -```text -./.opencode/themes/*.json -.//storage/ -./config/#custom-directory -./global/storage/ -.agents/skills/*/SKILL.md -.agents/skills//SKILL.md -.clang-format -.claude -.claude/skills -.claude/skills/*/SKILL.md -.claude/skills//SKILL.md -.env -.github/workflows/opencode.yml -.gitignore -.gitlab-ci.yml -.ignore -.NET SDK -.npmrc -.ocamlformat -.opencode -.opencode/ -.opencode/agents/ -.opencode/commands/ -.opencode/commands/test.md -.opencode/modes/ -.opencode/plans/*.md -.opencode/plugins/ -.opencode/skills//SKILL.md -.opencode/skills/git-release/SKILL.md -.opencode/tools/ -.well-known/opencode -{ type: "raw" \| "patch", content: string } -{file:path/to/file} -**/*.js -%USERPROFILE%/intelephense/license.txt -%USERPROFILE%\.cache\opencode -%USERPROFILE%\.config\opencode\opencode.jsonc -%USERPROFILE%\.config\opencode\plugins -%USERPROFILE%\.local\share\opencode -%USERPROFILE%\.local\share\opencode\log -/.opencode/themes/*.json -/ -/.opencode/plugins/ -~ -~/... -~/.agents/skills/*/SKILL.md -~/.agents/skills//SKILL.md -~/.aws/credentials -~/.bashrc -~/.cache/opencode -~/.cache/opencode/node_modules/ -~/.claude/CLAUDE.md -~/.claude/skills/ -~/.claude/skills/*/SKILL.md -~/.claude/skills//SKILL.md -~/.config/opencode -~/.config/opencode/AGENTS.md -~/.config/opencode/agents/ -~/.config/opencode/commands/ -~/.config/opencode/modes/ -~/.config/opencode/opencode.json -~/.config/opencode/opencode.jsonc -~/.config/opencode/plugins/ -~/.config/opencode/skills/*/SKILL.md -~/.config/opencode/skills//SKILL.md -~/.config/opencode/themes/*.json -~/.config/opencode/tools/ -~/.config/zed/settings.json -~/.local/share -~/.local/share/opencode/ -~/.local/share/opencode/auth.json -~/.local/share/opencode/log/ -~/.local/share/opencode/mcp-auth.json -~/.local/share/opencode/opencode.jsonc -~/.npmrc -~/.zshrc -~/code/ -~/Library/Application Support -~/projects/* -~/projects/personal/ -${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts -$HOME/intelephense/license.txt -$HOME/projects/* -$XDG_CONFIG_HOME/opencode/themes/*.json -agent/ -agents/ -build/ -commands/ -dist/ -http://:4096 -http://127.0.0.1:8080/callback -http://localhost: -http://localhost:4096 -http://localhost:4096/doc -https://app.example.com -https://AZURE_COGNITIVE_SERVICES_RESOURCE_NAME.cognitiveservices.azure.com/ -https://opencode.ai/zen/v1/chat/completions -https://opencode.ai/zen/v1/messages -https://opencode.ai/zen/v1/models/gemini-3-flash -https://opencode.ai/zen/v1/models/gemini-3-pro -https://opencode.ai/zen/v1/responses -https://RESOURCE_NAME.openai.azure.com/ -laravel/pint -log/ -model: "anthropic/claude-sonnet-4-5" -modes/ -node_modules/ -openai/gpt-4.1 -app.kilo.ai/config.json -opencode/ -opencode/gpt-5.1-codex -opencode/gpt-5.2-codex -opencode/kimi-k2 -openrouter/google/gemini-2.5-flash -opncd.ai/s/ -packages/*/AGENTS.md -plugins/ -project/ -provider_id/model_id -provider/model -provider/model-id -rm -rf ~/.cache/opencode -skills/ -skills/*/SKILL.md -src/**/*.ts -themes/ -tools/ -``` - -## Keybind strings - -```text -alt+b -Alt+Ctrl+K -alt+d -alt+f -Cmd+Esc -Cmd+Option+K -Cmd+Shift+Esc -Cmd+Shift+G -Cmd+Shift+P -ctrl+a -ctrl+b -ctrl+d -ctrl+e -Ctrl+Esc -ctrl+f -ctrl+g -ctrl+k -Ctrl+Shift+Esc -Ctrl+Shift+P -ctrl+t -ctrl+u -ctrl+w -ctrl+x -DELETE -Shift+Enter -WIN+R -``` - -## Model ID strings referenced - -```text -{env:KILO_MODEL} -anthropic/claude-3-5-sonnet-20241022 -anthropic/claude-haiku-4-20250514 -anthropic/claude-haiku-4-5 -anthropic/claude-sonnet-4-20250514 -anthropic/claude-sonnet-4-5 -gitlab/duo-chat-haiku-4-5 -lmstudio/google/gemma-3n-e4b -openai/gpt-4.1 -openai/gpt-5 -opencode/gpt-5.1-codex -opencode/gpt-5.2-codex -opencode/kimi-k2 -openrouter/google/gemini-2.5-flash -``` diff --git a/.opencode/command/translate.md b/.opencode/command/translate.md new file mode 100644 index 00000000000..ed185b1e289 --- /dev/null +++ b/.opencode/command/translate.md @@ -0,0 +1,14 @@ +--- +description: translate English to other languages +model: opencode/claude-opus-4-7 +--- + +run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time. + +Requirements: + +- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure). +- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks. +- Also preserve every term listed in the Do-Not-Translate glossary below. +- Also apply locale-specific guidance from `.opencode/glossary/.md` when available (for example, `zh-cn.md`). +- Do not modify fenced code blocks. diff --git a/.opencode/tool/github-pr-search.ts b/.opencode/tool/github-pr-search.ts index c2fad2402a7..3e5365892d3 100644 --- a/.opencode/tool/github-pr-search.ts +++ b/.opencode/tool/github-pr-search.ts @@ -8,7 +8,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, Accept: "application/vnd.github+json", "Content-Type": "application/json", - ...options.headers, + ...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers), }, }) if (!response.ok) { diff --git a/.opencode/tool/github-triage.ts b/.opencode/tool/github-triage.ts index 1471ec33241..2f8635826fa 100644 --- a/.opencode/tool/github-triage.ts +++ b/.opencode/tool/github-triage.ts @@ -28,7 +28,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) { Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, Accept: "application/vnd.github+json", "Content-Type": "application/json", - ...options.headers, + ...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers), }, }) if (!response.ok) { diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 00000000000..f1ca1ff46f3 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json", + "options": { + "typeAware": true + }, + "categories": { + "suspicious": "warn" + }, + "rules": { + "typescript/no-base-to-string": "warn", + // Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield + "require-yield": "off", + // SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime + "no-unassigned-vars": "off", + // SolidJS tracks reactive deps by reading properties inside createEffect + "no-unused-expressions": "off", + // Intentional control char matching (ANSI escapes, null byte sanitization) + "no-control-regex": "off", + // SST and plugin tools require triple-slash references + "triple-slash-reference": "off", + + // Suspicious category: suppress noisy rules + // Effect's nested function* closures inherently shadow outer scope + "no-shadow": "off", + // Namespace-heavy codebase makes this too noisy + "unicorn/consistent-function-scoping": "off", + // Opinionated — .sort()/.reverse() mutation is fine in this codebase + "unicorn/no-array-sort": "off", + "unicorn/no-array-reverse": "off", + // Not relevant — this isn't a DOM event handler codebase + "unicorn/prefer-add-event-listener": "off", + // Bundler handles module resolution + "unicorn/require-module-specifiers": "off", + // postMessage target origin not relevant for this codebase + "unicorn/require-post-message-target-origin": "off", + // Side-effectful constructors are intentional in some places + "no-new": "off", + + // Type-aware: catch unhandled promises + "typescript/no-floating-promises": "warn", + // Warn when spreading non-plain objects (Headers, class instances, etc.) + "typescript/no-misused-spread": "warn" + }, + "options": { + "typeAware": true + }, + "options": { + "typeAware": true + }, + "ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"] +} diff --git a/.prettierignore b/.prettierignore index c3fb42a4654..fd44c32aa5d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,10 @@ packages/desktop/src/bindings.ts + +# Markdown is excluded because prettier re-pads markdown table cells for column +# alignment, which creates large spurious diffs on any unrelated content change. +# See AGENTS.md "Markdown Tables" and script/check-md-table-padding.ts. +*.md + +packages/opencode/src/provider/models-snapshot.ts +packages/opencode/src/provider/models-snapshot.js +packages/opencode/src/provider/models-snapshot.d.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 1895004b52d..4e46621d9cd 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -2,6 +2,7 @@ "recommendations": [ "dbaeumer.vscode-eslint", "connor4312.esbuild-problem-matchers", - "ms-vscode.extension-test-runner" + "ms-vscode.extension-test-runner", + "mkhl.direnv" ] } diff --git a/AGENTS.md b/AGENTS.md index 3ff62660676..2c726e9a1cf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,43 +14,61 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang - **Extension**: `bun run extension` (build + launch VS Code with the extension in dev mode). Pass `--no-build` to skip the build. - **Typecheck**: `bun turbo typecheck` (uses `tsgo`, not `tsc`) - **Test**: `bun test` from `packages/opencode/` (NOT from root -- root blocks tests) -- **Single test**: `bun test test/tool/tool.test.ts` from `packages/opencode/` +- **Single test**: `bun test ./test/tool/tool-define.test.ts` from `packages/opencode/` +- **CLI build artifact size check**: after `bun run script/build.ts --single --skip-install` in `packages/opencode/`, use `du -h dist/*/*/bin/kilo` (scoped package output lives under `dist/@kilocode/`) - **SDK regen**: After changing server endpoints in `packages/opencode/src/server/`, run `./script/generate.ts` from root to regenerate `packages/sdk/js/` - **Knip** (unused exports): `bun run knip` from `packages/kilo-vscode/`. CI runs this — all exported types/functions must be imported somewhere. Remove or unexport unused exports before pushing. - **Source links**: After adding or changing URLs in `packages/kilo-vscode/`, `packages/kilo-vscode/webview-ui/`, or `packages/opencode/src/`, run `bun run script/extract-source-links.ts` from the repo root and commit the updated `packages/kilo-docs/source-links.md`. CI runs this check — the build fails if the file is stale. - **kilocode_change check**: `bun run check-kilocode-change` from `packages/kilo-vscode/`. CI runs this — `kilocode_change` is a marker for upstream merge conflicts and must not appear in `packages/kilo-vscode/` or `packages/kilo-ui/` (these are entirely Kilo Code additions). Remove the markers before pushing. - **opencode annotation check**: `bun run script/check-opencode-annotations.ts` from repo root. CI runs this on PRs touching `packages/opencode/` — every Kilo-specific change in shared opencode files must be annotated with `kilocode_change` markers. Exempt paths (no markers needed): `packages/opencode/src/kilocode/`, `packages/opencode/test/kilocode/`, and any path containing `kilocode` in the name. +- **Backend/SDK programmatic testing**: see [TESTING.md](./TESTING.md) for spawning the local main-branch backend (`bun dev serve`) and driving it via `curl` — use this instead of `kilo serve` (prod binary) when testing backend fixes. + +## Quality Checks + +Before saying an implementation is ready, run the smallest relevant checks that can catch lint, typecheck, and test failures for the touched package. Do not rely on manual extension launch to discover build problems. Fix failures you introduced before the final response, or state exactly which check is still failing or could not be run. + +| Area | Checks | +|---|---| +| Root / cross-package | `bun run lint`, `bun run typecheck` | +| CLI | From `packages/opencode/`: `bun run typecheck`, `bun test` or targeted `bun test ./path/to/file.test.ts` | +| VS Code extension | From `packages/kilo-vscode/`: `bun run typecheck`, `bun run lint`, `bun run test:unit` or `bun run test` | +| Extension build/package | From `packages/kilo-vscode/`: `bun run compile` or `bun run package` when touching build, packaging, SDK, or webview integration paths | +| CI-only guards | Run affected guards documented above, such as `bun run knip`, `bun run check-kilocode-change`, source link extraction, or opencode annotation checks | + +Never run root `bun test`; the root script prints `do not run tests from root` and exits with code 1. Use package-level tests instead. ## Products All products are clients of the **CLI** (`packages/opencode/`), which contains the AI agent runtime, HTTP server, and session management. Each client spawns or connects to a `kilo serve` process and communicates via HTTP + SSE using `@kilocode/sdk`. -| Product | Package | Description | -| ---------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Kilo CLI | `packages/opencode/` | Core engine. TUI, `kilo run`, `kilo serve`, `kilo web`. Fork of upstream OpenCode. | +| Product | Package | Description | +|---|---|---| +| Kilo CLI | `packages/opencode/` | Core engine. TUI, `kilo run`, `kilo serve`, `kilo web`. Fork of upstream OpenCode. | | Kilo VS Code Extension | `packages/kilo-vscode/` | VS Code extension. Bundles the CLI binary, spawns `kilo serve` as a child process. Includes the **Agent Manager** — a multi-session orchestration panel with git worktree isolation. | -| OpenCode Desktop | `packages/desktop/` | Standalone Tauri native app. Bundles CLI as sidecar. Single-session UI. Unrelated to the VS Code extension. Not actively maintained — synced from upstream fork. | -| OpenCode Web | `packages/app/` | Shared SolidJS frontend used by both the desktop app and `kilo web` CLI command. Not actively maintained — synced from upstream fork. | +| OpenCode Desktop | `packages/desktop/` | Standalone Tauri native app. Bundles CLI as sidecar. Single-session UI. Unrelated to the VS Code extension. Not actively maintained — synced from upstream fork. | +| OpenCode Web | `packages/app/` | Shared SolidJS frontend used by both the desktop app and `kilo web` CLI command. Not actively maintained — synced from upstream fork. | **Agent Manager** refers to a feature inside `packages/kilo-vscode/` (extension code in `src/agent-manager/`, webview in `webview-ui/agent-manager/`). It is not a standalone product. See the extension's `AGENTS.md` for details. +Extension-specific settings should live in the Kilo extension settings, not default VS Code settings, unless they are intentionally VS Code-wide. + ## Monorepo Structure Turborepo + Bun workspaces. The packages you'll work with most: -| Package | Name | Purpose | -| -------------------------- | -------------------------- | ------------------------------------------------------------------------------------------ | -| `packages/opencode/` | `@kilocode/cli` | Core CLI -- agents, tools, sessions, server, TUI. This is where most work happens. | -| `packages/sdk/js/` | `@kilocode/sdk` | Auto-generated TypeScript SDK (client for the server API). Do not edit `src/gen/` by hand. | -| `packages/kilo-vscode/` | `kilo-code` | VS Code extension with sidebar chat + Agent Manager. See its own `AGENTS.md` for details. | -| `packages/kilo-gateway/` | `@kilocode/kilo-gateway` | Kilo auth, provider routing, API integration | -| `packages/kilo-telemetry/` | `@kilocode/kilo-telemetry` | PostHog analytics + OpenTelemetry | -| `packages/kilo-i18n/` | `@kilocode/kilo-i18n` | Internationalization / translations | -| `packages/kilo-ui/` | `@kilocode/kilo-ui` | SolidJS component library shared by the extension webview and `packages/app/` | -| `packages/app/` | `@opencode-ai/app` | Shared SolidJS web UI for desktop app and `kilo web` | -| `packages/desktop/` | `@opencode-ai/desktop` | Tauri desktop app shell | -| `packages/util/` | `@opencode-ai/util` | Shared utilities (error, path, retry, slug, etc.) | -| `packages/plugin/` | `@kilocode/plugin` | Plugin/tool interface definitions | +| Package | Name | Purpose | +|---|---|---| +| `packages/opencode/` | `@kilocode/cli` | Core CLI -- agents, tools, sessions, server, TUI. This is where most work happens. | +| `packages/sdk/js/` | `@kilocode/sdk` | Auto-generated TypeScript SDK (client for the server API). Do not edit `src/gen/` by hand. | +| `packages/kilo-vscode/` | `kilo-code` | VS Code extension with sidebar chat + Agent Manager. See its own `AGENTS.md` for details. | +| `packages/kilo-gateway/` | `@kilocode/kilo-gateway` | Kilo auth, provider routing, API integration | +| `packages/kilo-telemetry/` | `@kilocode/kilo-telemetry` | PostHog analytics + OpenTelemetry | +| `packages/kilo-i18n/` | `@kilocode/kilo-i18n` | Internationalization / translations | +| `packages/kilo-ui/` | `@kilocode/kilo-ui` | SolidJS component library shared by the extension webview and `packages/app/` | +| `packages/app/` | `@opencode-ai/app` | Shared SolidJS web UI for desktop app and `kilo web` | +| `packages/desktop/` | `@opencode-ai/desktop` | Tauri desktop app shell | +| `packages/util/` | `@opencode-ai/util` | Shared utilities (error, path, retry, slug, etc.) | +| `packages/plugin/` | `@kilocode/plugin` | Plugin/tool interface definitions | ## Style Guide @@ -167,6 +185,26 @@ const bazFoo = 3 You MUST avoid using `mocks` as much as possible. Tests MUST test actual implementation, do not duplicate logic into a test. +## Markdown Tables + +Do not pad markdown table cells for column alignment. Use the compact form with single-space-padded content cells and a minimal separator row: + +``` +| Command | What it runs | +|---|---| +| `kilo serve` | The prod CLI on `$PATH`. | +``` + +Do **not** right-pad cells to line up columns: + +``` +| Command | What it runs | +| ----------------------------- | ------------------------ | +| `kilo serve` | The prod CLI on `$PATH`. | +``` + +Padding makes every content change rewrite the entire table, which blows up diffs on untouched rows. Markdown files are excluded from prettier (see `.prettierignore`) so running the formatter won't re-pad them, and `script/check-md-table-padding.ts` enforces the rule in CI. Run `bun run script/check-md-table-padding.ts --fix` to auto-rewrite padded tables. + ## Commit Conventions [Conventional Commits](https://www.conventionalcommits.org/) with scopes matching packages: `vscode`, `cli`, `agent-manager`, `sdk`, `ui`, `i18n`, `kilo-docs`, `gateway`, `telemetry`, `desktop`. Omit scope when spanning multiple packages. @@ -181,6 +219,15 @@ Changeset descriptions appear directly in release notes and are read by end user PR descriptions should be 2-3 lines covering **what** changed and **why**. Focus on intent and context a reviewer can't get from the diff — skip file-by-file inventories, test result summaries, and anything obvious from the code itself. +## GitHub Issues + +- When creating a GitHub issue for the VS Code extension or JetBrains plugin, use the repo's existing issue templates in `.github/ISSUE_TEMPLATE/`. Pick the matching template (`Bug report`, `Feature Request`, or `Question`) instead of opening a blank issue. +- Do not add platform-specific title prefixes such as `[JetBrains]`, `[Jetbrains]`, `[JB]`, `[VS Code]`, `[VSCode]`, or similar. Use a plain, descriptive title. +- Always add VS Code extension issues to the GitHub project `VS Code Extension`: https://github.com/orgs/Kilo-Org/projects/25 +- Always add JetBrains plugin issues to the GitHub project `Jetbrains Plugin`: https://github.com/orgs/Kilo-Org/projects/39 +- When using `gh`, prefer `gh issue create --template "..." --project "..."` with the matching project title. +- If project assignment fails because `gh` is missing the required scope, run `gh auth refresh -s project` and retry. + ## Fork Merge Process Kilo CLI is a fork of [opencode](https://github.com/anomalyco/opencode). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d7950ddfe8f..520978df09e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ The Kilo Community is [on Discord](https://kilo.ai/discord). ## Developing Kilo CLI -- **Requirements:** Bun 1.3.10+ +- **Requirements:** Bun 1.3.13+ - Install dependencies and start the dev server from the repo root: ```bash @@ -48,6 +48,40 @@ To run Kilo CLI in the root of the repo itself: bun dev . ``` +### Running Kilo CLI from any folder + +`bin/kilodev` is a self-locating launcher that runs this checkout from wherever you invoke it. Running it with no arguments launches the TUI pointed at the caller's directory; any arguments are forwarded to the CLI unchanged. + +One-shot install (recommended). From the repo root: + +```bash +./bin/kilodev dev-setup +``` + +This detects your shell, shows exactly what it will add, asks for confirmation, writes an idempotent block to your rc file, and saves a timestamped backup of the original. Re-running is safe — it only rewrites when the snippet has changed. + +Useful flags: + +- `--yes` — skip the confirmation prompt (good for CI/containers). +- `--print` — just print the snippet, don't touch any file (pipe-friendly). +- `--dry-run` — show what would change without writing. +- `--shell ` — override shell detection. +- `--rc ` — override the rc file. + +Manual alternatives (equivalent, no CLI invocation needed): + +- Unix: add `alias kilodev='/path/to/kilocode/bin/kilodev'` to `~/.zshrc` / `~/.bashrc`, or `fish_add_path /path/to/kilocode/bin`. +- Windows: add `C:\path\to\kilocode\bin` to PATH (System Environment Variables), or add `function kilodev { & "C:\path\to\kilocode\bin\kilodev.cmd" @args }` to `$PROFILE`. + +Then from anywhere: + +```bash +cd ~/some/project +kilodev # opens TUI with project = ~/some/project +kilodev dev-setup --print # prints the alias line (scripting) +kilodev run --dir "$PWD" "…" # subcommands pass through; use --dir for run/serve +``` + ### Building a "local" binary To compile a standalone executable: @@ -90,26 +124,64 @@ This redirects all gateway traffic (auth, model listing, provider routing, profi There are also optional overrides for other services: -| Variable | Default | Purpose | -| ------------------------- | -------------------------------- | ----------------------------------------- | -| `KILO_API_URL` | `https://api.kilo.ai` | Kilo API (gateway, auth, models, profile) | -| `KILO_SESSION_INGEST_URL` | `https://ingest.kilosessions.ai` | Session export / cloud sync | -| `KILO_MODELS_URL` | `https://models.dev` | Model metadata | +| Variable | Default | Purpose | +|---|---|---| +| `KILO_API_URL` | `https://api.kilo.ai` | Kilo API (gateway, auth, models, profile) | +| `KILO_SESSION_INGEST_URL` | `https://ingest.kilosessions.ai` | Session export / cloud sync | +| `KILO_MODELS_URL` | `https://models.dev` | Model metadata | > **VS Code:** The repo includes a "VSCode - Run Extension (Local Backend)" launch config in `.vscode/launch.json` that sets `KILO_API_URL=http://localhost:3000` automatically. -### Pull Request Expectations +## Issue Template Requirements + +If you open an issue through the GitHub web UI, GitHub will guide you through the correct template automatically. + +If you open an issue through `gh issue create`, the API, or another tool that bypasses the web UI, include the equivalent required fields yourself so the issue still matches the template. + +Current required fields by issue type: + +- **Bug report:** include a `Description`. +- **Feature request:** prefix the title with `[FEATURE]:`, include confirmation that the feature has not already been suggested, and add a description of the enhancement. +- **Question:** include the `Question`. + +Recommended fields for bug reports, even when not strictly required by the template: + +- Plugins +- Kilo version +- Steps to reproduce +- Screenshot and/or share link +- Operating System +- Terminal + +## Pull Request Expectations - **Issue First Policy:** All PRs must reference an existing issue. - **UI Changes:** Include screenshots or videos (before/after). - **Logic Changes:** Explain how you verified it works. - **PR Titles:** Follow conventional commit standards (`feat:`, `fix:`, `docs:`, etc.). -### Issue and PR Lifecycle +## Issue First Policy + +All pull requests must reference an existing issue. + +This helps reviewers understand the problem statement, discussion, and intended scope before reviewing the code change. + +## PR Titles + +Use conventional commit style PR titles such as: + +- `feat: add MCP settings tab` +- `fix: correct Windows path handling` +- `docs: clarify issue template requirements` +- `chore: bump TypeScript to 5.8` +- `refactor: extract diff renderer into a hook` +- `test: cover ServerManager orphan cleanup` + +## Issue and PR Lifecycle To keep our backlog manageable, we automatically close inactive issues and PRs after a period of inactivity. This isn't a judgment on quality — older items tend to lose context over time and we'd rather start fresh if they're still relevant. Feel free to reopen or create a new issue/PR if you're still working on something! -### Style Preferences +## Style Preferences - **Functions:** Keep logic within a single function unless breaking it out adds clear reuse. - **Destructuring:** Avoid unnecessary destructuring. diff --git a/README.md b/README.md index 267ed942fb8..5d48917f89c 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,11 @@ Reddit

-# 🚀 Kilo +

+ kilo-code-logo +

> Kilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent. -> [#1 coding agent on OpenRouter](https://openrouter.ai/apps/category/coding). 1.5M+ Kilo Coders. 25T+ tokens processed - ✨ Generate code from natural language - ✅ Checks its own work diff --git a/RELEASING.md b/RELEASING.md index 437d34b1926..c6b3d4fb1da 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -63,7 +63,7 @@ Downloads all build artifacts and publishes to every distribution channel: - Publishes platform-specific binary packages to **npm** (e.g. `@kilocode/cli-linux-x64`, `@kilocode/cli-darwin-arm64`, etc.). - Publishes the main `@kilocode/cli` package to **npm** with optional dependencies on the binary packages. -- Builds and pushes a multi-arch **Docker image** (`ghcr.io/kilo-org/kilo`) to GitHub Container Registry (linux/amd64 + linux/arm64). +- Builds and pushes a multi-arch **Docker image** (`ghcr.io/kilo-org/kilocode`) to GitHub Container Registry (linux/amd64 + linux/arm64). #### SDK (`@kilocode/sdk`) @@ -102,16 +102,16 @@ The workflow requires these GitHub token permissions: The following secrets must be configured in the repository: -| Secret | Purpose | -| ---------------------------- | ---------------------------------------------------------------- | -| `KILO_API_KEY` | Kilo API key used during version computation | -| `KILO_ORG_ID` | Kilo organization ID | -| `KILO_MAINTAINER_APP_ID` | GitHub App ID for the kilo-maintainer bot (used for git commits) | -| `KILO_MAINTAINER_APP_SECRET` | GitHub App secret for the kilo-maintainer bot | -| `NPM_TOKEN` | npm authentication token for publishing packages | -| `VSCE_TOKEN` | VS Code Marketplace personal access token | -| `OVSX_TOKEN` | Open VSX Registry token (currently unused but configured) | -| `AUR_KEY` | SSH private key for pushing to the AUR | +| Secret | Purpose | +|---|---| +| `KILO_API_KEY` | Kilo API key used during version computation | +| `KILO_ORG_ID` | Kilo organization ID | +| `KILO_MAINTAINER_APP_ID` | GitHub App ID for the kilo-maintainer bot (used for git commits) | +| `KILO_MAINTAINER_APP_SECRET` | GitHub App secret for the kilo-maintainer bot | +| `NPM_TOKEN` | npm authentication token for publishing packages | +| `VSCE_TOKEN` | VS Code Marketplace personal access token | +| `OVSX_TOKEN` | Open VSX Registry token (currently unused but configured) | +| `AUR_KEY` | SSH private key for pushing to the AUR | ### Concurrency diff --git a/SECURITY.md b/SECURITY.md index aca8b556832..96998a024f3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -24,13 +24,13 @@ Server mode is opt-in only. When enabled, set `KILO_SERVER_PASSWORD` to require ### Out of Scope -| Category | Rationale | -| ------------------------------- | ----------------------------------------------------------------------- | -| **Server access when opted-in** | If you enable server mode, API access is expected behavior | -| **Sandbox escapes** | The permission system is not a sandbox (see above) | -| **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies | -| **MCP server behavior** | External MCP servers you configure are outside our trust boundary | -| **Malicious config files** | Users control their own config; modifying it is not an attack vector | +| Category | Rationale | +|---|---| +| **Server access when opted-in** | If you enable server mode, API access is expected behavior | +| **Sandbox escapes** | The permission system is not a sandbox (see above) | +| **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies | +| **MCP server behavior** | External MCP servers you configure are outside our trust boundary | +| **Malicious config files** | Users control their own config; modifying it is not an attack vector | --- diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 00000000000..5d17ca66d6d --- /dev/null +++ b/TESTING.md @@ -0,0 +1,259 @@ +# TESTING.md + +How to spin up the **local main-branch** Kilo backend and test it with `curl` / `fetch`. Aimed at a running Kilo CLI agent iterating on backend fixes without rebuilding the VS Code extension, TUI, or desktop app. + +All examples use plain shell + `curl`. Writing TypeScript files is a last resort (see Section 8). + +## TL;DR + +```bash +# From repo root. Starts the LOCAL main-branch backend in the background. +PASS=$(openssl rand -hex 16) +KILO_SERVER_PASSWORD="$PASS" bun dev serve --port 0 >/tmp/kilo-serve.log 2>&1 & +echo $! >/tmp/kilo-serve.pid +while ! grep -q "kilo server listening" /tmp/kilo-serve.log 2>/dev/null; do sleep 0.1; done +PORT=$(grep -oE "listening on http://[^:]+:[0-9]+" /tmp/kilo-serve.log | grep -oE "[0-9]+$") +AUTH="Authorization: Basic $(printf 'kilo:%s' "$PASS" | base64 | tr -d '\n')" +BASE="http://127.0.0.1:$PORT" + +# Call any endpoint +curl -sS -H "$AUTH" -H "x-kilo-directory: $PWD" "$BASE/global/health" + +# Stop when done +kill "$(cat /tmp/kilo-serve.pid)" 2>/dev/null || true +rm -f /tmp/kilo-serve.pid /tmp/kilo-serve.log +``` + +## 1. What this doc is for + +Testing local backend fixes against a real running server, talking to it over HTTP the same way the VS Code extension, TUI, and `kilo run --attach` do — but without any of those clients. Every request is a `curl` the agent can copy-paste. + +For in-process tests (no socket, fastest loop) see `packages/opencode/test/kilocode/server/permission-allow-everything.test.ts` for the `Server.Default().app.request(...)` pattern. That's the right tool inside the `packages/opencode/` test suite; this doc is for out-of-process HTTP testing. + +## 2. `kilo serve` vs `bun dev serve` — important + +| Command | What it runs | +|---|---| +| `kilo serve` | The npm-installed production CLI on `$PATH`. **Not the code in this repo.** | +| `bun dev serve …` (repo root) | The local main-branch backend from this worktree. **This is what you want.** | +| `bun run --cwd packages/opencode --conditions=browser src/index.ts serve …` | Same as `bun dev serve`, fully expanded. | + +Root `package.json` defines `"dev"` as the full `bun run --cwd packages/opencode --conditions=browser src/index.ts` invocation, so `bun dev ` forwards `` to the local CLI entry point (`packages/opencode/src/index.ts`) without touching the installed binary. + +`bun dev` imports the source directly — no rebuild is needed between code edits. Just kill the running server and relaunch. + +Do **not** use `createKiloServer()` from `@kilocode/sdk/v2` to test local code: it spawns the PATH `kilo` binary (`packages/sdk/js/src/v2/server.ts:38-136`), which is the wrong tool here. + +## 3. Starting the backend (background) + +### Random port (recommended) + +```bash +KILO_SERVER_PASSWORD=$(openssl rand -hex 16) \ + bun dev serve --port 0 >/tmp/kilo-serve.log 2>&1 & +echo $! >/tmp/kilo-serve.pid +while ! grep -q "kilo server listening" /tmp/kilo-serve.log; do sleep 0.1; done +PORT=$(grep -oE "listening on http://[^:]+:[0-9]+" /tmp/kilo-serve.log | grep -oE "[0-9]+$") +``` + +### Fixed port (if you need a stable URL) + +```bash +KILO_SERVER_PASSWORD=secret \ + bun dev serve --port 4096 --hostname 127.0.0.1 \ + >/tmp/kilo-serve.log 2>&1 & +echo $! >/tmp/kilo-serve.pid +while ! grep -q "kilo server listening" /tmp/kilo-serve.log; do sleep 0.1; done +PORT=4096 +``` + +### No-auth quickstart (fastest) + +Omit `KILO_SERVER_PASSWORD` entirely. The server prints `Warning: KILO_SERVER_PASSWORD is not set; server is unsecured.` and the auth middleware is bypassed — fine for throwaway local testing, never for anything else. + +```bash +bun dev serve --port 0 >/tmp/kilo-serve.log 2>&1 & +echo $! >/tmp/kilo-serve.pid +while ! grep -q "kilo server listening" /tmp/kilo-serve.log; do sleep 0.1; done +PORT=$(grep -oE "listening on http://[^:]+:[0-9]+" /tmp/kilo-serve.log | grep -oE "[0-9]+$") +BASE="http://127.0.0.1:$PORT" +# no AUTH var needed +``` + +### Flags (`packages/opencode/src/cli/network.ts`) + +| Flag | Default | Notes | +|---|---|---| +| `--port` | `0` (OS-assigned) | Must be passed literally when overriding `opencode.json`'s `server.port`. | +| `--hostname` | `127.0.0.1` | Becomes `0.0.0.0` when `--mdns` is set without an override. | +| `--mdns` | `false` | Publishes an mDNS SRV record. | +| `--mdns-domain` | `kilo.local` | | +| `--cors` | `[]` | Extra allowed origins. | + +## 4. The two mandatory request knobs + +### Auth header (only if `KILO_SERVER_PASSWORD` was set) + +```bash +AUTH="Authorization: Basic $(printf 'kilo:%s' "$KILO_SERVER_PASSWORD" | base64 | tr -d '\n')" +``` + +The username is literally `kilo` (same as the VS Code extension). Skip this whole block if you launched without a password. + +### Directory header on every call + +```bash +DIR_HEADER="x-kilo-directory: $PWD" +``` + +`InstanceMiddleware` uses this to scope the request to a project. For `GET` / `HEAD`, pass `?directory=` in the URL instead — that's what the SDK does internally. + +## 5. Common `curl` recipes + +All of the below assume `BASE`, `AUTH`, `DIR_HEADER` are set. Drop `-H "$AUTH"` if you're running without a password. + +### Health check (no auth, no directory) + +```bash +curl -sS "$BASE/global/health" +``` + +### Full endpoint list (OpenAPI spec) + +```bash +curl -sS "$BASE/doc" | jq . +``` + +This is the source of truth — anything not in this doc is discoverable from `/doc` without reading source. + +### Create a session + +```bash +SID=$(curl -sS -X POST "$BASE/session" \ + -H "$AUTH" -H "$DIR_HEADER" -H "Content-Type: application/json" \ + -d '{}' | jq -r .id) +echo "$SID" +``` + +### List sessions (GET — directory goes in the query) + +```bash +curl -sS -H "$AUTH" \ + "$BASE/session?directory=$(printf %s "$PWD" | jq -sRr @uri)" +``` + +### Send a message (fire-and-forget) + +```bash +curl -sS -X POST "$BASE/session/$SID/prompt_async" \ + -H "$AUTH" -H "$DIR_HEADER" -H "Content-Type: application/json" \ + -d '{"parts":[{"type":"text","text":"hello"}]}' +``` + +### Read messages for a session + +```bash +curl -sS -H "$AUTH" \ + "$BASE/session/$SID/message?directory=$(printf %s "$PWD" | jq -sRr @uri)" +``` + +### Abort an in-flight prompt + +```bash +curl -sS -X POST -H "$AUTH" -H "$DIR_HEADER" "$BASE/session/$SID/abort" +``` + +### Get the resolved config (verify a config change took effect) + +```bash +curl -sS -H "$AUTH" \ + "$BASE/config?directory=$(printf %s "$PWD" | jq -sRr @uri)" +``` + +### Stream global events (SSE) + +```bash +curl -N -sS -H "$AUTH" \ + "$BASE/global/event?directory=$(printf %s "$PWD" | jq -sRr @uri)" +``` + +`-N` disables curl's output buffering so events appear live. Expect lines like `data: {"directory":"…","payload":{"type":"…",…}}`. + +## 6. Stopping the backend + +```bash +kill "$(cat /tmp/kilo-serve.pid)" 2>/dev/null || true +rm -f /tmp/kilo-serve.pid /tmp/kilo-serve.log +``` + +`ServeCommand` handles `SIGTERM` / `SIGINT` / `SIGHUP` and runs `Instance.disposeAll()` + `server.stop(true)` before exiting (`packages/opencode/src/cli/cmd/serve.ts:29-31`). `-9` is only needed if the process hangs past ~5 s. + +## 7. Useful environment variables + +| Var | Why you'd set it | +|---|---| +| `KILO_SERVER_PASSWORD` | Enable Basic auth. Omit for auth-bypassed local testing. | +| `KILO_DB=":memory:"` | Skip on-disk SQLite — hermetic runs. | +| `KILO_DISABLE_DEFAULT_PLUGINS=true` | Don't auto-load bundled plugins. | +| `KILO_WORKSPACE_ID=` | Single-workspace mode; disables control-plane routes. | +| `KILO_TELEMETRY_LEVEL=off` | Disable PostHog during tests. | +| `KILO_CONFIG_CONTENT='{…}'` | Inline JSON config without writing a file. | + +## 8. Last resort: typed SDK via a throwaway script + +Use this only when `curl` can't express what you need — typed request/response shapes, complex multi-turn orchestration, SSE consumers that need to coalesce events. Keep the file short and delete it after. + +```ts +// /tmp/probe.ts — delete after use. Talks to an already-running backend. +import { createKiloClient } from "@kilocode/sdk/v2" + +const port = process.env.PORT! +const pass = process.env.KILO_SERVER_PASSWORD +const headers = pass ? { Authorization: "Basic " + Buffer.from("kilo:" + pass).toString("base64") } : undefined + +const client = createKiloClient({ + baseUrl: `http://127.0.0.1:${port}`, + headers, + directory: process.cwd(), +}) + +const { data: session } = await client.session.create({}, { throwOnError: true }) +await client.session.promptAsync({ + sessionID: session.id, + parts: [{ type: "text", text: "hello" }], +}) + +const events = await client.global.event({}) +for await (const ev of events.stream) { + const e = ev as { payload: { type: string } } + console.log(e.payload.type) + if (e.payload.type === "session.idle") break +} +``` + +```bash +PORT="$PORT" KILO_SERVER_PASSWORD="$KILO_SERVER_PASSWORD" bun /tmp/probe.ts +rm /tmp/probe.ts +``` + +Reminder: this script **connects to** the server you launched in Section 3 — it does not start one. `createKiloServer()` from the SDK would spawn the PATH `kilo` binary (production CLI), which defeats the point of testing local code. + +## 9. Pitfalls + +- Running `kilo serve` instead of `bun dev serve` runs the installed prod binary, not your edits. +- Missing `x-kilo-directory` (or `?directory=`) returns `400` from `InstanceMiddleware`. +- `curl` without `-N` buffers SSE output — you won't see events until the connection closes. +- Hardcoding port `4096` breaks when a previous run didn't exit cleanly. Parse the log instead. +- `--port` must appear literally in `argv` to override `opencode.json`'s `server.port` (`packages/opencode/src/cli/network.ts:45`). +- `KILO_SERVER_PASSWORD` must be set **before** launch — changing it after doesn't rotate credentials. +- When sharing `/tmp/kilo-serve.log` / `.pid` across terminals, unique-suffix the paths to avoid clobbering parallel runs. + +## 10. After changing server routes + +Regenerate the SDK and OpenAPI spec so `/doc` and typed clients stay in sync: + +```bash +./script/generate.ts # from repo root +``` + +See `AGENTS.md` for the full rationale. diff --git a/bin/kilodev b/bin/kilodev new file mode 100755 index 00000000000..746f0b64072 --- /dev/null +++ b/bin/kilodev @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -e +dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +export KILO_DEV_REPO="$dir" +# Find the first non-flag arg. If there isn't one, the user is launching the +# TUI, so point it at the caller's directory via the project positional. +# Otherwise forward args untouched so subcommands (run, dev-alias, ...) don't +# collide with --project (only TUI/stats accept --project under yargs strict). +first= +for a in "$@"; do + case "$a" in + --) break ;; + -*) ;; + *) first="$a"; break ;; + esac +done +if [[ -z "$first" ]]; then + exec bun run --cwd "$dir/packages/opencode" --conditions=browser src/index.ts --project "$PWD" "$@" +fi +exec bun run --cwd "$dir/packages/opencode" --conditions=browser src/index.ts "$@" diff --git a/bin/kilodev.cmd b/bin/kilodev.cmd new file mode 100644 index 00000000000..a3706d672fd --- /dev/null +++ b/bin/kilodev.cmd @@ -0,0 +1,21 @@ +@echo off +setlocal enabledelayedexpansion +set "dir=%~dp0.." +set "KILO_DEV_REPO=%dir%" +rem Find the first non-flag arg. If there isn't one, the user is launching the +rem TUI, so point it at the caller's directory via --project. Otherwise +rem forward untouched so subcommands don't collide with --project. +set "first=" +for %%a in (%*) do ( + if not defined first ( + set "arg=%%~a" + if "!arg:~0,1!" neq "-" set "first=%%~a" + ) +) +if not defined first ( + bun run --cwd "%dir%\packages\opencode" --conditions=browser src/index.ts --project "%CD%" %* +) else ( + bun run --cwd "%dir%\packages\opencode" --conditions=browser src/index.ts %* +) +set "code=%ERRORLEVEL%" +endlocal & exit /b %code% diff --git a/bun.lock b/bun.lock index 47e7ad3b26f..be06087afee 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,8 @@ "@typescript/native-preview": "catalog:", "glob": "13.0.5", "husky": "9.1.7", + "oxlint": "1.60.0", + "oxlint-tsgolint": "0.21.0", "prettier": "3.6.2", "semver": "^7.6.0", "sst": "3.18.10", @@ -30,14 +32,14 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.2.14", + "version": "7.2.30", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", + "@opencode-ai/shared": "workspace:*", "@opencode-ai/ui": "workspace:*", - "@opencode-ai/util": "workspace:*", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", @@ -86,7 +88,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.2.14", + "version": "7.2.30", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -119,7 +121,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.2.14", + "version": "7.2.30", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -127,6 +129,7 @@ "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@solidjs/router": "0.15.4", + "drizzle-orm": "catalog:", "effect": "catalog:", "electron-context-menu": "4.1.2", "electron-log": "^5", @@ -150,7 +153,7 @@ "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "@valibot/to-json-schema": "1.6.0", - "electron": "40.8.5", + "electron": "41.2.1", "electron-builder": "^26", "electron-vite": "^5", "solid-js": "catalog:", @@ -170,7 +173,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.14", + "version": "7.2.30", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -199,7 +202,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.14", + "version": "7.2.30", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -208,7 +211,7 @@ "@clack/prompts": "1.0.0-alpha.1", "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", - "@openrouter/ai-sdk-provider": "2.5.1", + "@openrouter/ai-sdk-provider": "2.8.1", "ai": "catalog:", "open": "10.1.2", "zod": "catalog:", @@ -235,7 +238,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.2.14", + "version": "7.2.30", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -243,20 +246,51 @@ "typescript": "catalog:", }, }, + "packages/kilo-indexing": { + "name": "@kilocode/kilo-indexing", + "version": "7.2.30", + "dependencies": { + "@aws-sdk/client-bedrock-runtime": "3.1005.0", + "@aws-sdk/credential-provider-ini": "3.972.31", + "@kilocode/kilo-gateway": "workspace:*", + "@kilocode/plugin": "workspace:*", + "@lancedb/lancedb": "0.26.2", + "@qdrant/js-client-rest": "1.17.0", + "async-mutex": "0.5.0", + "chokidar": "4.0.3", + "glob": "13.0.6", + "hono": "catalog:", + "hono-openapi": "catalog:", + "ignore": "7.0.5", + "minimatch": "10.2.5", + "openai": "6.27.0", + "p-limit": "7.3.0", + "tree-sitter-wasms": "0.1.13", + "uuid": "14.0.0", + "web-tree-sitter": "0.25.10", + "zod": "catalog:", + }, + "devDependencies": { + "@tsconfig/node22": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:", + }, + }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.2.14", + "version": "7.2.30", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "@opentelemetry/api": "1.9.0", - "@opentelemetry/core": "1.30.1", - "@opentelemetry/resources": "1.30.1", - "@opentelemetry/sdk-trace-base": "1.30.1", - "@opentelemetry/sdk-trace-node": "1.30.1", - "@opentelemetry/semantic-conventions": "1.28.0", + "@opentelemetry/core": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/sdk-trace-base": "2.6.1", + "@opentelemetry/sdk-trace-node": "2.6.1", + "@opentelemetry/semantic-conventions": "1.40.0", "posthog-node": "4.4.0", }, "devDependencies": { @@ -268,10 +302,10 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.14", + "version": "7.2.30", "dependencies": { "@kobalte/core": "0.13.11", - "@opencode-ai/util": "workspace:*", + "@opencode-ai/shared": "workspace:*", "@pierre/diffs": "catalog:", "@solid-primitives/lifecycle": "0.1.2", "@solid-primitives/media": "2.3.3", @@ -303,14 +337,20 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.14", + "version": "7.2.30", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", + "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-ui": "workspace:*", "@kilocode/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", "@thisbeyond/solid-dnd": "0.7.5", + "@xterm/addon-clipboard": "0.2.0", + "@xterm/addon-fit": "0.11.0", + "@xterm/addon-unicode-graphemes": "0.4.0", + "@xterm/addon-web-links": "0.12.0", + "@xterm/xterm": "6.0.0", "diff": "8.0.4", "dotenv": "^16.4.7", "fastest-levenshtein": "^1.0.16", @@ -358,7 +398,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.14", + "version": "7.2.30", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -374,7 +414,7 @@ "@ai-sdk/cerebras": "2.0.41", "@ai-sdk/cohere": "3.0.27", "@ai-sdk/deepinfra": "2.0.41", - "@ai-sdk/gateway": "3.0.97", + "@ai-sdk/gateway": "3.0.104", "@ai-sdk/google": "3.0.63", "@ai-sdk/google-vertex": "4.0.112", "@ai-sdk/groq": "3.0.31", @@ -393,11 +433,12 @@ "@effect/platform-node": "catalog:", "@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@hono/node-server": "1.19.11", + "@hono/node-server": "1.19.13", "@hono/node-ws": "1.3.0", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@kilocode/kilo-gateway": "workspace:*", + "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-telemetry": "workspace:*", "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", @@ -405,13 +446,14 @@ "@modelcontextprotocol/sdk": "1.29.0", "@morphllm/morphsdk": "0.2.166", "@npmcli/arborist": "9.4.0", + "@npmcli/config": "10.8.1", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", "@opencode-ai/script": "workspace:*", - "@opencode-ai/server": "workspace:*", - "@opencode-ai/util": "workspace:*", - "@openrouter/ai-sdk-provider": "2.5.1", + "@openrouter/ai-sdk-provider": "2.8.1", + "@opentelemetry/api": "1.9.0", + "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", @@ -436,14 +478,16 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.4.2", + "gitlab-ai-provider": "6.6.0", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", "hono": "catalog:", "hono-openapi": "catalog:", + "iconv-lite": "0.7.2", "ignore": "7.0.5", "immer": "11.1.4", + "jschardet": "3.1.4", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", "minimatch": "10.2.5", @@ -463,6 +507,7 @@ "strip-ansi": "7.1.2", "tree-sitter-bash": "0.25.0", "tree-sitter-powershell": "0.25.10", + "tree-sitter-wasms": "^0.1.12", "turndown": "7.2.0", "ulid": "catalog:", "venice-ai-sdk-provider": "2.0.1", @@ -479,6 +524,7 @@ "@effect/language-service": "0.84.2", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/script": "workspace:*", + "@opencode-ai/shared": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", @@ -510,7 +556,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.14", + "version": "7.2.30", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -525,8 +571,8 @@ "typescript": "catalog:", }, "peerDependencies": { - "@opentui/core": ">=0.1.99", - "@opentui/solid": ">=0.1.99", + "@opentui/core": ">=0.1.100", + "@opentui/solid": ">=0.1.100", }, "optionalPeers": [ "@opentui/core", @@ -535,7 +581,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.14", + "version": "7.2.30", "dependencies": { "semver": "^7.6.3", }, @@ -546,7 +592,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.14", + "version": "7.2.30", "dependencies": { "cross-spawn": "catalog:", }, @@ -559,19 +605,33 @@ "typescript": "catalog:", }, }, - "packages/server": { - "name": "@opencode-ai/server", - "version": "7.2.14", + "packages/shared": { + "name": "@opencode-ai/shared", + "version": "7.2.30", + "bin": { + "opencode": "./bin/opencode", + }, "dependencies": { + "@effect/platform-node": "catalog:", + "@npmcli/arborist": "catalog:", "effect": "catalog:", + "glob": "13.0.5", + "mime-types": "3.0.2", + "minimatch": "10.2.5", + "semver": "catalog:", + "xdg-basedir": "5.1.0", + "zod": "catalog:", }, "devDependencies": { - "typescript": "catalog:", + "@tsconfig/bun": "catalog:", + "@types/bun": "catalog:", + "@types/npmcli__arborist": "6.3.3", + "@types/semver": "catalog:", }, }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.14", + "version": "7.2.30", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -594,11 +654,11 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.14", + "version": "7.2.30", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", - "@opencode-ai/util": "workspace:*", + "@opencode-ai/shared": "workspace:*", "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", @@ -642,17 +702,6 @@ "vite-plugin-solid": "catalog:", }, }, - "packages/util": { - "name": "@opencode-ai/util", - "version": "7.2.14", - "dependencies": { - "zod": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, }, "trustedDependencies": [ "esbuild", @@ -664,6 +713,8 @@ ], "patchedDependencies": { "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", + "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch", }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.46", @@ -688,8 +739,11 @@ "@hono/zod-validator": "0.4.2", "@kobalte/core": "0.13.11", "@lydell/node-pty": "1.2.0-beta.10", + "@npmcli/arborist": "9.4.0", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", + "@opentui/core": "0.1.99", + "@opentui/solid": "0.1.99", "@pierre/diffs": "1.1.0-beta.18", "@playwright/test": "1.59.1", "@solid-primitives/storage": "4.3.3", @@ -699,13 +753,13 @@ "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.11", + "@types/bun": "1.3.12", "@types/cross-spawn": "6.0.6", "@types/luxon": "3.7.1", "@types/node": "22.13.9", "@types/semver": "7.7.1", "@typescript/native-preview": "7.0.0-dev.20260316.1", - "ai": "6.0.158", + "ai": "6.0.168", "cross-spawn": "7.0.6", "diff": "8.0.4", "dompurify": "3.3.3", @@ -720,6 +774,7 @@ "marked-shiki": "1.2.1", "remeda": "2.26.0", "remend": "1.3.0", + "semver": "7.7.4", "shiki": "3.20.0", "solid-js": "1.9.12", "solid-list": "0.3.0", @@ -772,7 +827,7 @@ "@ai-sdk/fireworks": ["@ai-sdk/fireworks@2.0.46", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XRKR0zgRyegdmtK5CDUEjlyRp0Fo+XVCdoG+301U1SGtgRIAYG3ObVtgzVJBVpJdHFSLHuYeLTnNiQoUxD7+FQ=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.97", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ERHmVGX30YKTwxObuHQzNqoOf8Nb5WwYMDBn34e3TGGVn0vLEXwMimo7uRVTbhhi4gfu9WtwYTE4x1+csZok1w=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="], "@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="], @@ -822,6 +877,8 @@ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1005.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/credential-provider-node": "^3.972.19", "@aws-sdk/eventstream-handler-node": "^3.972.10", "@aws-sdk/middleware-eventstream": "^3.972.7", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/middleware-websocket": "^3.972.12", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/token-providers": "3.1005.0", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/eventstream-serde-browser": "^4.2.11", "@smithy/eventstream-serde-config-resolver": "^4.3.11", "@smithy/eventstream-serde-node": "^4.2.11", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-retry": "^4.4.40", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.39", "@smithy/util-defaults-mode-node": "^4.2.42", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-stream": "^4.5.17", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-IV5vZ6H46ZNsTxsFWkbrJkg+sPe6+3m90k7EejgB/AFCb/YQuseH0+I3B57ew+zoOaXJU71KDPBwsIiMSsikVg=="], + "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1025.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.26", "@aws-sdk/credential-provider-node": "^3.972.29", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", "@aws-sdk/middleware-user-agent": "^3.972.28", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.14", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.13", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.28", "@smithy/middleware-retry": "^4.4.46", "@smithy/middleware-serde": "^4.2.16", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.1", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.44", "@smithy/util-defaults-mode-node": "^4.2.48", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.13", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ke7vyS7Dmo3St1a354AlpAjocZpG25Ql52XQ1AXRD3VQ791FBT7vX+EqGCfAEIWLPxpcldaW2Nny6P6klEOkkw=="], "@aws-sdk/client-s3": ["@aws-sdk/client-s3@3.1025.0", "", { "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.26", "@aws-sdk/credential-provider-node": "^3.972.29", "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", "@aws-sdk/middleware-expect-continue": "^3.972.8", "@aws-sdk/middleware-flexible-checksums": "^3.974.6", "@aws-sdk/middleware-host-header": "^3.972.8", "@aws-sdk/middleware-location-constraint": "^3.972.8", "@aws-sdk/middleware-logger": "^3.972.8", "@aws-sdk/middleware-recursion-detection": "^3.972.9", "@aws-sdk/middleware-sdk-s3": "^3.972.27", "@aws-sdk/middleware-ssec": "^3.972.8", "@aws-sdk/middleware-user-agent": "^3.972.28", "@aws-sdk/region-config-resolver": "^3.972.10", "@aws-sdk/signature-v4-multi-region": "^3.996.15", "@aws-sdk/types": "^3.973.6", "@aws-sdk/util-endpoints": "^3.996.5", "@aws-sdk/util-user-agent-browser": "^3.972.8", "@aws-sdk/util-user-agent-node": "^3.973.14", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.13", "@smithy/eventstream-serde-browser": "^4.2.12", "@smithy/eventstream-serde-config-resolver": "^4.3.12", "@smithy/eventstream-serde-node": "^4.2.12", "@smithy/fetch-http-handler": "^5.3.15", "@smithy/hash-blob-browser": "^4.2.13", "@smithy/hash-node": "^4.2.12", "@smithy/hash-stream-node": "^4.2.12", "@smithy/invalid-dependency": "^4.2.12", "@smithy/md5-js": "^4.2.12", "@smithy/middleware-content-length": "^4.2.12", "@smithy/middleware-endpoint": "^4.4.28", "@smithy/middleware-retry": "^4.4.46", "@smithy/middleware-serde": "^4.2.16", "@smithy/middleware-stack": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/node-http-handler": "^4.5.1", "@smithy/protocol-http": "^5.3.12", "@smithy/smithy-client": "^4.12.8", "@smithy/types": "^4.13.1", "@smithy/url-parser": "^4.2.12", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.44", "@smithy/util-defaults-mode-node": "^4.2.48", "@smithy/util-endpoints": "^3.3.3", "@smithy/util-middleware": "^4.2.12", "@smithy/util-retry": "^4.2.13", "@smithy/util-stream": "^4.5.21", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.14", "tslib": "^2.6.2" } }, "sha512-9Byz2fPnuGRRL8DTTD5bYPl1Iwm+ysLiCMgptffa3lNkVLCiUZc5e5TAaOjk0MvyeXieq+jn35AmQL6cgN2KHQ=="], @@ -850,8 +907,12 @@ "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1025.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1025.0", "@aws-sdk/core": "^3.973.26", "@aws-sdk/credential-provider-cognito-identity": "^3.972.21", "@aws-sdk/credential-provider-env": "^3.972.24", "@aws-sdk/credential-provider-http": "^3.972.26", "@aws-sdk/credential-provider-ini": "^3.972.28", "@aws-sdk/credential-provider-login": "^3.972.28", "@aws-sdk/credential-provider-node": "^3.972.29", "@aws-sdk/credential-provider-process": "^3.972.24", "@aws-sdk/credential-provider-sso": "^3.972.28", "@aws-sdk/credential-provider-web-identity": "^3.972.28", "@aws-sdk/nested-clients": "^3.996.18", "@aws-sdk/types": "^3.973.6", "@smithy/config-resolver": "^4.4.13", "@smithy/core": "^3.23.13", "@smithy/credential-provider-imds": "^4.2.12", "@smithy/node-config-provider": "^4.3.12", "@smithy/property-provider": "^4.2.12", "@smithy/types": "^4.13.1", "tslib": "^2.6.2" } }, "sha512-hOMHzYetTwnpvfbLN8emaw+nnQrqlEV0I5rTrgRKTAx1anzEvls/rD1IXwOvX8Z+B9mgbK+yNFqO3wQkBghI1g=="], + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/eventstream-codec": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-m4X56gxG76/CKfxNVbOFuYwnAZcHgS6HOH8lgp15HoGHIAVTcZfZrXvcYzJFOMLEJgVn+JHBu6EiNV+xSNXXFg=="], + "@aws-sdk/middleware-bucket-endpoint": ["@aws-sdk/middleware-bucket-endpoint@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-arn-parser": "^3.972.3", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Vbc2frZH7wXlMNd+ZZSXUEs/l1Sv8Jj4zUnIfwrYF5lwaLdXHZ9xx4U3rjUcaye3HRhFVc+E5DbBxpRAbB16BA=="], + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-QUqLs7Af1II9X4fCRAu+EGHG3KHyOp4RkuLhRKoA3NuFlh6TL8i+zXBl8w2LUxqm44B/Kom45hgSlwA1SpTsXQ=="], + "@aws-sdk/middleware-expect-continue": ["@aws-sdk/middleware-expect-continue@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-2Yn0f1Qiq/DjxYR3wfI3LokXnjOhFM7Ssn4LTdFDIxRMCE6I32MAsVnhPX1cUZsuVA9tiZtwwhlSLAtFGxAZlQ=="], "@aws-sdk/middleware-flexible-checksums": ["@aws-sdk/middleware-flexible-checksums@3.974.9", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/crc64-nvme": "^3.972.7", "@aws-sdk/types": "^3.973.8", "@smithy/is-array-buffer": "^4.2.2", "@smithy/node-config-provider": "^4.3.14", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-stream": "^4.5.23", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-ye6xVuMEQ5NCT+yQOryGYsuCXnOwu7iGFGzV+qpXZOWtqXIAAaFostapxj6RCubw36rekVwmdB2lcspFuyNfYQ=="], @@ -870,13 +931,15 @@ "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.31", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@smithy/core": "^3.23.15", "@smithy/protocol-http": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-retry": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-L+hXN2HDomlIsWSHW5DVD7ppccCeRnlHXZ5uHG34ePTjF5bm0I1fmrJLbUGiW97xRXWryit5cjdP4Sx2FwiGog=="], + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.16", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-format-url": "^3.972.10", "@smithy/eventstream-codec": "^4.2.14", "@smithy/eventstream-serde-browser": "^4.2.14", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-86+S9oCyRVGzoMRpQhxkArp7kD2K75GPmaNevd9B6EyNhWoNvnCZZ3WbgN4j7ZT+jvtvBCGZvI2XHsWZJ+BRIg=="], + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.21", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.1", "@aws-sdk/middleware-host-header": "^3.972.10", "@aws-sdk/middleware-logger": "^3.972.10", "@aws-sdk/middleware-recursion-detection": "^3.972.11", "@aws-sdk/middleware-user-agent": "^3.972.31", "@aws-sdk/region-config-resolver": "^3.972.12", "@aws-sdk/types": "^3.973.8", "@aws-sdk/util-endpoints": "^3.996.7", "@aws-sdk/util-user-agent-browser": "^3.972.10", "@aws-sdk/util-user-agent-node": "^3.973.17", "@smithy/config-resolver": "^4.4.16", "@smithy/core": "^3.23.15", "@smithy/fetch-http-handler": "^5.3.17", "@smithy/hash-node": "^4.2.14", "@smithy/invalid-dependency": "^4.2.14", "@smithy/middleware-content-length": "^4.2.14", "@smithy/middleware-endpoint": "^4.4.30", "@smithy/middleware-retry": "^4.5.3", "@smithy/middleware-serde": "^4.2.18", "@smithy/middleware-stack": "^4.2.14", "@smithy/node-config-provider": "^4.3.14", "@smithy/node-http-handler": "^4.5.3", "@smithy/protocol-http": "^5.3.14", "@smithy/smithy-client": "^4.12.11", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.47", "@smithy/util-defaults-mode-node": "^4.2.52", "@smithy/util-endpoints": "^3.4.1", "@smithy/util-middleware": "^4.2.14", "@smithy/util-retry": "^4.3.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-Me3d/ua2lb2G0bQfFmvCeQQp3+nN6GSPqMxDmi/IQlQ8CrlpQ5C0JJHpz2AnOUkEFI0lBNrAL3Vnt29l44ndkA=="], "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.12", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/config-resolver": "^4.4.16", "@smithy/node-config-provider": "^4.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-QQI43Mxd53nBij0pm8HXC+t4IOC6gnhhZfzxE0OATQyO6QfPV4e+aTIRRuAJKA6Nig/cR8eLwPryqYTX9ZrjAQ=="], "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.18", "", { "dependencies": { "@aws-sdk/middleware-sdk-s3": "^3.972.30", "@aws-sdk/types": "^3.973.8", "@smithy/protocol-http": "^5.3.14", "@smithy/signature-v4": "^5.3.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-4KT8UXRmvNAP5zKq9UI1MIwbnmSChZncBt89RKu/skMqZSSWGkBZTAJsZ+no+txfmF3kVaUFv31CTBZkQ5BJpQ=="], - "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1032.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/nested-clients": "^3.996.21", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-n+PU8Z+gll7p3wDrH+Wo6fkt8sPrVnq30YYM6Ryga95oJlEneNMEbDHj0iqjMX3V7gaGdJo/hJWyPo4lscP+mA=="], + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1005.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.8", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-vMxd+ivKqSxU9bHx5vmAlFKDAkjGotFU56IOkDa5DaTu1WWwbcse0yFHEm9I537oVvodaiwMl3VBwgHfzQ2rvw=="], "@aws-sdk/types": ["@aws-sdk/types@3.973.8", "", { "dependencies": { "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw=="], @@ -884,6 +947,8 @@ "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "@smithy/url-parser": "^4.2.14", "@smithy/util-endpoints": "^3.4.1", "tslib": "^2.6.2" } }, "sha512-ty4LQxN1QC+YhUP28NfEgZDEGXkyqOQy+BDriBozqHsrYO4JMgiPhfizqOGF7P+euBTZ5Ez6SKlLAMCLo8tzmw=="], + "@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/querystring-builder": "^4.2.14", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-DEKiHNJVtNxdyTeQspzY+15Po/kHm6sF0Cs4HV9Q2+lplB63+DrvdeiSoOSdWEWAoO2RcY1veoXVDz2tWxWCgQ=="], + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="], "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.10", "", { "dependencies": { "@aws-sdk/types": "^3.973.8", "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g=="], @@ -1212,7 +1277,7 @@ "@hey-api/types": ["@hey-api/types@0.1.2", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="], - "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + "@hono/node-server": ["@hono/node-server@1.19.13", "", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="], "@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="], @@ -1382,6 +1447,8 @@ "@kilocode/kilo-i18n": ["@kilocode/kilo-i18n@workspace:packages/kilo-i18n"], + "@kilocode/kilo-indexing": ["@kilocode/kilo-indexing@workspace:packages/kilo-indexing"], + "@kilocode/kilo-jetbrains": ["@kilocode/kilo-jetbrains@workspace:packages/kilo-jetbrains"], "@kilocode/kilo-telemetry": ["@kilocode/kilo-telemetry@workspace:packages/kilo-telemetry"], @@ -1400,6 +1467,22 @@ "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], + "@lancedb/lancedb": ["@lancedb/lancedb@0.26.2", "", { "dependencies": { "reflect-metadata": "^0.2.2" }, "optionalDependencies": { "@lancedb/lancedb-darwin-arm64": "0.26.2", "@lancedb/lancedb-linux-arm64-gnu": "0.26.2", "@lancedb/lancedb-linux-arm64-musl": "0.26.2", "@lancedb/lancedb-linux-x64-gnu": "0.26.2", "@lancedb/lancedb-linux-x64-musl": "0.26.2", "@lancedb/lancedb-win32-arm64-msvc": "0.26.2", "@lancedb/lancedb-win32-x64-msvc": "0.26.2" }, "peerDependencies": { "apache-arrow": ">=15.0.0 <=18.1.0" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-umk4WMCTwJntLquwvUbpqE+TXREolcQVL9MHcxr8EhRjsha88+ATJ4QuS/hpyiE1CG3R/XcgrMgJAGkziPC/gA=="], + + "@lancedb/lancedb-darwin-arm64": ["@lancedb/lancedb-darwin-arm64@0.26.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LAZ/v261eTlv44KoEm+AdqGnohS9IbVVVJkH9+8JTqwhe/k4j4Af8X9cD18tsaJAAtrGxxOCyIJ3wZTiBqrkCw=="], + + "@lancedb/lancedb-linux-arm64-gnu": ["@lancedb/lancedb-linux-arm64-gnu@0.26.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-guHKm+zvuQB22dgyn6/sYZJvD6IL9lC24cl6ZuzVX/jYgag/gNLHT86HongrcBjgdjI6+YIGmdfD6b/iAKxn3Q=="], + + "@lancedb/lancedb-linux-arm64-musl": ["@lancedb/lancedb-linux-arm64-musl@0.26.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pR6Hs/0iphItrJYYLf/yrqCC+scPcHpCGl6rHqcU2GHxo5RFpzlMzqW1DiXScGiBRuCcD9HIMec+kBsOgXv4GQ=="], + + "@lancedb/lancedb-linux-x64-gnu": ["@lancedb/lancedb-linux-x64-gnu@0.26.2", "", { "os": "linux", "cpu": "x64" }, "sha512-u4UUSPwd2YecgGqWjh9W0MHKgsVwB2Ch2ROpF8AY+IA7kpGsbB18R1/t7v2B0q7pahRy20dgsaku5LH1zuzMRQ=="], + + "@lancedb/lancedb-linux-x64-musl": ["@lancedb/lancedb-linux-x64-musl@0.26.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XIS4qkVfGlzmsUPqAG2iKt8ykuz28GfemGC0ijXwu04kC1pYiCFzTpB3UIZjm5oM7OTync1aQ3mGTj1oCciSPA=="], + + "@lancedb/lancedb-win32-arm64-msvc": ["@lancedb/lancedb-win32-arm64-msvc@0.26.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-//tZDPitm2PxNvalHP+m+Pf6VvFAeQgcht1+HJnutjH4gp6xYW6ynQlWWFDBmz9WRkUT+mXu2O4FUIhbdNaJSQ=="], + + "@lancedb/lancedb-win32-x64-msvc": ["@lancedb/lancedb-win32-x64-msvc@0.26.2", "", { "os": "win32", "cpu": "x64" }, "sha512-GH3pfyzicgPGTb84xMXgujlWDaAnBTmUyjooYiCE2tC24BaehX4hgFhXivamzAEsF5U2eVsA/J60Ppif+skAbA=="], + "@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="], "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="], @@ -1486,6 +1569,8 @@ "@npmcli/arborist": ["@npmcli/arborist@9.4.0", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/fs": "^5.0.0", "@npmcli/installed-package-contents": "^4.0.0", "@npmcli/map-workspaces": "^5.0.0", "@npmcli/metavuln-calculator": "^9.0.2", "@npmcli/name-from-folder": "^4.0.0", "@npmcli/node-gyp": "^5.0.0", "@npmcli/package-json": "^7.0.0", "@npmcli/query": "^5.0.0", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.0", "bin-links": "^6.0.0", "cacache": "^20.0.1", "common-ancestor-path": "^2.0.0", "hosted-git-info": "^9.0.0", "json-stringify-nice": "^1.1.4", "lru-cache": "^11.2.1", "minimatch": "^10.0.3", "nopt": "^9.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.0", "npm-pick-manifest": "^11.0.1", "npm-registry-fetch": "^19.0.0", "pacote": "^21.0.2", "parse-conflict-json": "^5.0.1", "proc-log": "^6.0.0", "proggy": "^4.0.0", "promise-all-reject-late": "^1.0.0", "promise-call-limit": "^3.0.1", "semver": "^7.3.7", "ssri": "^13.0.0", "treeverse": "^3.0.0", "walk-up-path": "^4.0.0" }, "bin": { "arborist": "bin/index.js" } }, "sha512-4Bm8hNixJG/sii1PMnag0V9i/sGOX9VRzFrUiZMSBJpGlLR38f+Btl85d07G9GL56xO0l0OZjvrGNYsDYp0xKA=="], + "@npmcli/config": ["@npmcli/config@10.8.1", "", { "dependencies": { "@npmcli/map-workspaces": "^5.0.0", "@npmcli/package-json": "^7.0.0", "ci-info": "^4.0.0", "ini": "^6.0.0", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "walk-up-path": "^4.0.0" } }, "sha512-MAYk9IlIGiyC0c9fnjdBSQfIFPZT0g1MfeSiD1UXTq2zJOLX55jS9/sETJHqw/7LN18JjITrhYfgCfapbmZHiQ=="], + "@npmcli/fs": ["@npmcli/fs@5.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="], "@npmcli/git": ["@npmcli/git@7.0.2", "", { "dependencies": { "@gar/promise-retry": "^1.0.0", "@npmcli/promise-spawn": "^9.0.0", "ini": "^6.0.0", "lru-cache": "^11.2.1", "npm-pick-manifest": "^11.0.1", "proc-log": "^6.0.0", "semver": "^7.3.5", "which": "^6.0.0" } }, "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg=="], @@ -1552,15 +1637,13 @@ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.4.12", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-V8/RnAPOcvvEOKefNBuWsrd1rY23bJUYVeI2CS1CEW9bUf0Q9JNESBT7Eop6jv77VBAwzljZju54AzgsCB7ydw=="], - "@opencode-ai/server": ["@opencode-ai/server@workspace:packages/server"], + "@opencode-ai/shared": ["@opencode-ai/shared@workspace:packages/shared"], "@opencode-ai/storybook": ["@opencode-ai/storybook@workspace:packages/storybook"], "@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"], - "@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"], - - "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="], + "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.8.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Y6j3yivgoEUf/kutD/k5GX/mzZfioRFoSx0gbQ+mIOzMaH/vJv1rCkztiuvlLw5xRYQil7oxHUZvmSfXqOx1NQ=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], @@ -1568,7 +1651,7 @@ "@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.6.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ=="], - "@opentelemetry/core": ["@opentelemetry/core@1.30.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ=="], + "@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], "@opentelemetry/exporter-logs-otlp-http": ["@opentelemetry/exporter-logs-otlp-http@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/otlp-exporter-base": "0.208.0", "@opentelemetry/otlp-transformer": "0.208.0", "@opentelemetry/sdk-logs": "0.208.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg=="], @@ -1578,11 +1661,7 @@ "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="], - "@opentelemetry/propagator-b3": ["@opentelemetry/propagator-b3@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-oATwWWDIJzybAZ4pO76ATN5N6FFbOA1otibAVlS8v90B4S1wClnhRUk7K+2CHAwN1JKYuj4jh/lpCEG5BAqFuQ=="], - - "@opentelemetry/propagator-jaeger": ["@opentelemetry/propagator-jaeger@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Pj/BfnYEKIOImirH76M4hDaBSx6HyZ2CXUqk+Kj02m6BB80c/yo4BdWkn/1gDFfU+YPY+bPR2U0DKBfdxCKwmg=="], - - "@opentelemetry/resources": ["@opentelemetry/resources@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA=="], + "@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA=="], @@ -1592,7 +1671,7 @@ "@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.6.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/core": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw=="], - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], "@opentui/core": ["@opentui/core@0.1.99", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.99", "@opentui/core-darwin-x64": "0.1.99", "@opentui/core-linux-arm64": "0.1.99", "@opentui/core-linux-x64": "0.1.99", "@opentui/core-win32-arm64": "0.1.99", "@opentui/core-win32-x64": "0.1.99", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-I3+AEgGzqNWIpWX9g2WOscSPwtQDNOm4KlBjxBWCZjLxkF07u77heWXF7OiAdhKLtNUW6TFiyt6yznqAZPdG3A=="], @@ -1660,6 +1739,56 @@ "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.19.1", "", { "os": "win32", "cpu": "x64" }, "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.21.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-P20j3MLqfwIT+94qGU3htC7dWp4pXGZW1p1p7FRUzu1aopq7c9nPCgf0W/WjktqQ57+iuTq9mbSlwWinl6+H1A=="], + + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.21.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-81TmmuBcPedEA0MwRmObuQuXnCprS1UiHQWGe7pseqNAJzUWXeAPrayqKTACX92VpruJI+yvY0XJrFp11PpcTA=="], + + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.21.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-sbjBr6zDduX8rNO0PTjhf7VYLCPWqdijWiMPp8e10qu6Tam1GdaVLaLlX8QrNupTgglO1GvqqgY/jcacWL8a6g=="], + + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.21.0", "", { "os": "linux", "cpu": "x64" }, "sha512-jNrOcy53R5TJQfrK444Cm60bW9437xDoxPbm3AdvFSo/fhdFMllawc7uZC2Wzr+EAjTkW13K8R4QHzsUdBG9fQ=="], + + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.21.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-xWeRxJJILDE4b9UqHEWGBxcBc1TUS6zWHhxcyxTZMwf4q3wdKeu0OHYAcwLGJzoSjEIf6FTjyfPiRNil2oqsdg=="], + + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.21.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Ob9AA9teI8ckPo1whV1smLr5NrqwgBv/8boDbK0YZG+fKgNGRwr1hBj1ORgFWOQaUBv+5njp5A0RAfJJjQ95QQ=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-YdeJKaZckDQL1qa62a1aKq/goyq48aX3yOxaaWqWb4sau4Ee4IiLbamftNLU3zbePky6QsDj6thnSSzHRBjDfA=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-7ANS7PpXCfq84xZQ8E5WPs14gwcuPcl+/8TFNXfpSu0CQBXz3cUo2fDpHT8v8HJN+Ut02eacvMAzTnc9s6X4tw=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pJsgd9AfplLGBm1fIr25V6V14vMrayhx4uIQvlfH7jWs2SZwSrvi3TfgfJySB8T+hvyEH8K2zXljQiUnkgUnfQ=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ue1aXHX49ivwflKqGJc7zcd/LeLgbhaTcDCQStgx5x06AXgjEAZmvrlMuIkWd4AL4FHQe6QJ9f33z04Cg448VQ=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.60.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YCyQzsQtusQw+gNRW9rRTifSO+Dt/+dtCl2NHoDMZqJlRTEZ/Oht9YnuporI9yiTx7+cB+eqzX3MtHHVHGIWhg=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-c7dxM2Zksa45Qw16i2iGY3Fti2NirJ38FrsBsKw+qcJ0OtqTsBgKJLF0xV+yLG56UH01Z8WRPgsw31e0MoRoGQ=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ZWALoA42UYqBEP1Tbw9OWURgFGS1nWj2AAvLdY6ZcGx/Gj93qVCBKjcvwXMupZibYwFbi9s/rzqkZseb/6gVtQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tpy+1w4p9hN5CicMCxqNy6ymfRtV5ayE573vFNjp1k1TN/qhLFgflveZoE/0++RlkHikBz2vY545NWm/hp7big=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-eDYDXZGhQAXyn6GwtwiX/qcLS0HlOLPJ/+iiIY8RYr+3P8oKBmgKxADLlniL6FtWfE7pPk7IGN9/xvDEvDvFeg=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-nxehly5XYBHUWI9VJX1bqCf9j/B43DaK/aS/T1fcxCpX3PA4Rm9BB54nPD1CKayT8xg6REN1ao+01hSRNgy8OA=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-j1qf/NaUfOWQutjeoooNG1Q0zsK0XGmSu1uDLq3cctquRF3j7t9Hxqf/76ehCc5GEUAanth2W4Fa+XT1RFg/nw=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-YELKPRefQ/q/h3RUmeRfPCUhh2wBvgV1RyZ/F9M9u8cDyXsQW2ojv1DeWQTt466yczDITjZnIOg/s05pk7Ve2A=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-JkO3C6Gki7Y6h/MiIkFKvHFOz98/YWvQ4WYbK9DLXACMP2rjULzkeGyAzorJE5S1dzLQGFgeqvN779kSFwoV1g=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XjKHdFVCpZZZSWBCKyyqCq65s2AKXykMXkjLoKYODrD+f5toLhlwsMESscu8FbgnJQ4Y/dpR/zdazsahmgBJIA=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-js29ZWIuPhNWzY8NC7KoffEMEeWG105vbmm+8EOJsC+T/jHBiKIJEUF78+F/IrgEWMMP9N0kRND4Pp75+xAhKg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-H+PUITKHk04stFpWj3x3Kg08Afp/bcXSBi0EhasR5a0Vw7StXHTzdl655PUI0fB4qdh2Wsu6Dsi+3ACxPoyQnA=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-WA/yc7f7ZfCefBXVzNHn1Ztulb1EFwNBb4jMZ6pjML0zz6pHujlF3Q3jySluz3XHl/GNeMTntG1seUBWVMlMag=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-33YxL1sqwYNZXtn3MD/4dno6s0xeedXOJlT1WohkVD565WvohClZUr7vwKdAk954n4xiEWJkewiCr+zLeq7AeA=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-JOro4ZcfBLamJCyfURQmOQByoorgOdx3ZjAkSqnb/CyG/i+lN3KoV5LAgk5ZAW6DPq7/Cx7n23f8DuTWXTWgyQ=="], + "@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="], "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="], @@ -1730,6 +1859,10 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@qdrant/js-client-rest": ["@qdrant/js-client-rest@1.17.0", "", { "dependencies": { "@qdrant/openapi-typescript-fetch": "1.2.6", "undici": "^6.23.0" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-aZFQeirWVqWAa1a8vJ957LMzcXkFHGbsoRhzc8AkGfg6V0jtK8PlG8/eyyc2xhYsR961FDDx1Tx6nyE0K7lS+A=="], + + "@qdrant/openapi-typescript-fetch": ["@qdrant/openapi-typescript-fetch@1.2.6", "", {}, "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.2", "", { "os": "android", "cpu": "arm" }, "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.2", "", { "os": "android", "cpu": "arm64" }, "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg=="], @@ -2152,7 +2285,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="], @@ -2160,6 +2293,10 @@ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + "@types/command-line-args": ["@types/command-line-args@5.2.3", "", {}, "sha512-uv0aG6R0Y8WHZLTamZwtfsDLVRnOa+n+n5rEvFWL5Na5gZ8V2Teab/duDPFzIIIhs9qizDpcavCusCLJZu62Kw=="], + + "@types/command-line-usage": ["@types/command-line-usage@5.0.4", "", {}, "sha512-BwR5KP3Es/CSht0xqBcUXS3qCAUVXwpRKsV2+arxeb65atasuXG9LykC9Ab10Cw3s2raH92ZqOeILaQbsB2ACg=="], + "@types/cross-spawn": ["@types/cross-spawn@6.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA=="], "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], @@ -2378,7 +2515,7 @@ "@valibot/to-json-schema": ["@valibot/to-json-schema@1.6.0", "", { "peerDependencies": { "valibot": "^1.3.0" } }, "sha512-d6rYyK5KVa2XdqamWgZ4/Nr+cXhxjy7lmpe6Iajw15J/jmU+gyxl2IEd1Otg1d7Rl3gOQL5reulnSypzBtYy1A=="], - "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], + "@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="], "@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="], @@ -2430,6 +2567,16 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.9.10", "", {}, "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw=="], + "@xterm/addon-clipboard": ["@xterm/addon-clipboard@0.2.0", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg=="], + + "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="], + + "@xterm/addon-unicode-graphemes": ["@xterm/addon-unicode-graphemes@0.4.0", "", {}, "sha512-9+/CqwbKcnlkJU4d3wIgO+wjsL8f6vyz+UwUWLu6nADQz8Gr8ONqGCJfdDjIdI+yYZLABQqQy47FzEM6AWELjw=="], + + "@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="], + + "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="], + "@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="], "abbrev": ["abbrev@4.0.0", "", {}, "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA=="], @@ -2452,7 +2599,7 @@ "aggregate-error": ["aggregate-error@4.0.1", "", { "dependencies": { "clean-stack": "^4.0.0", "indent-string": "^5.0.0" } }, "sha512-0poP0T7el6Vq3rstR8Mn4V/IQrpBLO6POkUSrN7RhyY+GF/InCFShQzsQ39T25gkHhLgSLByyAz+Kjb+c2L98w=="], - "ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="], + "ai": ["ai@6.0.168", "", { "dependencies": { "@ai-sdk/gateway": "3.0.104", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ=="], "ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="], @@ -2474,6 +2621,8 @@ "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + "apache-arrow": ["apache-arrow@18.1.0", "", { "dependencies": { "@swc/helpers": "^0.5.11", "@types/command-line-args": "^5.2.3", "@types/command-line-usage": "^5.0.4", "@types/node": "^20.13.0", "command-line-args": "^5.2.1", "command-line-usage": "^7.0.1", "flatbuffers": "^24.3.25", "json-bignum": "^0.0.3", "tslib": "^2.6.2" }, "bin": { "arrow2csv": "bin/arrow2csv.js" } }, "sha512-v/ShMp57iBnBp4lDgV8Jx3d3Q5/Hac25FWmQ98eMahUiHPXcvwIMKJD0hBIgclm/FCG+LwPkAKtkRO1O/W0YGg=="], + "app-builder-bin": ["app-builder-bin@5.0.0-alpha.12", "", {}, "sha512-j87o0j6LqPL3QRr8yid6c+Tt5gC7xNfYo6uQIQkorAC6MpeayVMZrEDzKmJJ/Hlv7EnOQpaRm53k6ktDYZyB6w=="], "app-builder-lib": ["app-builder-lib@26.8.1", "", { "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", "@electron/rebuild": "^4.0.3", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", "async-exit-hook": "^2.0.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", "debug": "^4.3.4", "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", "electron-publish": "26.8.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", "jiti": "^2.4.2", "js-yaml": "^4.1.0", "json5": "^2.2.3", "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", "tar": "^7.5.7", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" }, "peerDependencies": { "dmg-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1" } }, "sha512-p0Im/Dx5C4tmz8QEE1Yn4MkuPC8PrnlRneMhWJj7BBXQfNTJUshM/bp3lusdEsDbvvfJZpXWnYesgSLvwtM2Zw=="], @@ -2488,6 +2637,8 @@ "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + "array-back": ["array-back@3.1.0", "", {}, "sha512-TkuxA4UCOvxuDK6NZYXCalszEzj+TLszyASooky+i742l9TqsOdYCMJJupxRic61hwquNtppB3hgcuq9SVSH1Q=="], + "array-find-index": ["array-find-index@1.0.2", "", {}, "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw=="], "array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="], @@ -2512,6 +2663,8 @@ "async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="], + "async-mutex": ["async-mutex@0.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], "at-least-node": ["at-least-node@1.0.0", "", {}, "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="], @@ -2626,7 +2779,7 @@ "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], @@ -2676,6 +2829,8 @@ "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + "chalk-template": ["chalk-template@0.4.0", "", { "dependencies": { "chalk": "^4.1.2" } }, "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg=="], + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], @@ -2750,6 +2905,10 @@ "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + "command-line-args": ["command-line-args@5.2.1", "", { "dependencies": { "array-back": "^3.1.0", "find-replace": "^3.0.0", "lodash.camelcase": "^4.3.0", "typical": "^4.0.0" } }, "sha512-H4UfQhZyakIjC74I9d34fGYDwk3XpSr17QhEd0Q3I9Xq1CETHo4Hcuo87WyWHpAF1aSLjLRf5lD9ZGX2qStUvg=="], + + "command-line-usage": ["command-line-usage@7.0.4", "", { "dependencies": { "array-back": "^6.2.2", "chalk-template": "^0.4.0", "table-layout": "^4.1.1", "typical": "^7.3.0" } }, "sha512-85UdvzTNx/+s5CkSgBm/0hzP80RFHAa7PsfeADE5ezZF3uHz3/Tqj9gIKGT9PTtpycc3Ua64T0oVulGfKxzfqg=="], + "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], "common-ancestor-path": ["common-ancestor-path@2.0.0", "", {}, "sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng=="], @@ -3002,7 +3161,7 @@ "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - "electron": ["electron@40.8.5", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-pgTY/VPQKaiU4sTjfU96iyxCXrFm4htVPCMRT4b7q9ijNTRgtLmLvcmzp2G4e7xDrq9p7OLHSmu1rBKFf6Y1/A=="], + "electron": ["electron@41.2.1", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-teeRThiYGTPKf/2yOW7zZA1bhb91KEQ4yLBPOg7GxpmnkLFLugKgQaAKOrCgdzwsXh/5mFIfmkm+4+wACJKwaA=="], "electron-builder": ["electron-builder@26.8.1", "", { "dependencies": { "app-builder-lib": "26.8.1", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-uWhx1r74NGpCagG0ULs/P9Nqv2nsoo+7eo4fLUOB8L8MdWltq9odW/uuLXMFCDGnPafknYLZgjNX0ZIFRzOQAw=="], @@ -3212,12 +3371,16 @@ "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], + "find-replace": ["find-replace@3.0.0", "", { "dependencies": { "array-back": "^3.0.1" } }, "sha512-6Tb2myMioCAgv5kfvP5/PkZZ/ntTpVK39fHY7WkWBgvbeE+VHd/tZuZ4mrC+bxh4cfOZeYKVPaJIZtZXV7GNCQ=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], "flat": ["flat@5.0.2", "", { "bin": { "flat": "cli.js" } }, "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ=="], "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + "flatbuffers": ["flatbuffers@24.12.23", "", {}, "sha512-dLVCAISd5mhls514keQzmEG6QHmUUsNuWsb4tFafIUwvvgDjXhtfAYSKOzt5SWOy+qByV5pbsDZ+Vb7HUOBEdA=="], + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], @@ -3288,7 +3451,7 @@ "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.4.2", "", { "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-Wyw6uslCuipBOr/NYwAtpgXEUJj68iJY5aekad2DjePN99JetKVQBqkLgAy9PZp2EA4OuscfRQu9qKIBN/evNw=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.6.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-jUxYnKA4XQaPc3wxACDZ8bPDXO0Mzx7cZaBDxbT2uGgLqtGZmSi+9tVNIg7louSS+s/ioVra3SoUz3iOFVhKPA=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], @@ -3504,6 +3667,8 @@ "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="], + "js-md4": ["js-md4@0.3.2", "", {}, "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="], "js-string-escape": ["js-string-escape@1.0.1", "", {}, "sha512-Smw4xcfIQ5LVjAOuJCvN/zIodzA/BBSsluuoSykP+lUvScIi4U6RJLfwHet5cxFnCswUjISV8oAXaqaJDY3chg=="], @@ -3516,10 +3681,14 @@ "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="], + "jschardet": ["jschardet@3.1.4", "", {}, "sha512-/kmVISmrwVwtyYU40iQUOp3SUPk2dhNCMsZBQX0R1/jZ8maaXJ/oZIzUOiyOqcgtLnETFKYChbJ5iDC/eWmFHg=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + "json-bignum": ["json-bignum@0.0.3", "", {}, "sha512-2WHyXj3OfHSgNyuzDbSxI1w2jgw5gkWSWhS7Qg4bWXx1nLk3jnbwfUeS0PSba3IzpTUWdHxBieELUzXRjQB2zg=="], + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-parse-even-better-errors": ["json-parse-even-better-errors@5.0.0", "", {}, "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ=="], @@ -3632,6 +3801,8 @@ "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="], + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], @@ -3898,7 +4069,7 @@ "open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="], - "openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="], + "openai": ["openai@6.27.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-osTKySlrdYrLYTt0zjhY8yp0JUBmWDCN+Q+QxsV4xMQnnoVFpylgKGgxwN8sSdTNw0G4y+WUXs4eCMWpyDNWZQ=="], "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], @@ -3920,6 +4091,10 @@ "oxc-resolver": ["oxc-resolver@11.19.1", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.19.1", "@oxc-resolver/binding-android-arm64": "11.19.1", "@oxc-resolver/binding-darwin-arm64": "11.19.1", "@oxc-resolver/binding-darwin-x64": "11.19.1", "@oxc-resolver/binding-freebsd-x64": "11.19.1", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", "@oxc-resolver/binding-linux-x64-musl": "11.19.1", "@oxc-resolver/binding-openharmony-arm64": "11.19.1", "@oxc-resolver/binding-wasm32-wasi": "11.19.1", "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" } }, "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg=="], + "oxlint": ["oxlint@1.60.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.60.0", "@oxlint/binding-android-arm64": "1.60.0", "@oxlint/binding-darwin-arm64": "1.60.0", "@oxlint/binding-darwin-x64": "1.60.0", "@oxlint/binding-freebsd-x64": "1.60.0", "@oxlint/binding-linux-arm-gnueabihf": "1.60.0", "@oxlint/binding-linux-arm-musleabihf": "1.60.0", "@oxlint/binding-linux-arm64-gnu": "1.60.0", "@oxlint/binding-linux-arm64-musl": "1.60.0", "@oxlint/binding-linux-ppc64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-gnu": "1.60.0", "@oxlint/binding-linux-riscv64-musl": "1.60.0", "@oxlint/binding-linux-s390x-gnu": "1.60.0", "@oxlint/binding-linux-x64-gnu": "1.60.0", "@oxlint/binding-linux-x64-musl": "1.60.0", "@oxlint/binding-openharmony-arm64": "1.60.0", "@oxlint/binding-win32-arm64-msvc": "1.60.0", "@oxlint/binding-win32-ia32-msvc": "1.60.0", "@oxlint/binding-win32-x64-msvc": "1.60.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-tnRzTWiWJ9pg3ftRWnD0+Oqh78L6ZSwcEudvCZaER0PIqiAnNyXj5N1dPwjmNpDalkKS9m/WMLN1CTPUBPmsgw=="], + + "oxlint-tsgolint": ["oxlint-tsgolint@0.21.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.21.0", "@oxlint-tsgolint/darwin-x64": "0.21.0", "@oxlint-tsgolint/linux-arm64": "0.21.0", "@oxlint-tsgolint/linux-x64": "0.21.0", "@oxlint-tsgolint/win32-arm64": "0.21.0", "@oxlint-tsgolint/win32-x64": "0.21.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-HiWPhANwRnN1pZJQ2SgNB3WRR+1etLJHmRzQ/MJhyINsEIaOUCjxhlXJKbEaVUwdnyXwRWqo/P9Fx21lz0/mSg=="], + "p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="], "p-defer": ["p-defer@1.0.0", "", {}, "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw=="], @@ -3928,7 +4103,7 @@ "p-filter": ["p-filter@2.1.0", "", { "dependencies": { "p-map": "^2.0.0" } }, "sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw=="], - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "p-limit": ["p-limit@7.3.0", "", { "dependencies": { "yocto-queue": "^1.2.1" } }, "sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], @@ -4178,6 +4353,8 @@ "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], @@ -4482,6 +4659,8 @@ "table": ["table@6.9.0", "", { "dependencies": { "ajv": "^8.0.1", "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" } }, "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A=="], + "table-layout": ["table-layout@4.1.1", "", { "dependencies": { "array-back": "^6.2.2", "wordwrapjs": "^5.1.0" } }, "sha512-iK5/YhZxq5GO5z8wb0bY1317uDF3Zjpha0QFFLA8/trAoiLbQD0HUbMesEaxyzUgDxi2QlcbM8IvqOlEjgoXBA=="], + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], @@ -4570,6 +4749,8 @@ "tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="], + "tree-sitter-wasms": ["tree-sitter-wasms@0.1.13", "", { "dependencies": { "tree-sitter-wasms": "^0.1.11" } }, "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ=="], + "treeverse": ["treeverse@3.0.0", "", {}, "sha512-gcANaAnd2QDZFmHFEOF4k7uc1J/6a6z3DJMd/QwEyxLoKGiptJRwid582r7QIsFlFMIZ3SnxfS52S4hm2DHkuQ=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], @@ -4622,6 +4803,8 @@ "typescript-eslint": ["typescript-eslint@8.58.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.58.2", "@typescript-eslint/parser": "8.58.2", "@typescript-eslint/typescript-estree": "8.58.2", "@typescript-eslint/utils": "8.58.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-V8iSng9mRbdZjl54VJ9NKr6ZB+dW0J3TzRXRGcSbLIej9jV86ZRtlYeTKDR/QLxXykocJ5icNzbsl2+5TzIvcQ=="], + "typical": ["typical@4.0.0", "", {}, "sha512-VAH4IvQ7BDFYglMd7BPRDfLgxZZX4O4TFcRDA6EN5X7erNJJq+McIEp8np9aVtxrCJ6qx4GTYVfOWNjcqwZgRw=="], + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], @@ -4684,7 +4867,7 @@ "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], - "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "v8-to-istanbul": ["v8-to-istanbul@9.3.0", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", "convert-source-map": "^2.0.0" } }, "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA=="], @@ -4766,6 +4949,8 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "wordwrapjs": ["wordwrapjs@5.1.1", "", {}, "sha512-0yweIbkINJodk27gX9LBGMzyQdBDan3s/dEAiwBOj+Mf0PPyWL6/rikalkv8EeD0E8jm4o5RXEOrFTP3NXbhJg=="], + "workerpool": ["workerpool@9.3.4", "", {}, "sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], @@ -4806,7 +4991,7 @@ "yazl": ["yazl@2.5.1", "", { "dependencies": { "buffer-crc32": "~0.2.3" } }, "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], @@ -4872,6 +5057,8 @@ "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1032.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.1", "@aws-sdk/nested-clients": "^3.996.21", "@aws-sdk/types": "^3.973.8", "@smithy/property-provider": "^4.2.14", "@smithy/shared-ini-file-loader": "^4.4.9", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-n+PU8Z+gll7p3wDrH+Wo6fkt8sPrVnq30YYM6Ryga95oJlEneNMEbDHj0iqjMX3V7gaGdJo/hJWyPo4lscP+mA=="], + "@aws-sdk/xml-builder/fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="], "@azure/core-http/@azure/abort-controller": ["@azure/abort-controller@1.1.0", "", { "dependencies": { "tslib": "^2.2.0" } }, "sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw=="], @@ -4902,8 +5089,6 @@ "@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], - "@effect/opentelemetry/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@effect/platform-node/undici": ["undici@8.1.0", "", {}, "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw=="], "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], @@ -5010,9 +5195,7 @@ "@kilocode/kilo-gateway/@opentui/solid": ["@opentui/solid@0.1.75", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.75", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-WjKsZIfrm29znfRlcD9w3uUn/+uvoy2MmeoDwTvg1YOa0OjCTCmjZ43L9imp0m9S4HmVU8ma6o2bR4COzcyDdg=="], - "@kilocode/kilo-telemetry/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@1.30.1", "", { "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg=="], - - "@kilocode/kilo-telemetry/@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@1.30.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "1.30.1", "@opentelemetry/core": "1.30.1", "@opentelemetry/propagator-b3": "1.30.1", "@opentelemetry/propagator-jaeger": "1.30.1", "@opentelemetry/sdk-trace-base": "1.30.1", "semver": "^7.5.2" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-cBjYOINt1JxXdpw1e5MlHmFRc5fgj4GW/86vsKFxJCJ8AL4PdVtYH41gWwl4qd4uQjqEL1oJVrXkSy5cnduAnQ=="], + "@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=="], "@malept/flatpak-bundler/fs-extra": ["fs-extra@9.1.0", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="], @@ -5024,10 +5207,16 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "@modelcontextprotocol/sdk/@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], "@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@morphllm/morphsdk/ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="], + + "@morphllm/morphsdk/openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="], + "@octokit/core/@octokit/graphql": ["@octokit/graphql@7.1.1", "", { "dependencies": { "@octokit/request": "^8.4.1", "@octokit/types": "^13.0.0", "universal-user-agent": "^6.0.0" } }, "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g=="], "@octokit/core/@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="], @@ -5102,42 +5291,22 @@ "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.208.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.208.0", "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/sdk-logs": "0.208.0", "@opentelemetry/sdk-metrics": "2.2.0", "@opentelemetry/sdk-trace-base": "2.2.0", "protobufjs": "^7.3.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ=="], - "@opentelemetry/exporter-trace-otlp-http/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], - - "@opentelemetry/exporter-trace-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - - "@opentelemetry/otlp-exporter-base/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], - "@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA=="], - "@opentelemetry/otlp-transformer/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], - - "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA=="], "@opentelemetry/sdk-logs/@opentelemetry/core": ["@opentelemetry/core@2.2.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw=="], "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], - "@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], - - "@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - - "@opentelemetry/sdk-trace-base/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], - - "@opentelemetry/sdk-trace-base/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - - "@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - - "@opentelemetry/sdk-trace-node/@opentelemetry/core": ["@opentelemetry/core@2.6.1", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g=="], - "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], "@protobuf-ts/plugin/typescript": ["typescript@3.9.10", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q=="], + "@qdrant/js-client-rest/undici": ["undici@6.25.0", "", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="], + "@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], "@shikijs/engine-oniguruma/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], @@ -5220,8 +5389,6 @@ "@vscode/vsce/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - "ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="], - "ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@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-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="], "ai-gateway-provider/@ai-sdk/google": ["@ai-sdk/google@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uz8tIlkDgQJG9Js2Wh9JHzd4kI9+hYJqf9XXJLx60vyN5mRIqhr49iwR5zGP5Gl8odp2PeR3Gh2k+5bh3Z1HHw=="], @@ -5232,6 +5399,8 @@ "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.75", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-V8UKK4fNpI9cnrtsZBvUp9O9J6Y9fTKBRoSLyEaNGPirACewixmLDbXsSgAeownPVWiWpK34bFysd+XouI5Ywg=="], + "ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="], + "ajv-keywords/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "app-builder-lib/@electron/get": ["@electron/get@3.1.0", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="], @@ -5298,6 +5467,8 @@ "cacheable-request/get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + "chalk-template/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "cheerio/undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], "cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], @@ -5306,6 +5477,10 @@ "clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="], + "command-line-usage/array-back": ["array-back@6.2.3", "", {}, "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw=="], + + "command-line-usage/typical": ["typical@7.3.0", "", {}, "sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw=="], + "compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], @@ -5326,6 +5501,8 @@ "dir-compare/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "dir-compare/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "dir-glob/path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], "dmg-builder/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], @@ -5340,6 +5517,8 @@ "effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "effect/uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "electron-builder/fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], @@ -5426,6 +5605,8 @@ "keytar/node-addon-api": ["node-addon-api@4.3.0", "", {}, "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ=="], + "kilo-code/openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="], + "kilo-code/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "kilo-code/web-tree-sitter": ["web-tree-sitter@0.24.7", "", {}, "sha512-CdC/TqVFbXqR+C51v38hv6wOPatKEUGxa39scAeFSm98wIhZxAYonhRQPSMmfZ2w7JDI0zQDdzdmgtNk06/krQ=="], @@ -5506,6 +5687,8 @@ "p-filter/p-map": ["p-map@2.1.0", "", {}, "sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw=="], + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + "parent-module/callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "parse-semver/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], @@ -5600,6 +5783,8 @@ "table/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "table-layout/array-back": ["array-back@6.2.3", "", {}, "sha512-SGDvmg6QTYiTxCBkYVmThcoa67uLl35pyzRHdpCGBOcqFy6BtwnphoFPk7LhJshD+Yk1Kt35WGWeZPTgwR4Fhw=="], + "tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], @@ -5768,14 +5953,16 @@ "@kilocode/kilo-gateway/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.9", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="], - "@kilocode/kilo-telemetry/@opentelemetry/sdk-trace-node/@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@1.30.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA=="], - "@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "@malept/flatpak-bundler/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], "@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "@morphllm/morphsdk/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="], + + "@morphllm/morphsdk/openai/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], "@octokit/endpoint/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], @@ -5818,6 +6005,8 @@ "@opencode-ai/plugin/effect/toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + "@opencode-ai/plugin/effect/uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "@opencode-ai/storybook/@storybook/addon-docs/@storybook/csf-plugin": ["@storybook/csf-plugin@10.3.5", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.3.5", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-qlEzNKxOjq86pvrbuMwiGD/bylnsXk1dg7ve0j77YFjEEchqtl7qTlrXvFdNaLA89GhW6D/EV6eOCu/eobPDgw=="], "@opencode-ai/storybook/@storybook/addon-docs/@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.3.5", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.3.5" } }, "sha512-Gw8R7XZm0zSUH0XAuxlQJhmizsLzyD6x00KOlP6l7oW9eQHXGfxg3seNDG3WrSAcW07iP1/P422kuiriQlOv7g=="], @@ -5828,46 +6017,26 @@ "@opencode-ai/ui/@solid-primitives/resize-observer/@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="], - "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A=="], "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw=="], "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.2.0", "", { "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw=="], - "@opentelemetry/exporter-trace-otlp-http/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - - "@opentelemetry/exporter-trace-otlp-http/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - - "@opentelemetry/otlp-exporter-base/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - - "@opentelemetry/otlp-transformer/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - - "@opentelemetry/otlp-transformer/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - - "@opentelemetry/otlp-transformer/@opentelemetry/sdk-logs/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - - "@opentelemetry/sdk-logs/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - - "@opentelemetry/sdk-logs/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - - "@opentelemetry/sdk-metrics/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - - "@opentelemetry/sdk-metrics/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - - "@opentelemetry/sdk-trace-node/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-community/standard-json/effect/toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + "@standard-community/standard-json/effect/uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-community/standard-openapi/effect/toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + "@standard-community/standard-openapi/effect/uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], + "@storybook/addon-links/storybook/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=="], "@storybook/addon-onboarding/storybook/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=="], @@ -6010,6 +6179,8 @@ "c8/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "chalk-template/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "crc/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], @@ -6020,6 +6191,8 @@ "dir-compare/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + "dir-compare/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "dmg-builder/fs-extra/jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], "dmg-builder/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], @@ -6232,6 +6405,8 @@ "ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + "pkg-conf/find-up/locate-path": ["locate-path@7.2.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="], "pkg-conf/find-up/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], @@ -6240,8 +6415,6 @@ "posthog-js/@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.7.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-DT12SXVwV2eoJrGf4nnsvZojxxeQo+LlNAsoYGRRObPWTeN6APiqZ2+nqDCQDvQX40eLi1AePONS0onoASp3yQ=="], - "posthog-js/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], "qrcode/yargs/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -6342,6 +6515,8 @@ "@manypkg/find-root/find-up/locate-path/p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "@morphllm/morphsdk/ai/@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], + "@octokit/graphql/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], @@ -6352,10 +6527,6 @@ "@opencode-ai/app/@playwright/test/playwright/playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], - "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/resources/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - - "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="], - "@tailwindcss/vite/@tailwindcss/node/lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="], @@ -6638,8 +6809,6 @@ "mocha/glob/jackspeak/@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - "pkg-conf/find-up/locate-path/p-locate/p-limit/yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], - "qrcode/yargs/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], "test-exclude/glob/jackspeak/@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], diff --git a/flake.lock b/flake.lock index 5c1783a1e94..e66c04991a3 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1775823930, - "narHash": "sha256-ALT447J7FcxP/97J01A/gp/hgdO5lXRsm+zLMt+gIjc=", + "lastModified": 1777270315, + "narHash": "sha256-yKB4G6cKsQsWN7M6rZGk6gkJPDNPIzT05y4qzRyCDlI=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8c11f88bb9573a10a7d6bf87161ef08455ac70b9", + "rev": "6368eda62c9775c38ef7f714b2555a741c20c72d", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 13293523aa3..6380c23d272 100644 --- a/flake.nix +++ b/flake.nix @@ -21,9 +21,63 @@ devShells = forEachSystem (pkgs: { default = let + # Pin bun to the version declared in package.json (packageManager: "bun@1.3.13"). + # nixpkgs-unstable currently ships 1.3.11, so we fetch the official release directly. + bun = + let + sources = { + "aarch64-linux" = { + name = "bun-linux-aarch64"; + hash = "sha256-cLrkGzkIsKEg4eWMXIrzDnSvrjuNEbDT/djnh937SyI="; + }; + "x86_64-linux" = { + name = "bun-linux-x64"; + hash = "sha256-ecB3H6i5LDOq5B4VoODTB+qZ0OLwAxfHHGxTI3p44lo="; + }; + "aarch64-darwin" = { + name = "bun-darwin-aarch64"; + hash = "sha256-VGfj9l26Umuf6pjwzOBO+vwMY+Fpcz7Ce4dqOtMtoZA="; + }; + "x86_64-darwin" = { + name = "bun-darwin-x64"; + hash = "sha256-5abItk9BmSUjLREeyxPiXwq/VeVPeSNB+YdiP9B3gAk="; + }; + }; + source = + sources.${pkgs.stdenv.hostPlatform.system} + or (throw "Unsupported system for bun: ${pkgs.stdenv.hostPlatform.system}"); + in + pkgs.stdenv.mkDerivation rec { + pname = "bun"; + version = "1.3.13"; + src = pkgs.fetchurl { + url = "https://github.com/oven-sh/bun/releases/download/bun-v${version}/${source.name}.zip"; + inherit (source) hash; + }; + nativeBuildInputs = [ + pkgs.unzip + ] ++ pkgs.lib.optional pkgs.stdenv.isLinux pkgs.autoPatchelfHook; + buildInputs = pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.stdenv.cc.cc.lib ]; + dontConfigure = true; + dontBuild = true; + installPhase = '' + runHook preInstall + install -Dm755 bun $out/bin/bun + ln -s $out/bin/bun $out/bin/bunx + runHook postInstall + ''; + meta = { + description = "Fast all-in-one JavaScript runtime"; + homepage = "https://bun.sh"; + license = pkgs.lib.licenses.mit; + mainProgram = "bun"; + platforms = builtins.attrNames sources; + }; + }; + kilo-dev = pkgs.writeShellScriptBin "kilo-dev" '' cd "$KILO_ROOT" - exec ${pkgs.bun}/bin/bun dev "$@" + exec ${bun}/bin/bun dev "$@" ''; kilo-install-bin = pkgs.writeShellScriptBin "kilo-install" '' diff --git a/nix/hashes.json b/nix/hashes.json index 14da256fb54..c92c084061b 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-XbN+85hGFq53RxAaksXBwD4n2PYrcVimDyJGsyi4IW0=", - "aarch64-linux": "sha256-F6lWJh3J2vdRv+gfYFa8kNPNHgBRhjlwtQdFKHNjaZo=", - "aarch64-darwin": "sha256-3oURfnhyuvj86o4TCH9vCnIoNVcwwRFE6wcc4xG2qLY=", - "x86_64-darwin": "sha256-pQUBadkJdkqPKpi5sgJsEGw40ZeASDK1xG/XpcfnECo=" + "x86_64-linux": "sha256-cAQ3LYGSSUAxDH77qouZcG4dPjTBIysPAkLwXHkRza4=", + "aarch64-linux": "sha256-49haI3uiwlTn4YYJsCyEEMTXmjGYyktm8qC5zQ0MAzg=", + "aarch64-darwin": "sha256-sdd37hufZNCHQuVkDxaHc6xVqovz/BuCPbBOoDbcuMQ=", + "x86_64-darwin": "sha256-Ndl68EHo83z9C3WFDNqRDni6fdKSX9nj4/5o+3wxzNs=" } } diff --git a/nix/kilo.nix b/nix/kilo.nix index ba6c7afc966..6c6375405f3 100644 --- a/nix/kilo.nix +++ b/nix/kilo.nix @@ -7,6 +7,7 @@ sysctl, makeBinaryWrapper, models-dev, + ripgrep, installShellFiles, versionCheckHook, writableTmpDirAsHomeHook, @@ -51,25 +52,25 @@ stdenvNoCC.mkDerivation (finalAttrs: { runHook postBuild ''; - installPhase = - '' - runHook preInstall + installPhase = '' + runHook preInstall - install -Dm755 dist/@kilocode/cli-*/bin/kilo $out/bin/kilo - install -Dm644 schema.json $out/share/kilo/schema.json - '' - # bun runs sysctl to detect if dunning on rosetta2 - + lib.optionalString stdenvNoCC.hostPlatform.isDarwin '' - wrapProgram $out/bin/kilo \ - --prefix PATH : ${ - lib.makeBinPath [ - sysctl + install -Dm755 dist/@kilocode/cli-*/bin/kilo $out/bin/kilo + install -Dm644 schema.json $out/share/kilo/schema.json + + wrapProgram $out/bin/kilo \ + --prefix PATH : ${ + lib.makeBinPath ( + [ + ripgrep ] - } - '' - + '' - runHook postInstall - ''; + # bun runs sysctl to detect if dunning on rosetta2 + ++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl + ) + } + + runHook postInstall + ''; postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) '' # trick yargs into also generating zsh completions diff --git a/nix/node_modules.nix b/nix/node_modules.nix index 9f99900f70a..e92a8a48849 100644 --- a/nix/node_modules.nix +++ b/nix/node_modules.nix @@ -54,6 +54,7 @@ stdenvNoCC.mkDerivation { --filter './packages/opencode' \ --filter './packages/desktop' \ --filter './packages/app' \ + --filter './packages/shared' \ --frozen-lockfile \ --ignore-scripts \ --no-progress diff --git a/package.json b/package.json index 27766c649b2..254cc89838c 100644 --- a/package.json +++ b/package.json @@ -4,13 +4,15 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.11", + "packageManager": "bun@1.3.13", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", - "dev:desktop": "bun --cwd packages/desktop tauri dev", + "dev-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup", + "dev:desktop": "bun --cwd packages/desktop-electron dev", "dev:web": "bun --cwd packages/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", + "lint": "oxlint", "typecheck": "bun turbo typecheck", "postinstall": "bun run --cwd packages/opencode fix-node-pty", "prepare": "husky", @@ -27,10 +29,13 @@ "catalog": { "@effect/opentelemetry": "4.0.0-beta.48", "@effect/platform-node": "4.0.0-beta.48", - "@types/bun": "1.3.11", + "@npmcli/arborist": "9.4.0", + "@types/bun": "1.3.12", "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", + "@opentui/core": "0.1.99", + "@opentui/solid": "0.1.99", "ulid": "3.0.1", "@kobalte/core": "0.13.11", "@types/luxon": "3.7.1", @@ -48,7 +53,7 @@ "drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4", "effect": "4.0.0-beta.48", - "ai": "6.0.158", + "ai": "6.0.168", "cross-spawn": "7.0.6", "hono": "4.12.12", "hono-openapi": "1.1.2", @@ -58,6 +63,7 @@ "marked-shiki": "1.2.1", "remend": "1.3.0", "@playwright/test": "1.59.1", + "semver": "7.7.4", "typescript": "5.8.2", "@typescript/native-preview": "7.0.0-dev.20260316.1", "zod": "4.1.8", @@ -82,6 +88,8 @@ "@typescript/native-preview": "catalog:", "glob": "13.0.5", "husky": "9.1.7", + "oxlint": "1.60.0", + "oxlint-tsgolint": "0.21.0", "prettier": "3.6.2", "semver": "^7.6.0", "sst": "3.18.10", @@ -134,9 +142,11 @@ "happy-dom": ">=20.8.9" }, "patchedDependencies": { + "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.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" + "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", + "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" }, - "version": "7.2.14", + "version": "7.2.30", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index f127033c221..9311d276b20 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.2.14", + "version": "7.2.30", "description": "", "type": "module", "exports": { @@ -42,7 +42,7 @@ "@kobalte/core": "catalog:", "@kilocode/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", - "@opencode-ai/util": "workspace:*", + "@opencode-ai/shared": "workspace:*", "@shikijs/transformers": "3.9.2", "@solid-primitives/active-element": "2.1.3", "@solid-primitives/audio": "1.4.2", diff --git a/packages/app/public/assets/JetBrainsMonoNerdFontMono-Regular.woff2 b/packages/app/public/assets/JetBrainsMonoNerdFontMono-Regular.woff2 new file mode 100644 index 00000000000..02a57c6f500 Binary files /dev/null and b/packages/app/public/assets/JetBrainsMonoNerdFontMono-Regular.woff2 differ diff --git a/packages/app/src/addons/serialize.test.ts b/packages/app/src/addons/serialize.test.ts index 7f6780557da..6828e60f847 100644 --- a/packages/app/src/addons/serialize.test.ts +++ b/packages/app/src/addons/serialize.test.ts @@ -180,8 +180,8 @@ describe("SerializeAddon", () => { await writeAndWait(term, input) const origLine = term.buffer.active.getLine(0) - const origFg = origLine!.getCell(0)!.getFgColor() - const origBg = origLine!.getCell(0)!.getBgColor() + const _origFg = origLine!.getCell(0)!.getFgColor() + const _origBg = origLine!.getCell(0)!.getBgColor() expect(origLine!.getCell(0)!.isBold()).toBe(1) const serialized = addon.serialize({ range: { start: 0, end: 0 } }) diff --git a/packages/app/src/addons/serialize.ts b/packages/app/src/addons/serialize.ts index 4cab55b3f2f..3823fb443af 100644 --- a/packages/app/src/addons/serialize.ts +++ b/packages/app/src/addons/serialize.ts @@ -258,8 +258,8 @@ class StringSerializeHandler extends BaseSerializeHandler { } protected _beforeSerialize(rows: number, start: number, _end: number): void { - this._allRows = new Array(rows) - this._allRowSeparators = new Array(rows) + this._allRows = Array.from({ length: rows }) + this._allRowSeparators = Array.from({ length: rows }) this._rowIndex = 0 this._currentRow = "" diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 932472892cf..6bec1de84f0 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -10,7 +10,7 @@ import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" -import { type Duration, Effect } from "effect" +import { Effect } from "effect" import { type Component, createMemo, @@ -121,10 +121,10 @@ function SessionProviders(props: ParentProps) { function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { return ( - }> - {props.appChildren} - {props.children} - + {/*}>*/} + {props.appChildren} + {props.children} + {/**/} ) } @@ -141,13 +141,11 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { }> - - - - {props.children} - - - + + + {props.children} + + @@ -156,11 +154,6 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { ) } -const effectMinDuration = - (duration: Duration.Input) => - (e: Effect.Effect) => - Effect.all([e, Effect.sleep(duration)], { concurrency: "unbounded" }).pipe(Effect.map((v) => v[0])) - function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { const server = useServer() const checkServerHealth = useCheckServerHealth() @@ -189,32 +182,41 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { ) return ( - } > + {/* + + + } + >*/} + {checkMode() === "blocking" ? startupHealthCheck() : startupHealthCheck.latest} { - if (checkMode() === "background") healthCheckActions.refetch() + if (checkMode() === "background") void healthCheckActions.refetch() }} onServerSelected={(key) => { setCheckMode("blocking") server.setActive(key) - healthCheckActions.refetch() + void healthCheckActions.refetch() }} /> } > {props.children} - + {/**/} + ) } @@ -289,20 +291,22 @@ export function AppInterface(props: { > - - - {routerProps.children}} - > - - - - - - - - + + + + {routerProps.children}} + > + + + + + + + + + diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index a92184c10a5..4822fa72666 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -327,7 +327,7 @@ export function DialogConnectProvider(props: { provider: string }) { if (loading()) return if (methods().length === 1) { auto = true - selectMethod(0) + void selectMethod(0) } }) @@ -373,7 +373,7 @@ export function DialogConnectProvider(props: { provider: string }) { key={(m) => m?.label} onSelect={async (selected, index) => { if (!selected) return - selectMethod(index) + void selectMethod(index) }} > {(i) => ( diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index eb962f47eb0..8eb12daf52e 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -9,9 +9,10 @@ import { createStore } from "solid-js/store" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { type LocalProject, getAvatarColors } from "@/context/layout" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { Avatar } from "@opencode-ai/ui/avatar" import { useLanguage } from "@/context/language" +import { getProjectAvatarSource } from "@/pages/layout/sidebar-items" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const @@ -26,8 +27,8 @@ export function DialogEditProject(props: { project: LocalProject }) { const [store, setStore] = createStore({ name: defaultName(), - color: props.project.icon?.color || "pink", - iconUrl: props.project.icon?.override || "", + color: props.project.icon?.color, + iconOverride: props.project.icon?.override, startup: props.project.commands?.start ?? "", dragOver: false, iconHover: false, @@ -39,7 +40,7 @@ export function DialogEditProject(props: { project: LocalProject }) { if (!file.type.startsWith("image/")) return const reader = new FileReader() reader.onload = (e) => { - setStore("iconUrl", e.target?.result as string) + setStore("iconOverride", e.target?.result as string) setStore("iconHover", false) } reader.readAsDataURL(file) @@ -68,7 +69,7 @@ export function DialogEditProject(props: { project: LocalProject }) { } function clearIcon() { - setStore("iconUrl", "") + setStore("iconOverride", "") } const saveMutation = useMutation(() => ({ @@ -81,17 +82,17 @@ export function DialogEditProject(props: { project: LocalProject }) { projectID: props.project.id, directory: props.project.worktree, name, - icon: { color: store.color, override: store.iconUrl }, + icon: { color: store.color || "", override: store.iconOverride || "" }, commands: { start }, }) - globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) + globalSync.project.icon(props.project.worktree, store.iconOverride || undefined) dialog.close() return } globalSync.project.meta(props.project.worktree, { name, - icon: { color: store.color, override: store.iconUrl || undefined }, + icon: { color: store.color || undefined, override: store.iconOverride || undefined }, commands: { start: start || undefined }, }) dialog.close() @@ -130,13 +131,13 @@ export function DialogEditProject(props: { project: LocalProject }) { classList={{ "border-text-interactive-base bg-surface-info-base/20": store.dragOver, "border-border-base hover:border-border-strong": !store.dragOver, - "overflow-hidden": !!store.iconUrl, + "overflow-hidden": !!store.iconOverride, }} onDrop={handleDrop} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onClick={() => { - if (store.iconUrl && store.iconHover) { + if (store.iconOverride && store.iconHover) { clearIcon() } else { iconInput?.click() @@ -144,7 +145,11 @@ export function DialogEditProject(props: { project: LocalProject }) { }} > } > - {language.t("dialog.project.edit.icon.alt")} + {(src) => ( + {language.t("dialog.project.edit.icon.alt")} + )}
@@ -174,8 +181,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
@@ -198,7 +205,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
- +
@@ -215,7 +222,10 @@ export function DialogEditProject(props: { project: LocalProject }) { "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": store.color !== color, }} - onClick={() => setStore("color", color)} + onClick={() => { + if (store.color === color && !props.project.icon?.url) return + setStore("color", store.color === color ? undefined : color) + }} > { const value = file.tab(path) - tabs().open(value) - file.load(path) + void tabs().open(value) + void file.load(path) if (!view().reviewPanel.opened()) view().reviewPanel.open() layout.fileTree.setTab("all") props.onOpenFile?.(path) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index ca4c42a3769..0cb5a2d6046 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -344,7 +344,7 @@ export function DialogSelectServer() { createEffect(() => { items() - refreshHealth() + void refreshHealth() const interval = setInterval(refreshHealth, 10_000) onCleanup(() => clearInterval(interval)) }) @@ -498,13 +498,13 @@ export function DialogSelectServer() { async function handleRemove(url: ServerConnection.Key) { server.remove(url) if ((await platform.getDefaultServer?.()) === url) { - platform.setDefaultServer?.(null) + void platform.setDefaultServer?.(null) } } return ( -
+
x.http.url} onSelect={(x) => { - if (x) select(x) + if (x) void select(x) }} divider={true} - class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" + class="flex-1 min-h-0 px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" > {(i) => { const key = ServerConnection.key(i) @@ -619,7 +619,7 @@ export function DialogSelectServer() { -
+
= (props) => { const sdk = useSDK() + const sync = useSync() const local = useLocal() const files = useFile() @@ -212,9 +215,9 @@ export const PromptInput: Component = (props) => { if (!view().reviewPanel.opened()) view().reviewPanel.open() layout.fileTree.setTab("all") const tab = files.tab(item.path) - tabs().open(tab) + void tabs().open(tab) tabs().setActive(tab) - Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus()) + void Promise.resolve(files.load(item.path)).finally(() => queueCommentFocus()) } const recent = createMemo(() => { @@ -267,7 +270,7 @@ export const PromptInput: Component = (props) => { const buttonsSpring = useSpring(() => (store.mode === "normal" ? 1 : 0), { visualDuration: 0.2, bounce: 0 }) const motion = (value: number) => ({ opacity: value, - transform: `scale(${0.95 + value * 0.05})`, + transform: `scale(${0.98 + value * 0.02})`, filter: `blur(${(1 - value) * 2}px)`, "pointer-events": value > 0.5 ? ("auto" as const) : ("none" as const), }) @@ -342,7 +345,7 @@ export const PromptInput: Component = (props) => { promptPlaceholder({ mode: store.mode, commentCount: commentCount(), - example: suggest() ? language.t(EXAMPLES[store.placeholder]) : "", + example: suggest() ? (store.mode === "shell" ? "git status" : language.t(EXAMPLES[store.placeholder])) : "", suggest: suggest(), t: (key, params) => language.t(key as Parameters[0], params as never), }), @@ -1139,7 +1142,7 @@ export const PromptInput: Component = (props) => { } if (working()) { - abort() + void abort() event.preventDefault() event.stopPropagation() return @@ -1205,7 +1208,7 @@ export const PromptInput: Component = (props) => { return } if (working()) { - abort() + void abort() event.preventDefault() } return @@ -1245,12 +1248,27 @@ export const PromptInput: Component = (props) => { ) { return } - handleSubmit(event) + void handleSubmit(event) } } + const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({ + queries: [loadAgentsQuery(sdk.directory), loadProvidersQuery(null), loadProvidersQuery(sdk.directory)], + })) + + const agentsLoading = () => agentsQuery.isLoading + const agentsShouldFadeIn = createMemo((prev) => prev ?? agentsLoading()) + const providersLoading = () => agentsLoading() || providersQuery.isLoading || globalProvidersQuery.isLoading + const providersShouldFadeIn = createMemo((prev) => prev ?? providersLoading()) + + const [promptReady] = createResource( + () => prompt.ready().promise, + (p) => p, + ) + return (
+ {(promptReady(), null)} (slashPopoverRef = el)} @@ -1348,15 +1366,13 @@ export const PromptInput: Component = (props) => { }} style={{ "padding-bottom": space }} /> - -
- {placeholder()} -
-
+
+ {placeholder()} +
= (props) => { @@ -1439,62 +1454,114 @@ export const PromptInput: Component = (props) => { >
- {language.t("prompt.mode.shell")} -
+ + {language.t("prompt.mode.shell")} +
+
-
-
- + +
- { + local.agent.set(value) + restoreFocus() + }} + class="capitalize max-w-[160px] text-text-base" + valueClass="truncate text-13-regular text-text-base" + triggerStyle={control()} + triggerProps={{ "data-action": "prompt-agent" }} + variant="ghost" + /> + +
+
+ + +
+ 0} + fallback={ + + + + } + > - + - } - > - +
+ 2}> +
- - - - - - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - - - - + (x === "default" ? language.t("common.default") : x)} - onSelect={(value) => { - local.model.variant.set(value === "default" ? undefined : value) - restoreFocus() - }} - class="capitalize max-w-[160px] text-text-base" - valueClass="truncate text-13-regular text-text-base" - triggerStyle={control()} - triggerProps={{ "data-action": "prompt-model-variant" }} - variant="ghost" - /> - -
+
diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index 9f0a8defbe5..9a62d0b71d1 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.ts @@ -1,4 +1,4 @@ -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@kilocode/sdk/v2/client" import type { FileSelection } from "@/context/file" import { encodeFilePath } from "@/context/file/path" diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index b138fe3ef69..9f20f1c04b0 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -2,7 +2,7 @@ import { Component, For, Show } from "solid-js" import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path" +import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/shared/util/path" import type { ContextItem } from "@/context/prompt" type PromptContextItem = ContextItem & { key: string } diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts index 5f6aa59e9a4..d4caead0d2e 100644 --- a/packages/app/src/components/prompt-input/placeholder.test.ts +++ b/packages/app/src/components/prompt-input/placeholder.test.ts @@ -12,7 +12,7 @@ describe("promptPlaceholder", () => { suggest: true, t, }) - expect(value).toBe("prompt.placeholder.shell") + expect(value).toBe("prompt.placeholder.shell:example") }) test("returns summarize placeholders for comment context", () => { diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts index 395fee51b1c..6669f136147 100644 --- a/packages/app/src/components/prompt-input/placeholder.ts +++ b/packages/app/src/components/prompt-input/placeholder.ts @@ -7,7 +7,7 @@ type PromptPlaceholderInput = { } export function promptPlaceholder(input: PromptPlaceholderInput) { - if (input.mode === "shell") return input.t("prompt.placeholder.shell") + if (input.mode === "shell") return input.t("prompt.placeholder.shell", { example: input.example }) if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments") if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment") if (!input.suggest) return input.t("prompt.placeholder.simple") diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index 65eb01c797b..0c8c9592349 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -1,7 +1,7 @@ import { Component, For, Match, Show, Switch } from "solid-js" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Icon } from "@opencode-ai/ui/icon" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" export type AtOption = | { type: "agent"; name: string; display: string } diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 40686e4eab4..e1b959e8dd7 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -74,7 +74,7 @@ beforeAll(async () => { showToast: () => 0, })) - mock.module("@opencode-ai/util/encode", () => ({ + mock.module("@opencode-ai/shared/util/encode", () => ({ base64Encode: (value: string) => value, })) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 584f91f4e9c..fdd2889efa3 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,7 +1,7 @@ import type { Message, Session } from "@kilocode/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" -import { base64Encode } from "@opencode-ai/util/encode" -import { Binary } from "@opencode-ai/util/binary" +import { base64Encode } from "@opencode-ai/shared/util/encode" +import { Binary } from "@opencode-ai/shared/util/binary" import { useNavigate, useParams } from "@solidjs/router" import { batch, type Accessor } from "solid-js" import type { FileSelection } from "@/context/file" @@ -295,7 +295,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const mode = input.mode() if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) { - if (input.working()) abort() + if (input.working()) void abort() return } diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index d7c249ab031..6b7fe4ef7de 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -24,7 +24,7 @@ function openSessionContext(args: { }) { if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open() if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all") - args.tabs.open("context") + void args.tabs.open("context") args.tabs.setActive("context") } diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index c4cca9cf660..2fa44f1c7e1 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -1,8 +1,8 @@ import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" import type { JSX } from "solid-js" import { useSync } from "@/context/sync" -import { checksum } from "@opencode-ai/util/encode" -import { findLast } from "@opencode-ai/util/array" +import { checksum } from "@opencode-ai/shared/util/encode" +import { findLast } from "@opencode-ai/shared/util/array" import { same } from "@/utils/same" import { Icon } from "@opencode-ai/ui/icon" import { Accordion } from "@opencode-ai/ui/accordion" diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 495b3234058..d61335d9f0d 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -7,8 +7,8 @@ import { Keybind } from "@opencode-ai/ui/keybind" import { Spinner } from "@opencode-ai/ui/spinner" import { showToast } from "@opencode-ai/ui/toast" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { getFilename } from "@opencode-ai/util/path" -import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" +import { getFilename } from "@opencode-ai/shared/util/path" +import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useCommand } from "@/context/command" @@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useServer } from "@/context/server" +import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { focusTerminalById } from "@/pages/session/helpers" @@ -134,6 +135,7 @@ export function SessionHeader() { const server = useServer() const platform = usePlatform() const language = useLanguage() + const settings = useSettings() const sync = useSync() const terminal = useTerminal() const { params, view } = useSessionLayout() @@ -151,6 +153,11 @@ export function SessionHeader() { }) const hotkey = createMemo(() => command.keybind("file.open")) const os = createMemo(() => detectOS(platform)) + const isDesktopBeta = platform.platform === "desktop" && import.meta.env.VITE_KILO_CHANNEL === "beta" + const search = createMemo(() => !isDesktopBeta || settings.general.showSearch()) + const tree = createMemo(() => !isDesktopBeta || settings.general.showFileTree()) + const term = createMemo(() => !isDesktopBeta || settings.general.showTerminal()) + const status = createMemo(() => !isDesktopBeta || settings.general.showStatus()) const [exists, setExists] = createStore>>({ finder: true, @@ -262,12 +269,16 @@ export function SessionHeader() { .catch((err: unknown) => showRequestError(language, err)) } - const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) - const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) + const [centerMount, setCenterMount] = createSignal(null) + const [rightMount, setRightMount] = createSignal(null) + onMount(() => { + setCenterMount(document.getElementById("opencode-titlebar-center")) + setRightMount(document.getElementById("opencode-titlebar-right")) + }) return ( <> - + {(mount) => ( - + + +
diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index e4ef3639362..d2cac28fc43 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -5,7 +5,7 @@ import { useSDK } from "@/context/sdk" import { useLanguage } from "@/context/language" import { Icon } from "@opencode-ai/ui/icon" import { Mark } from "@opencode-ai/ui/logo" -import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { getDirectory, getFilename } from "@opencode-ai/shared/util/path" const MAIN_WORKTREE = "main" const CREATE_WORKTREE = "create" diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index dfda91c160a..fb2275c445a 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -5,7 +5,7 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tabs } from "@opencode-ai/ui/tabs" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { useCommand } from "@/context/command" diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index ba697f91af6..2d88ed18064 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -44,7 +44,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => const close = () => { const count = terminal.all().length - terminal.close(props.terminal.id) + void terminal.close(props.terminal.id) if (count === 1) { props.onClose?.() } diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 43c45f44afc..50c511d0dd9 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -19,6 +19,9 @@ import { sansDefault, sansFontFamily, sansInput, + terminalDefault, + terminalFontFamily, + terminalInput, useSettings, } from "@/context/settings" import { decode64 } from "@/utils/base64" @@ -106,6 +109,7 @@ export const SettingsGeneral: Component = () => { permission.disableAutoAccept(params.id, value) } + const desktop = createMemo(() => platform.platform === "desktop") const check = () => { if (!platform.checkUpdate) return @@ -124,27 +128,25 @@ export const SettingsGeneral: Component = () => { return } - const actions = - platform.update && platform.restart - ? [ - { - label: language.t("toast.update.action.installRestart"), - onClick: async () => { - await platform.update!() - await platform.restart!() - }, + const actions = platform.updateAndRestart + ? [ + { + label: language.t("toast.update.action.installRestart"), + onClick: async () => { + await platform.updateAndRestart!() }, - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] - : [ - { - label: language.t("toast.update.action.notYet"), - onClick: "dismiss" as const, - }, - ] + }, + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] + : [ + { + label: language.t("toast.update.action.notYet"), + onClick: "dismiss" as const, + }, + ] showToast({ persistent: true, @@ -180,6 +182,7 @@ export const SettingsGeneral: Component = () => { const soundOptions = [noneSound, ...SOUND_OPTIONS] const mono = () => monoInput(settings.appearance.font()) const sans = () => sansInput(settings.appearance.uiFont()) + const terminal = () => terminalInput(settings.appearance.terminalFont()) const soundSelectProps = ( enabled: () => boolean, @@ -275,6 +278,86 @@ export const SettingsGeneral: Component = () => { />
+ + +
+ settings.general.setShowSessionProgressBar(checked)} + /> +
+
+ +
+ ) + + const AdvancedSection = () => ( +
+

{language.t("settings.general.section.advanced")}

+ + + +
+ settings.general.setShowFileTree(checked)} + /> +
+
+ + +
+ settings.general.setShowNavigation(checked)} + /> +
+
+ + +
+ settings.general.setShowSearch(checked)} + /> +
+
+ + +
+ settings.general.setShowTerminal(checked)} + /> +
+
+ + +
+ settings.general.setShowStatus(checked)} + /> +
+
) @@ -382,6 +465,29 @@ export const SettingsGeneral: Component = () => { />
+ + +
+ settings.appearance.setTerminalFont(value)} + placeholder={terminalDefault} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + class="text-12-regular" + style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }} + /> +
+
) @@ -527,6 +633,7 @@ export const SettingsGeneral: Component = () => {
) + console.log(import.meta.env) return (
@@ -609,6 +716,10 @@ export const SettingsGeneral: Component = () => { ) }} + + + +
) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 96a865b9e8e..ff5ff9dada8 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -11,7 +11,7 @@ import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { useServer } from "@/context/server" -import { monoFontFamily, useSettings } from "@/context/settings" +import { terminalFontFamily, useSettings } from "@/context/settings" import type { LocalPTY } from "@/context/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { terminalWriter } from "@/utils/terminal-writer" @@ -191,7 +191,7 @@ export const Terminal = (props: TerminalProps) => { const scrollY = typeof local.pty.scrollY === "number" ? local.pty.scrollY : undefined let ws: WebSocket | undefined let term: Term | undefined - let ghostty: Ghostty + let _ghostty: Ghostty let serializeAddon: SerializeAddon let fitAddon: FitAddon let handleResize: () => void @@ -300,7 +300,7 @@ export const Terminal = (props: TerminalProps) => { }) createEffect(() => { - const font = monoFontFamily(settings.appearance.font()) + const font = terminalFontFamily(settings.appearance.terminalFont()) if (!term) return setOptionIfSupported(term, "fontFamily", font) scheduleFit() @@ -360,7 +360,7 @@ export const Terminal = (props: TerminalProps) => { cols: restoreSize?.cols, rows: restoreSize?.rows, fontSize: 14, - fontFamily: monoFontFamily(settings.appearance.font()), + fontFamily: terminalFontFamily(settings.appearance.terminalFont()), allowTransparency: false, convertEol: false, theme: terminalColors(), @@ -372,7 +372,7 @@ export const Terminal = (props: TerminalProps) => { cleanup() return } - ghostty = g + _ghostty = g term = t output = terminalWriter((data, done) => t.write(data, () => { @@ -415,7 +415,7 @@ export const Terminal = (props: TerminalProps) => { if (local.autoFocus !== false) focusTerminal() if (typeof document !== "undefined" && document.fonts) { - document.fonts.ready.then(scheduleFit) + void document.fonts.ready.then(scheduleFit) } const onResize = t.onResize((size) => { @@ -634,7 +634,7 @@ export const Terminal = (props: TerminalProps) => { tabIndex={-1} style={{ "background-color": terminalColors().background }} classList={{ - ...(local.classList ?? {}), + ...local.classList, "select-text": true, "size-full px-6 py-3 font-mono relative overflow-hidden": true, [local.class ?? ""]: !!local.class, diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 0a41f31196e..29e9828a693 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js" +import { createEffect, createMemo, Show, untrack } from "solid-js" import { createStore } from "solid-js/store" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" +import { useSettings } from "@/context/settings" import { applyPath, backPath, forwardPath } from "./titlebar-history" type TauriDesktopWindow = { @@ -40,6 +41,7 @@ export function Titlebar() { const platform = usePlatform() const command = useCommand() const language = useLanguage() + const settings = useSettings() const theme = useTheme() const navigate = useNavigate() const location = useLocation() @@ -78,6 +80,7 @@ export function Titlebar() { const canBack = createMemo(() => history.index > 0) const canForward = createMemo(() => history.index < history.stack.length - 1) const hasProjects = createMemo(() => layout.projects.list().length > 0) + const nav = createMemo(() => import.meta.env.VITE_KILO_CHANNEL !== "beta" || settings.general.showNavigation()) const back = () => { const next = backPath(history) @@ -252,41 +255,48 @@ export function Titlebar() {
- -
- -
-
+
+ +
+ +
+
+
+ {import.meta.env.VITE_KILO_CHANNEL && + ["beta", "dev"].includes(import.meta.env.VITE_KILO_CHANNEL) && ( // kilocode_change +
+ {import.meta.env.VITE_KILO_CHANNEL.toUpperCase()} +
+ )} +
-
diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index f8fec7142d8..8998731a6cb 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -3,7 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" import { useParams } from "@solidjs/router" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { useSDK } from "./sdk" import { useSync } from "./sync" import { useLanguage } from "@/context/language" diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 35fd59bde1b..5c81174426f 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -128,6 +128,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo if (started) return run started = true run = (async () => { + // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit while (!abort.signal.aborted && started) { attempt = new AbortController() lastEventAt = Date.now() diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index b28554be82c..edebef2b909 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -8,11 +8,10 @@ import type { Todo, } from "@kilocode/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" -import { getFilename } from "@opencode-ai/util/path" -import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" +import { getFilename } from "@opencode-ai/shared/util/path" +import { batch, createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" -import { Persist, persisted } from "@/utils/persist" import type { InitError } from "../pages/error" import { useGlobalSDK } from "./global-sdk" import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap" @@ -24,8 +23,8 @@ import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" -import { sanitizeProject } from "./global-sync/utils" import { formatServerError } from "@/utils/server-errors" +import { queryOptions, skipToken, useQueryClient } from "@tanstack/solid-query" type GlobalStore = { ready: boolean @@ -41,6 +40,9 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } +export const loadSessionsQuery = (directory: string) => + queryOptions({ queryKey: [directory, "loadSessions"], queryFn: skipToken }) + function createGlobalSync() { const globalSDK = useGlobalSDK() const language = useLanguage() @@ -52,53 +54,30 @@ function createGlobalSync() { const sessionLoads = new Map>() const sessionMeta = new Map() - const [projectCache, setProjectCache, projectInit] = persisted( - Persist.global("globalSync.project", ["globalSync.project.v1"]), - createStore({ value: [] as Project[] }), - ) - const [globalStore, setGlobalStore] = createStore({ ready: false, path: { state: "", config: "", worktree: "", directory: "", home: "" }, - project: projectCache.value, + project: [], session_todo: {}, provider: { all: [], connected: [], default: {} }, provider_auth: {}, config: {}, reload: undefined, }) + const queryClient = useQueryClient() - let active = true - let projectWritten = false let bootedAt = 0 let bootingRoot = false let eventFrame: number | undefined let eventTimer: ReturnType | undefined - onCleanup(() => { - active = false - }) onCleanup(() => { if (eventFrame !== undefined) cancelAnimationFrame(eventFrame) if (eventTimer !== undefined) clearTimeout(eventTimer) }) - const cacheProjects = () => { - setProjectCache( - "value", - untrack(() => globalStore.project.map(sanitizeProject)), - ) - } - - const setProjects = (next: Project[] | ((draft: Project[]) => void)) => { - projectWritten = true - if (typeof next === "function") { - setGlobalStore("project", produce(next)) - cacheProjects() - return - } + const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => { setGlobalStore("project", next) - cacheProjects() } const setBootStore = ((...input: unknown[]) => { @@ -111,22 +90,12 @@ function createGlobalSync() { const set = ((...input: unknown[]) => { if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) { - setProjects(input[1] as Project[] | ((draft: Project[]) => void)) + setProjects(input[1] as Project[] | ((draft: Project[]) => Project[])) return input[1] } return (setGlobalStore as (...args: unknown[]) => unknown)(...input) }) as typeof setGlobalStore - if (projectInit instanceof Promise) { - void projectInit.then(() => { - if (!active) return - if (projectWritten) return - const cached = projectCache.value - if (cached.length === 0) return - setGlobalStore("project", cached) - }) - } - const setSessionTodo = (sessionID: string, todos: Todo[] | undefined) => { if (!sessionID) return if (!todos) { @@ -198,46 +167,55 @@ function createGlobalSync() { } const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) - const promise = loadRootSessionsWithFallback({ - directory, - limit, - list: (query) => globalSDK.client.session.list(query), - }) - .then((x) => { - const nonArchived = (x.data ?? []) - .filter((s) => !!s?.id) - .filter((s) => !s.time?.archived) - .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - const limit = store.limit - const childSessions = store.session.filter((s) => !!s.parentID) - const sessions = trimSessions([...nonArchived, ...childSessions], { - limit, - permission: store.permission, - }) - setStore( - "sessionTotal", - estimateRootSessionTotal({ - count: nonArchived.length, - limit: x.limit, - limited: x.limited, - }), - ) - setStore("session", reconcile(sessions, { key: "id" })) - cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo) - sessionMeta.set(directory, { limit }) - }) - .catch((err) => { - console.error("Failed to load sessions", err) - const project = getFilename(directory) - showToast({ - variant: "error", - title: language.t("toast.session.listFailed.title", { project }), - description: formatServerError(err, language.t), - }) + const promise = queryClient + .fetchQuery({ + ...loadSessionsQuery(directory), + queryFn: () => + loadRootSessionsWithFallback({ + directory, + limit, + list: (query) => globalSDK.client.session.list(query), + }) + .then((x) => { + const nonArchived = (x.data ?? []) + .filter((s) => !!s?.id) + .filter((s) => !s.time?.archived) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + const limit = store.limit + const childSessions = store.session.filter((s) => !!s.parentID) + const sessions = trimSessions([...nonArchived, ...childSessions], { + limit, + permission: store.permission, + }) + batch(() => { + setStore( + "sessionTotal", + estimateRootSessionTotal({ + count: nonArchived.length, + limit: x.limit, + limited: x.limited, + }), + ) + setStore("session", reconcile(sessions, { key: "id" })) + cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo) + }) + sessionMeta.set(directory, { limit }) + }) + .catch((err) => { + console.error("Failed to load sessions", err) + const project = getFilename(directory) + showToast({ + variant: "error", + title: language.t("toast.session.listFailed.title", { project }), + description: formatServerError(err, language.t), + }) + }) + .then(() => null), }) + .then(() => {}) sessionLoads.set(directory, promise) - promise.finally(() => { + void promise.finally(() => { sessionLoads.delete(directory) children.unpin(directory) }) @@ -250,7 +228,7 @@ function createGlobalSync() { if (pending) return pending children.pin(directory) - const promise = (async () => { + const promise = Promise.resolve().then(async () => { const child = children.ensureChild(directory) const cache = children.vcsCache.get(directory) if (!cache) return @@ -269,11 +247,12 @@ function createGlobalSync() { vcsCache: cache, loadSessions, translate: language.t, + queryClient, }) - })() + }) booting.set(directory, promise) - promise.finally(() => { + void promise.finally(() => { booting.delete(directory) children.unpin(directory) }) @@ -285,6 +264,19 @@ function createGlobalSync() { const event = e.details const recent = bootingRoot || Date.now() - bootedAt < 1500 + if (event.type === "session.error") { + const error = event.properties.error + if (error?.name !== "MessageAbortedError") { + console.error("[global-sync] session error", { + scope: directory === "global" ? "global" : "workspace", + directory: directory === "global" ? undefined : directory, + project: directory === "global" ? undefined : getFilename(directory), + sessionID: event.properties.sessionID, + error, + }) + } + } + if (directory === "global") { applyGlobalEvent({ event, @@ -317,7 +309,7 @@ function createGlobalSync() { setSessionTodo, vcsCache: children.vcsCache.get(directory), loadLsp: () => { - sdkFor(directory) + void sdkFor(directory) .lsp.status() .then((x) => { setStore("lsp", x.data ?? []) @@ -346,6 +338,7 @@ function createGlobalSync() { translate: language.t, formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), setGlobalStore: setBootStore, + queryClient, }) bootedAt = Date.now() } finally { @@ -359,13 +352,13 @@ function createGlobalSync() { eventFrame = undefined eventTimer = setTimeout(() => { eventTimer = undefined - globalSDK.event.start() + void globalSDK.event.start() }, 0) }) } else { eventTimer = setTimeout(() => { eventTimer = undefined - globalSDK.event.start() + void globalSDK.event.start() }, 0) } void bootstrap() diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 1e0d24d730a..0b4d843026a 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -11,13 +11,14 @@ import type { Todo, } from "@kilocode/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" -import { getFilename } from "@opencode-ai/util/path" -import { retry } from "@opencode-ai/util/retry" +import { getFilename } from "@opencode-ai/shared/util/path" +import { retry } from "@opencode-ai/shared/util/retry" import { batch } from "solid-js" import { reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State, VcsCache } from "./types" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { formatServerError } from "@/utils/server-errors" +import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query" type GlobalStore = { ready: boolean @@ -65,28 +66,13 @@ function runAll(list: Array<() => Promise>) { return Promise.allSettled(list.map((item) => item())) } -function showErrors(input: { - errors: unknown[] - title: string - translate: (key: string, vars?: Record) => string - formatMoreCount: (count: number) => string -}) { - if (input.errors.length === 0) return - const message = formatServerError(input.errors[0], input.translate) - const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : "" - showToast({ - variant: "error", - title: input.title, - description: message + more, - }) -} - export async function bootstrapGlobal(input: { globalSDK: KiloClient requestFailedTitle: string translate: (key: string, vars?: Record) => string formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction + queryClient: QueryClient }) { const fast = [ () => @@ -95,15 +81,20 @@ export async function bootstrapGlobal(input: { input.setGlobalStore("config", x.data!) }), ), - () => - retry(() => - input.globalSDK.provider.list().then((x) => { - input.setGlobalStore("provider", normalizeProviderList(x.data!)) - }), - ), ] const slow = [ + () => + input.queryClient.fetchQuery({ + ...loadProvidersQuery(null), + queryFn: () => + retry(() => + input.globalSDK.provider.list().then((x) => { + input.setGlobalStore("provider", normalizeProviderList(x.data!)) + return null + }), + ), + }), () => retry(() => input.globalSDK.path.get().then((x) => { @@ -188,6 +179,47 @@ function warmSessions(input: { ).then(() => undefined) } +export const loadProvidersQuery = (directory: string | null) => + queryOptions({ queryKey: [directory, "providers"], queryFn: skipToken }) + +export const loadAgentsQuery = ( + directory: string | null, + sdk?: KiloClient, + transform?: (x: Awaited>) => void, +) => + queryOptions({ + queryKey: [directory, "agents"], + queryFn: + sdk && transform + ? () => + retry(() => + sdk.app + .agents() + .then(transform) + .then(() => null), + ) + : skipToken, + }) + +export const loadPathQuery = ( + directory: string | null, + sdk?: KiloClient, + transform?: (x: Awaited>) => void, +) => + queryOptions({ + queryKey: [directory, "path"], + queryFn: + sdk && transform + ? () => + retry(() => + sdk.path.get().then(async (x) => { + transform(x) + return x.data! + }), + ) + : skipToken, + }) + export async function bootstrapDirectory(input: { directory: string sdk: KiloClient @@ -202,6 +234,7 @@ export async function bootstrapDirectory(input: { project: Project[] provider: ProviderListResponse } + queryClient: QueryClient }) { const loading = input.store.status !== "complete" const seededProject = projectID(input.directory, input.global.project) @@ -223,139 +256,131 @@ export async function bootstrapDirectory(input: { input.setStore("lsp", []) if (loading) input.setStore("status", "partial") - const fast = [ - () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))), - () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), - () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), - ] - - const slow = [ - () => - seededProject - ? Promise.resolve() - : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), - () => - seededPath - ? Promise.resolve() - : retry(() => - input.sdk.path.get().then((x) => { - input.setStore("path", x.data!) + const rev = (providerRev.get(input.directory) ?? 0) + 1 + providerRev.set(input.directory, rev) + ;(async () => { + const slow = [ + () => Promise.resolve(input.loadSessions(input.directory)), + () => + input.queryClient.ensureQueryData( + loadAgentsQuery(input.directory, input.sdk, (x) => input.setStore("agent", normalizeAgentList(x.data))), + ), + () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), + () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), + !seededProject && + (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))), + !seededPath && + (() => + input.queryClient.ensureQueryData( + loadPathQuery(input.directory, input.sdk, (x) => { const next = projectID(x.data?.directory ?? input.directory, input.global.project) if (next) input.setStore("project", next) }), - ), - () => - retry(() => - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs - input.setStore("vcs", next) - if (next) input.vcsCache.setStore("value", next) + )), + () => + retry(() => + input.sdk.vcs.get().then((x) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next) input.vcsCache.setStore("value", next) + }), + ), + () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), + () => + retry(() => + input.sdk.permission.list().then((x) => { + const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id) + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() => + batch(() => { + for (const sessionID of Object.keys(input.store.permission)) { + if (grouped[sessionID]) continue + input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + input.setStore( + "permission", + sessionID, + reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }), + ) + }), + ), + () => + retry(() => + input.sdk.question.list().then((x) => { + const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id) + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() => + batch(() => { + for (const sessionID of Object.keys(input.store.question)) { + if (grouped[sessionID]) continue + input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + input.setStore( + "question", + sessionID, + reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }), + ) + }), + ), + () => Promise.resolve(input.loadSessions(input.directory)), + () => + retry(() => + input.sdk.mcp.status().then((x) => { + input.setStore("mcp", x.data!) + input.setStore("mcp_ready", true) + }), + ), + () => + input.queryClient.ensureQueryData({ + ...loadProvidersQuery(input.directory), + queryFn: () => + retry(() => input.sdk.provider.list()) + .then((x) => { + if (providerRev.get(input.directory) !== rev) return + input.setStore("provider", normalizeProviderList(x.data!)) + input.setStore("provider_ready", true) + }) + .catch((err) => { + if (providerRev.get(input.directory) !== rev) console.error("Failed to refresh provider list", err) + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(err, input.translate), + }) + }) + .then(() => null), }), - ), - () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), - () => - retry(() => - input.sdk.permission.list().then((x) => { - const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id) - const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), - ) - return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() => - batch(() => { - for (const sessionID of Object.keys(input.store.permission)) { - if (grouped[sessionID]) continue - input.setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - input.setStore( - "permission", - sessionID, - reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }), - ) - }), - ), - () => - retry(() => - input.sdk.question.list().then((x) => { - const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id) - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) - return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() => - batch(() => { - for (const sessionID of Object.keys(input.store.question)) { - if (grouped[sessionID]) continue - input.setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - input.setStore( - "question", - sessionID, - reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }), - ) - }), - ), - () => Promise.resolve(input.loadSessions(input.directory)), - () => - retry(() => - input.sdk.mcp.status().then((x) => { - input.setStore("mcp", x.data!) - input.setStore("mcp_ready", true) - }), - ), - ] + ].filter(Boolean) as (() => Promise)[] - const errs = errors(await runAll(fast)) - if (errs.length > 0) { - console.error("Failed to bootstrap instance", errs[0]) - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(errs[0], input.translate), - }) - } - - await waitForPaint() - const slowErrs = errors(await runAll(slow)) - if (slowErrs.length > 0) { - console.error("Failed to finish bootstrap instance", slowErrs[0]) - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(slowErrs[0], input.translate), - }) - } - - if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") - - const rev = (providerRev.get(input.directory) ?? 0) + 1 - providerRev.set(input.directory, rev) - void retry(() => input.sdk.provider.list()) - .then((x) => { - if (providerRev.get(input.directory) !== rev) return - input.setStore("provider", normalizeProviderList(x.data!)) - input.setStore("provider_ready", true) - }) - .catch((err) => { - if (providerRev.get(input.directory) !== rev) return - console.error("Failed to refresh provider list", err) + await waitForPaint() + const slowErrs = errors(await runAll(slow)) + if (slowErrs.length > 0) { + console.error("Failed to finish bootstrap instance", slowErrs[0]) const project = getFilename(input.directory) showToast({ variant: "error", title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), + description: formatServerError(slowErrs[0], input.translate), }) - }) + } + + if (loading && slowErrs.length === 0) input.setStore("status", "complete") + })() } diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index b31e1927e85..1545fb42089 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -14,6 +14,8 @@ import { type VcsCache, } from "./types" import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" +import { useQuery } from "@tanstack/solid-query" +import { loadPathQuery } from "./bootstrap" export function createChildStoreManager(input: { owner: Owner @@ -154,16 +156,21 @@ export function createChildStoreManager(input: { const init = () => createRoot((dispose) => { - const initialMeta = meta[0].value const initialIcon = icon[0].value + + const pathQuery = useQuery(() => loadPathQuery(directory)) const child = createStore({ project: "", - projectMeta: initialMeta, + projectMeta: undefined, icon: initialIcon, provider_ready: false, provider: { all: [], connected: [], default: {} }, config: {}, - path: { state: "", config: "", worktree: "", directory: "", home: "" }, + get path() { + if (pathQuery.isLoading || !pathQuery.data) + return { state: "", config: "", worktree: "", directory: "", home: "" } + return pathQuery.data + }, status: "loading" as const, agent: [], command: [], @@ -200,11 +207,6 @@ export function createChildStoreManager(input: { child[1]("vcs", (value) => value ?? cached) }) - onPersistedInit(meta[2], () => { - if (child[0].projectMeta !== initialMeta) return - child[1]("projectMeta", meta[0].value) - }) - onPersistedInit(icon[2], () => { if (child[0].icon !== initialIcon) return child[1]("icon", icon[0].value) @@ -243,8 +245,8 @@ export function createChildStoreManager(input: { const cached = metaCache.get(directory) if (!cached) return const previous = store.projectMeta ?? {} - const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon - const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands + const icon = patch.icon ? { ...previous.icon, ...patch.icon } : previous.icon + const commands = patch.commands ? { ...previous.commands, ...patch.commands } : previous.commands const next = { ...previous, ...patch, diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index c0f34db17bf..9196d5b4f76 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -1,4 +1,4 @@ -import { Binary } from "@opencode-ai/util/binary" +import { Binary } from "@opencode-ai/shared/util/binary" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { Message, @@ -21,7 +21,7 @@ const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"]) export function applyGlobalEvent(input: { event: { type: string; properties?: unknown } project: Project[] - setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void + setGlobalProject: (next: Project[] | ((draft: Project[]) => Project[])) => void refresh: () => void }) { if (input.event.type === "global.disposed" || input.event.type === "server.connected") { @@ -33,14 +33,18 @@ export function applyGlobalEvent(input: { const properties = input.event.properties as Project const result = Binary.search(input.project, properties.id, (s) => s.id) if (result.found) { - input.setGlobalProject((draft) => { - draft[result.index] = { ...draft[result.index], ...properties } - }) + input.setGlobalProject( + produce((draft) => { + draft[result.index] = { ...draft[result.index], ...properties } + }), + ) return } - input.setGlobalProject((draft) => { - draft.splice(result.index, 0, properties) - }) + input.setGlobalProject( + produce((draft) => { + draft.splice(result.index, 0, properties) + }), + ) } function cleanupSessionCaches( diff --git a/packages/app/src/context/global-sync/queue.ts b/packages/app/src/context/global-sync/queue.ts index c3468583b93..5c228dac043 100644 --- a/packages/app/src/context/global-sync/queue.ts +++ b/packages/app/src/context/global-sync/queue.ts @@ -63,6 +63,7 @@ export function createRefreshQueue(input: QueueInput) { } } finally { running = false + // oxlint-disable-next-line no-unsafe-finally -- intentional: early return skips schedule() when paused if (input.paused()) return if (root || queued.size) schedule() } diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 2f822076ede..a1a51842684 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -8,7 +8,6 @@ import type { Part, Path, PermissionRequest, - Project, ProviderListResponse, QuestionRequest, Session, diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 1a8a1531a6b..603c3c5961c 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -344,7 +344,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( return } - setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next })) + setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...prev, ...next })) prune(keep) }, }) @@ -391,37 +391,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( ? globalSync.data.project.find((x) => x.id === projectID) : globalSync.data.project.find((x) => x.worktree === project.worktree) - const local = childStore.projectMeta - const localOverride = - local?.name !== undefined || - local?.commands?.start !== undefined || - local?.icon?.override !== undefined || - local?.icon?.color !== undefined - - const base = { - ...(metadata ?? {}), - ...project, - icon: { - url: metadata?.icon?.url, - override: metadata?.icon?.override ?? childStore.icon, - color: metadata?.icon?.color, - }, - } - - const isGlobal = projectID === "global" || (metadata?.id === undefined && localOverride) - if (!isGlobal) return base - - return { - ...base, - id: base.id ?? "global", - name: local?.name, - commands: local?.commands, - icon: { - url: base.icon?.url, - override: local?.icon?.override, - color: local?.icon?.color, - }, - } + return { ...metadata, ...project } } const roots = createMemo(() => { @@ -516,7 +486,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } for (const project of projects) { - if (project.icon?.color) continue + if (project.icon?.color || project.icon?.override || project.icon?.url) continue const worktree = project.worktree const existing = colors[worktree] const color = existing ?? pickAvailableColor(used) @@ -582,7 +552,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( open(directory: string) { const root = rootFor(directory) if (server.projects.list().find((x) => x.worktree === root)) return - globalSync.project.loadSessions(root) + void globalSync.project.loadSessions(root) server.projects.open(root) }, close(directory: string) { diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 28ce2770de9..0b0972ee670 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -1,5 +1,5 @@ import { createSimpleContext } from "@opencode-ai/ui/context" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { useParams } from "@solidjs/router" import { batch, createEffect, createMemo } from "solid-js" import { createStore } from "solid-js/store" diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index ee06e394f1b..9c30ed269d6 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -7,8 +7,8 @@ import { useGlobalSync } from "./global-sync" import { usePlatform } from "@/context/platform" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" -import { Binary } from "@opencode-ai/util/binary" -import { base64Encode } from "@opencode-ai/util/encode" +import { Binary } from "@opencode-ai/shared/util/binary" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { decode64 } from "@/utils/base64" import { EventSessionError } from "@kilocode/sdk/v2" import { Persist, persisted } from "@/utils/persist" diff --git a/packages/app/src/context/permission-auto-respond.test.ts b/packages/app/src/context/permission-auto-respond.test.ts index 547de1c39ae..cb927bbd317 100644 --- a/packages/app/src/context/permission-auto-respond.test.ts +++ b/packages/app/src/context/permission-auto-respond.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { PermissionRequest, Session } from "@kilocode/sdk/v2/client" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { autoRespondsPermission, isDirectoryAutoAccepting } from "./permission-auto-respond" const session = (input: { id: string; parentID?: string }) => diff --git a/packages/app/src/context/permission-auto-respond.ts b/packages/app/src/context/permission-auto-respond.ts index b206deedff9..2ebca34347d 100644 --- a/packages/app/src/context/permission-auto-respond.ts +++ b/packages/app/src/context/permission-auto-respond.ts @@ -1,4 +1,4 @@ -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" export function acceptKey(sessionID: string, directory?: string) { if (!directory) return sessionID diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 3bdc46391b6..fd89bf51ba7 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -49,11 +49,11 @@ export type Platform = { /** Storage mechanism, defaults to localStorage */ storage?: (name?: string) => SyncStorage | AsyncStorage - /** Check for updates (Tauri only) */ + /** Check for a downloadable desktop update */ checkUpdate?(): Promise - /** Install updates (Tauri only) */ - update?(): Promise + /** Install the downloaded update using the platform restart flow */ + updateAndRestart?(): Promise /** Fetch override */ fetch?: typeof fetch diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 831fdbca83d..15af57b355e 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,5 +1,5 @@ import { createSimpleContext } from "@opencode-ai/ui/context" -import { checksum } from "@opencode-ai/util/encode" +import { checksum } from "@opencode-ai/shared/util/encode" import { useParams } from "@solidjs/router" import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createStore, type SetStoreFunction } from "solid-js/store" @@ -185,9 +185,9 @@ function createPromptSession(dir: string, id: string | undefined) { return { ready, - current: createMemo(() => store.prompt), + current: () => store.prompt, cursor: createMemo(() => store.cursor), - dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), + dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), context: { items: createMemo(() => store.context.items), add(item: ContextItem) { @@ -277,7 +277,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const pick = (scope?: Scope) => (scope ? load(scope.dir, scope.id) : session()) return { - ready: () => session().ready(), + ready: () => session().ready, current: () => session().current(), cursor: () => session().cursor(), dirty: () => session().dirty(), diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index afd03365eac..be2fb49d7e0 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -23,9 +23,15 @@ export interface Settings { autoSave: boolean releaseNotes: boolean followup: "queue" | "steer" + showFileTree: boolean + showNavigation: boolean + showSearch: boolean + showStatus: boolean + showTerminal: boolean showReasoningSummaries: boolean shellToolPartsExpanded: boolean editToolPartsExpanded: boolean + showSessionProgressBar: boolean } updates: { startup: boolean @@ -34,6 +40,7 @@ export interface Settings { fontSize: number mono: string sans: string + terminal: string } keybinds: Record permissions: { @@ -45,13 +52,17 @@ export interface Settings { export const monoDefault = "System Mono" export const sansDefault = "System Sans" +export const terminalDefault = "JetBrainsMono Nerd Font Mono" const monoFallback = 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' +const terminalFallback = + '"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace' const monoBase = monoFallback const sansBase = sansFallback +const terminalBase = terminalFallback function input(font: string | undefined) { return font ?? "" @@ -84,14 +95,28 @@ export function sansFontFamily(font: string | undefined) { return stack(font, sansBase) } +export function terminalInput(font: string | undefined) { + return input(font) +} + +export function terminalFontFamily(font: string | undefined) { + return stack(font, terminalBase) +} + const defaultSettings: Settings = { general: { autoSave: true, releaseNotes: true, followup: "steer", + showFileTree: false, + showNavigation: false, + showSearch: false, + showStatus: false, + showTerminal: false, showReasoningSummaries: false, shellToolPartsExpanded: false, editToolPartsExpanded: false, + showSessionProgressBar: true, }, updates: { startup: true, @@ -100,6 +125,7 @@ const defaultSettings: Settings = { fontSize: 14, mono: "", sans: "", + terminal: "", }, keybinds: {}, permissions: { @@ -162,6 +188,26 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setFollowup(value: "queue" | "steer") { setStore("general", "followup", value === "queue" ? "steer" : value) }, + showFileTree: withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree), + setShowFileTree(value: boolean) { + setStore("general", "showFileTree", value) + }, + showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation), + setShowNavigation(value: boolean) { + setStore("general", "showNavigation", value) + }, + showSearch: withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch), + setShowSearch(value: boolean) { + setStore("general", "showSearch", value) + }, + showStatus: withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus), + setShowStatus(value: boolean) { + setStore("general", "showStatus", value) + }, + showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal), + setShowTerminal(value: boolean) { + setStore("general", "showTerminal", value) + }, showReasoningSummaries: withFallback( () => store.general?.showReasoningSummaries, defaultSettings.general.showReasoningSummaries, @@ -183,6 +229,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setEditToolPartsExpanded(value: boolean) { setStore("general", "editToolPartsExpanded", value) }, + showSessionProgressBar: withFallback( + () => store.general?.showSessionProgressBar, + defaultSettings.general.showSessionProgressBar, + ), + setShowSessionProgressBar(value: boolean) { + setStore("general", "showSessionProgressBar", value) + }, }, updates: { startup: withFallback(() => store.updates?.startup, defaultSettings.updates.startup), @@ -203,6 +256,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setUIFont(value: string) { setStore("appearance", "sans", value.trim() ? value : "") }, + terminalFont: withFallback(() => store.appearance?.terminal, defaultSettings.appearance.terminal), + setTerminalFont(value: string) { + setStore("appearance", "terminal", value.trim() ? value : "") + }, }, keybinds: { get: (action: string) => store.keybinds?.[action], diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 8c1dee39c05..3fb37e4b26a 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -1,7 +1,7 @@ import { batch, createMemo } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" -import { Binary } from "@opencode-ai/util/binary" -import { retry } from "@opencode-ai/util/retry" +import { Binary } from "@opencode-ai/shared/util/binary" +import { retry } from "@opencode-ai/shared/util/retry" import { createSimpleContext } from "@opencode-ai/ui/context" import { clearSessionPrefetch, diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 17355aab9ab..31d2d6e04ca 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -117,7 +117,7 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat entry?.value.clear() } - removePersisted(Persist.workspace(dir, "terminal"), platform) + void removePersisted(Persist.workspace(dir, "terminal"), platform) const legacy = new Set(getLegacyTerminalStorageKeys(dir)) for (const id of sessionIDs ?? []) { @@ -126,7 +126,7 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat } } for (const key of legacy) { - removePersisted({ key }, platform) + void removePersisted({ key }, platform) } } diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index 8d5d1b15f64..1197383a3cd 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -1,15 +1,14 @@ -import "solid-js" - interface ImportMetaEnv { readonly VITE_KILO_SERVER_HOST: string readonly VITE_KILO_SERVER_PORT: string + readonly VITE_KILO_CHANNEL?: "dev" | "beta" | "prod" } interface ImportMeta { readonly env: ImportMetaEnv } -declare module "solid-js" { +export declare module "solid-js" { namespace JSX { interface Directives { sortable: true diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index afc5d08765f..47f98dbbfe6 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -209,7 +209,7 @@ export const dict = { "common.saving": "جارٍ الحفظ...", "common.default": "افتراضي", "common.attachment": "مرفق", - "prompt.placeholder.shell": "أدخل أمر shell...", + "prompt.placeholder.shell": "أدخل أمر shell... {{example}}", "prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"', "prompt.placeholder.simple": "اسأل أي شيء...", "prompt.placeholder.summarizeComments": "لخّص التعليقات…", @@ -564,7 +564,9 @@ export const dict = { "settings.general.row.theme.title": "السمة", "settings.general.row.theme.description": "تخصيص سمة Kilo.", "settings.general.row.font.title": "خط الكود", - "settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية والطرفيات", + "settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "خط الواجهة", "settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها", "settings.general.row.followup.title": "سلوك المتابعة", @@ -579,6 +581,8 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "توسيع أجزاء أداة edit", "settings.general.row.editToolPartsExpanded.description": "إظهار أجزاء أدوات edit و write و patch موسعة بشكل افتراضي في الشريط الزمني", + "settings.general.row.showSessionProgressBar.title": "إظهار شريط تقدم الجلسة", + "settings.general.row.showSessionProgressBar.description": "عرض شريط التقدم المتحرك أعلى الجلسة أثناء عمل الوكيل", "settings.general.row.wayland.title": "استخدام Wayland الأصلي", "settings.general.row.wayland.description": "تعطيل التراجع إلى X11 على Wayland. يتطلب إعادة التشغيل.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 217a017e606..101eb7ce838 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -210,7 +210,7 @@ export const dict = { "common.saving": "Salvando...", "common.default": "Padrão", "common.attachment": "anexo", - "prompt.placeholder.shell": "Digite comando do shell...", + "prompt.placeholder.shell": "Digite comando do shell... {{example}}", "prompt.placeholder.normal": 'Pergunte qualquer coisa... "{{example}}"', "prompt.placeholder.simple": "Pergunte qualquer coisa...", "prompt.placeholder.summarizeComments": "Resumir comentários…", @@ -572,7 +572,9 @@ export const dict = { "settings.general.row.theme.title": "Tema", "settings.general.row.theme.description": "Personalize como o Kilo é tematizado.", "settings.general.row.font.title": "Fonte de código", - "settings.general.row.font.description": "Personalize a fonte usada em blocos de código e terminais", + "settings.general.row.font.description": "Personalize a fonte usada em blocos de código", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Fonte da interface", "settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface", "settings.general.row.followup.title": "Comportamento de acompanhamento", @@ -588,6 +590,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Expandir partes da ferramenta de edição", "settings.general.row.editToolPartsExpanded.description": "Mostrar partes das ferramentas de edição, escrita e patch expandidas por padrão na linha do tempo", + "settings.general.row.showSessionProgressBar.title": "Mostrar barra de progresso da sessão", + "settings.general.row.showSessionProgressBar.description": + "Exibir a barra de progresso animada no topo da sessão quando o agente estiver trabalhando", "settings.general.row.wayland.title": "Usar Wayland nativo", "settings.general.row.wayland.description": "Desabilitar fallback X11 no Wayland. Requer reinicialização.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 46b40269b00..a52b04c437f 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -228,7 +228,7 @@ export const dict = { "common.default": "Podrazumijevano", "common.attachment": "prilog", - "prompt.placeholder.shell": "Unesi shell naredbu...", + "prompt.placeholder.shell": "Unesi shell naredbu... {{example}}", "prompt.placeholder.normal": 'Pitaj bilo šta... "{{example}}"', "prompt.placeholder.simple": "Pitaj bilo šta...", "prompt.placeholder.summarizeComments": "Sažmi komentare…", @@ -637,7 +637,9 @@ export const dict = { "settings.general.row.theme.title": "Tema", "settings.general.row.theme.description": "Prilagodi temu Kilo-a.", "settings.general.row.font.title": "Font za kod", - "settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda i terminalima", + "settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI font", "settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu", "settings.general.row.followup.title": "Ponašanje nadovezivanja", @@ -653,6 +655,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Proširi dijelove alata za uređivanje", "settings.general.row.editToolPartsExpanded.description": "Prikaži dijelove alata za uređivanje, pisanje i patch podrazumijevano proširene na vremenskoj traci", + "settings.general.row.showSessionProgressBar.title": "Prikaži traku napretka sesije", + "settings.general.row.showSessionProgressBar.description": + "Prikaži animiranu traku napretka na vrhu sesije kada agent radi", "settings.general.row.wayland.title": "Koristi nativni Wayland", "settings.general.row.wayland.description": "Onemogući X11 fallback na Waylandu. Zahtijeva restart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 66b6ead5799..a281c96e428 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -226,7 +226,7 @@ export const dict = { "common.default": "Standard", "common.attachment": "vedhæftning", - "prompt.placeholder.shell": "Indtast shell-kommando...", + "prompt.placeholder.shell": "Indtast shell-kommando... {{example}}", "prompt.placeholder.normal": 'Spørg om hvad som helst... "{{example}}"', "prompt.placeholder.simple": "Spørg om hvad som helst...", "prompt.placeholder.summarizeComments": "Opsummér kommentarer…", @@ -632,7 +632,9 @@ export const dict = { "settings.general.row.theme.title": "Tema", "settings.general.row.theme.description": "Tilpas hvordan Kilo er temabestemt.", "settings.general.row.font.title": "Kode-skrifttype", - "settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke og terminaler", + "settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI-skrifttype", "settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen", "settings.general.row.followup.title": "Opfølgningsadfærd", @@ -647,6 +649,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Udvid edit-værktøjsdele", "settings.general.row.editToolPartsExpanded.description": "Vis edit-, write- og patch-værktøjsdele udvidet som standard i tidslinjen", + "settings.general.row.showSessionProgressBar.title": "Vis sessionens fremdriftslinje", + "settings.general.row.showSessionProgressBar.description": + "Vis den animerede fremdriftslinje øverst i sessionen, når agenten arbejder", "settings.general.row.wayland.title": "Brug native Wayland", "settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Kræver genstart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index e5ca0038a3e..34ce312aea2 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -215,7 +215,7 @@ export const dict = { "common.saving": "Speichert...", "common.default": "Standard", "common.attachment": "Anhang", - "prompt.placeholder.shell": "Shell-Befehl eingeben...", + "prompt.placeholder.shell": "Shell-Befehl eingeben... {{example}}", "prompt.placeholder.normal": 'Fragen Sie alles... "{{example}}"', "prompt.placeholder.simple": "Fragen Sie alles...", "prompt.placeholder.summarizeComments": "Kommentare zusammenfassen…", @@ -581,7 +581,9 @@ export const dict = { "settings.general.row.theme.title": "Thema", "settings.general.row.theme.description": "Das Thema von Kilo anpassen.", "settings.general.row.font.title": "Code-Schriftart", - "settings.general.row.font.description": "Die in Codeblöcken und Terminals verwendete Schriftart anpassen", + "settings.general.row.font.description": "Die in Codeblöcken verwendete Schriftart anpassen", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI-Schriftart", "settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen", "settings.general.row.followup.title": "Verhalten bei Folgefragen", @@ -598,6 +600,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Edit-Tool-Abschnitte ausklappen", "settings.general.row.editToolPartsExpanded.description": "Edit-, Write- und Patch-Tool-Abschnitte standardmäßig in der Timeline ausgeklappt anzeigen", + "settings.general.row.showSessionProgressBar.title": "Sitzungsfortschrittsleiste anzeigen", + "settings.general.row.showSessionProgressBar.description": + "Die animierte Fortschrittsleiste oben in der Sitzung anzeigen, wenn der Agent arbeitet", "settings.general.row.wayland.title": "Natives Wayland verwenden", "settings.general.row.wayland.description": "X11-Fallback unter Wayland deaktivieren. Erfordert Neustart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 30c34534c39..6cde9741882 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -230,7 +230,7 @@ export const dict = { "common.default": "Default", "common.attachment": "attachment", - "prompt.placeholder.shell": "Enter shell command...", + "prompt.placeholder.shell": "Enter shell command... {{example}}", "prompt.placeholder.normal": 'Ask anything... "{{example}}"', "prompt.placeholder.simple": "Ask anything...", "prompt.placeholder.summarizeComments": "Summarize comments…", @@ -719,6 +719,7 @@ export const dict = { "settings.desktop.wsl.description": "Run the Kilo server inside WSL on Windows.", "settings.general.section.appearance": "Appearance", + "settings.general.section.advanced": "Advanced", "settings.general.section.notifications": "System notifications", "settings.general.section.updates": "Updates", "settings.general.section.sounds": "Sound effects", @@ -734,13 +735,25 @@ export const dict = { "settings.general.row.theme.title": "Theme", "settings.general.row.theme.description": "Customise how Kilo is themed.", "settings.general.row.font.title": "Code Font", - "settings.general.row.font.description": "Customise the font used in code blocks and terminals", + "settings.general.row.font.description": "Customise the font used in code blocks", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI Font", "settings.general.row.uiFont.description": "Customise the font used throughout the interface", "settings.general.row.followup.title": "Follow-up behavior", "settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue", "settings.general.row.followup.option.queue": "Queue", "settings.general.row.followup.option.steer": "Steer", + "settings.general.row.showFileTree.title": "File tree", + "settings.general.row.showFileTree.description": "Show the file tree toggle and panel in desktop sessions", + "settings.general.row.showNavigation.title": "Navigation controls", + "settings.general.row.showNavigation.description": "Show the back and forward buttons in the desktop title bar", + "settings.general.row.showSearch.title": "Command palette", + "settings.general.row.showSearch.description": "Show the search and command palette button in the desktop title bar", + "settings.general.row.showTerminal.title": "Terminal", + "settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar", + "settings.general.row.showStatus.title": "Server status", + "settings.general.row.showStatus.description": "Show the server status button in the desktop title bar", "settings.general.row.reasoningSummaries.title": "Show reasoning summaries", "settings.general.row.reasoningSummaries.description": "Display model reasoning summaries in the timeline", "settings.general.row.shellToolPartsExpanded.title": "Expand shell tool parts", @@ -749,6 +762,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Expand edit tool parts", "settings.general.row.editToolPartsExpanded.description": "Show edit, write, and patch tool parts expanded by default in the timeline", + "settings.general.row.showSessionProgressBar.title": "Show session progress bar", + "settings.general.row.showSessionProgressBar.description": + "Display the animated progress bar at the top of the session when the agent is working", "settings.general.row.wayland.title": "Use native Wayland", "settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index c00c2ba50c9..9ed93e813e4 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -227,7 +227,7 @@ export const dict = { "common.default": "Predeterminado", "common.attachment": "adjunto", - "prompt.placeholder.shell": "Introduce comando de shell...", + "prompt.placeholder.shell": "Introduce comando de shell... {{example}}", "prompt.placeholder.normal": 'Pregunta cualquier cosa... "{{example}}"', "prompt.placeholder.simple": "Pregunta cualquier cosa...", "prompt.placeholder.summarizeComments": "Resumir comentarios…", @@ -640,7 +640,9 @@ export const dict = { "settings.general.row.theme.title": "Tema", "settings.general.row.theme.description": "Personaliza el tema de Kilo.", "settings.general.row.font.title": "Fuente de código", - "settings.general.row.font.description": "Personaliza la fuente usada en bloques de código y terminales", + "settings.general.row.font.description": "Personaliza la fuente usada en bloques de código", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Fuente de la interfaz", "settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz", "settings.general.row.followup.title": "Comportamiento de seguimiento", @@ -657,6 +659,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Expandir partes de la herramienta de edición", "settings.general.row.editToolPartsExpanded.description": "Mostrar las partes de las herramientas de edición, escritura y parcheado expandidas por defecto en la línea de tiempo", + "settings.general.row.showSessionProgressBar.title": "Mostrar barra de progreso de la sesión", + "settings.general.row.showSessionProgressBar.description": + "Mostrar la barra de progreso animada en la parte superior de la sesión cuando el agente esté trabajando", "settings.general.row.wayland.title": "Usar Wayland nativo", "settings.general.row.wayland.description": "Deshabilitar fallback a X11 en Wayland. Requiere reinicio.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 2bd6ea940e6..587076d240a 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -210,7 +210,7 @@ export const dict = { "common.saving": "Enregistrement...", "common.default": "Défaut", "common.attachment": "pièce jointe", - "prompt.placeholder.shell": "Entrez une commande shell...", + "prompt.placeholder.shell": "Entrez une commande shell... {{example}}", "prompt.placeholder.normal": 'Demandez n\'importe quoi... "{{example}}"', "prompt.placeholder.simple": "Demandez n'importe quoi...", "prompt.placeholder.summarizeComments": "Résumer les commentaires…", @@ -398,8 +398,7 @@ export const dict = { "toast.session.unshare.failed.description": "Une erreur s'est produite lors de l'annulation du partage de la session", "toast.session.listFailed.title": "Échec du chargement des sessions pour {{project}}", "toast.update.title": "Mise à jour disponible", - "toast.update.description": - "Une nouvelle version d'Kilo ({{version}}) est maintenant disponible pour installation.", + "toast.update.description": "Une nouvelle version d'Kilo ({{version}}) est maintenant disponible pour installation.", "toast.update.action.installRestart": "Installer et redémarrer", "toast.update.action.notYet": "Pas encore", "error.page.title": "Quelque chose s'est mal passé", @@ -549,8 +548,7 @@ export const dict = { "sidebar.workspaces.enable": "Activer les espaces de travail", "sidebar.workspaces.disable": "Désactiver les espaces de travail", "sidebar.gettingStarted.title": "Commencer", - "sidebar.gettingStarted.line1": - "Kilo inclut des modèles gratuits pour que vous puissiez commencer immédiatement.", + "sidebar.gettingStarted.line1": "Kilo inclut des modèles gratuits pour que vous puissiez commencer immédiatement.", "sidebar.gettingStarted.line2": "Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini etc.", "sidebar.project.recentSessions": "Sessions récentes", @@ -579,7 +577,9 @@ export const dict = { "settings.general.row.theme.title": "Thème", "settings.general.row.theme.description": "Personnaliser le thème d'Kilo.", "settings.general.row.font.title": "Police de code", - "settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code et les terminaux", + "settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Police de l'interface", "settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface", "settings.general.row.followup.title": "Comportement de suivi", @@ -596,6 +596,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Développer les parties de l'outil edit", "settings.general.row.editToolPartsExpanded.description": "Afficher les parties des outils edit, write et patch développées par défaut dans la chronologie", + "settings.general.row.showSessionProgressBar.title": "Afficher la barre de progression de la session", + "settings.general.row.showSessionProgressBar.description": + "Afficher la barre de progression animée en haut de la session lorsque l'agent travaille", "settings.general.row.wayland.title": "Utiliser Wayland natif", "settings.general.row.wayland.description": "Désactiver le repli X11 sur Wayland. Nécessite un redémarrage.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 9d1657c12f4..49b8d652d7e 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -209,7 +209,7 @@ export const dict = { "common.saving": "保存中...", "common.default": "デフォルト", "common.attachment": "添付ファイル", - "prompt.placeholder.shell": "シェルコマンドを入力...", + "prompt.placeholder.shell": "シェルコマンドを入力... {{example}}", "prompt.placeholder.normal": '何でも聞いてください... "{{example}}"', "prompt.placeholder.simple": "何でも聞いてください...", "prompt.placeholder.summarizeComments": "コメントを要約…", @@ -569,7 +569,9 @@ export const dict = { "settings.general.row.theme.title": "テーマ", "settings.general.row.theme.description": "Kiloのテーマをカスタマイズします。", "settings.general.row.font.title": "コードフォント", - "settings.general.row.font.description": "コードブロックとターミナルで使用するフォントをカスタマイズします", + "settings.general.row.font.description": "コードブロックで使用するフォントをカスタマイズします", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UIフォント", "settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします", "settings.general.row.followup.title": "フォローアップの動作", @@ -585,6 +587,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "edit ツールパーツを展開", "settings.general.row.editToolPartsExpanded.description": "タイムラインで edit、write、patch ツールパーツをデフォルトで展開して表示します", + "settings.general.row.showSessionProgressBar.title": "セッション進行状況バーを表示", + "settings.general.row.showSessionProgressBar.description": + "エージェントの作業中に、セッション上部にアニメーション付きの進行状況バーを表示します", "settings.general.row.wayland.title": "ネイティブWaylandを使用", "settings.general.row.wayland.description": "WaylandでのX11フォールバックを無効にします。再起動が必要です。", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 7814fa3fdf3..c1c6f397ccc 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -1,7 +1,3 @@ -import { dict as en } from "./en" - -type Keys = keyof typeof en - export const dict = { "command.category.suggested": "추천", "command.category.view": "보기", @@ -213,7 +209,7 @@ export const dict = { "common.saving": "저장 중...", "common.default": "기본값", "common.attachment": "첨부 파일", - "prompt.placeholder.shell": "셸 명령어 입력...", + "prompt.placeholder.shell": "셸 명령어 입력... {{example}}", "prompt.placeholder.normal": '무엇이든 물어보세요... "{{example}}"', "prompt.placeholder.simple": "무엇이든 물어보세요...", "prompt.placeholder.summarizeComments": "댓글 요약…", @@ -570,7 +566,9 @@ export const dict = { "settings.general.row.theme.title": "테마", "settings.general.row.theme.description": "Kilo 테마 사용자 지정", "settings.general.row.font.title": "코드 글꼴", - "settings.general.row.font.description": "코드 블록과 터미널에 사용되는 글꼴을 사용자 지정", + "settings.general.row.font.description": "코드 블록에 사용되는 글꼴을 사용자 지정", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI 글꼴", "settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정", "settings.general.row.followup.title": "후속 조치 동작", @@ -585,6 +583,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "edit 도구 파트 펼치기", "settings.general.row.editToolPartsExpanded.description": "타임라인에서 기본적으로 edit, write, patch 도구 파트를 펼친 상태로 표시합니다", + "settings.general.row.showSessionProgressBar.title": "세션 진행 표시줄 표시", + "settings.general.row.showSessionProgressBar.description": + "에이전트가 작업 중일 때 세션 상단에 애니메이션 진행 표시줄을 표시합니다", "settings.general.row.wayland.title": "네이티브 Wayland 사용", "settings.general.row.wayland.description": "Wayland에서 X11 폴백을 비활성화합니다. 다시 시작해야 합니다.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index a77f8b025aa..1551f956821 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -230,7 +230,7 @@ export const dict = { "common.default": "Standard", "common.attachment": "vedlegg", - "prompt.placeholder.shell": "Skriv inn shell-kommando...", + "prompt.placeholder.shell": "Skriv inn shell-kommando... {{example}}", "prompt.placeholder.normal": 'Spør om hva som helst... "{{example}}"', "prompt.placeholder.simple": "Spør om hva som helst...", "prompt.placeholder.summarizeComments": "Oppsummer kommentarer…", @@ -640,7 +640,9 @@ export const dict = { "settings.general.row.theme.title": "Tema", "settings.general.row.theme.description": "Tilpass hvordan Kilo er tematisert.", "settings.general.row.font.title": "Kodefont", - "settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker og terminaler", + "settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "UI-skrift", "settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet", "settings.general.row.followup.title": "Oppfølgingsadferd", @@ -654,6 +656,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Utvid edit-verktøydeler", "settings.general.row.editToolPartsExpanded.description": "Vis edit-, write- og patch-verktøydeler utvidet som standard i tidslinjen", + "settings.general.row.showSessionProgressBar.title": "Vis fremdriftslinje for sesjonen", + "settings.general.row.showSessionProgressBar.description": + "Vis den animerte fremdriftslinjen øverst i sesjonen når agenten jobber", "settings.general.row.wayland.title": "Bruk innebygd Wayland", "settings.general.row.wayland.description": "Deaktiver X11-fallback på Wayland. Krever omstart.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 64170ee1d64..49e7e12d992 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -211,7 +211,7 @@ export const dict = { "common.saving": "Zapisywanie...", "common.default": "Domyślny", "common.attachment": "załącznik", - "prompt.placeholder.shell": "Wpisz polecenie terminala...", + "prompt.placeholder.shell": "Wpisz polecenie terminala... {{example}}", "prompt.placeholder.normal": 'Zapytaj o cokolwiek... "{{example}}"', "prompt.placeholder.simple": "Zapytaj o cokolwiek...", "prompt.placeholder.summarizeComments": "Podsumuj komentarze…", @@ -571,7 +571,9 @@ export const dict = { "settings.general.row.theme.title": "Motyw", "settings.general.row.theme.description": "Dostosuj motyw Kilo.", "settings.general.row.font.title": "Czcionka kodu", - "settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu i terminalach", + "settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Czcionka interfejsu", "settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie", "settings.general.row.followup.title": "Zachowanie kontynuacji", @@ -586,6 +588,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Rozwijaj elementy narzędzia edit", "settings.general.row.editToolPartsExpanded.description": "Domyślnie pokazuj rozwinięte elementy narzędzi edit, write i patch na osi czasu", + "settings.general.row.showSessionProgressBar.title": "Pokazuj pasek postępu sesji", + "settings.general.row.showSessionProgressBar.description": + "Wyświetlaj animowany pasek postępu u góry sesji, gdy agent pracuje", "settings.general.row.wayland.title": "Użyj natywnego Wayland", "settings.general.row.wayland.description": "Wyłącz fallback X11 na Wayland. Wymaga restartu.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 89c9862951b..6436f62c1ff 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -227,7 +227,7 @@ export const dict = { "common.default": "По умолчанию", "common.attachment": "вложение", - "prompt.placeholder.shell": "Введите команду оболочки...", + "prompt.placeholder.shell": "Введите команду оболочки... {{example}}", "prompt.placeholder.normal": 'Спросите что угодно... "{{example}}"', "prompt.placeholder.simple": "Спросите что угодно...", "prompt.placeholder.summarizeComments": "Суммировать комментарии…", @@ -637,7 +637,9 @@ export const dict = { "settings.general.row.theme.title": "Тема", "settings.general.row.theme.description": "Настройте оформление Kilo.", "settings.general.row.font.title": "Шрифт кода", - "settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода и терминалах", + "settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Шрифт интерфейса", "settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе", "settings.general.row.followup.title": "Поведение уточняющих вопросов", @@ -654,6 +656,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "Разворачивать элементы инструмента edit", "settings.general.row.editToolPartsExpanded.description": "Показывать элементы инструментов edit, write и patch в ленте развернутыми по умолчанию", + "settings.general.row.showSessionProgressBar.title": "Показывать индикатор прогресса сессии", + "settings.general.row.showSessionProgressBar.description": + "Показывать анимированный индикатор прогресса вверху сессии, когда агент работает", "settings.general.row.wayland.title": "Использовать нативный Wayland", "settings.general.row.wayland.description": "Отключить X11 fallback на Wayland. Требуется перезапуск.", "settings.general.row.wayland.tooltip": diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 4a6bffdcbb5..f1bc3e9f0e1 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -149,8 +149,7 @@ export const dict = { "provider.connect.oauth.code.invalid": "รหัสการอนุญาตไม่ถูกต้อง", "provider.connect.oauth.auto.visit.prefix": "เยี่ยมชม ", "provider.connect.oauth.auto.visit.link": "ลิงก์นี้", - "provider.connect.oauth.auto.visit.suffix": - " และป้อนรหัสด้านล่างเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน Kilo", + "provider.connect.oauth.auto.visit.suffix": " และป้อนรหัสด้านล่างเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน Kilo", "provider.connect.oauth.auto.confirmationCode": "รหัสยืนยัน", "provider.connect.toast.connected.title": "{{provider}} ที่เชื่อมต่อแล้ว", "provider.connect.toast.connected.description": "โมเดล {{provider}} พร้อมใช้งานแล้ว", @@ -227,7 +226,7 @@ export const dict = { "common.default": "ค่าเริ่มต้น", "common.attachment": "ไฟล์แนบ", - "prompt.placeholder.shell": "ป้อนคำสั่งเชลล์...", + "prompt.placeholder.shell": "ป้อนคำสั่งเชลล์... {{example}}", "prompt.placeholder.normal": 'ถามอะไรก็ได้... "{{example}}"', "prompt.placeholder.simple": "ถามอะไรก็ได้...", "prompt.placeholder.summarizeComments": "สรุปความคิดเห็น…", @@ -631,7 +630,9 @@ export const dict = { "settings.general.row.theme.title": "ธีม", "settings.general.row.theme.description": "ปรับแต่งวิธีการที่ Kilo มีธีม", "settings.general.row.font.title": "ฟอนต์โค้ด", - "settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ดและเทอร์มินัล", + "settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ด", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "ฟอนต์ UI", "settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ", "settings.general.row.followup.title": "พฤติกรรมการติดตามผล", @@ -645,6 +646,9 @@ export const dict = { "settings.general.row.editToolPartsExpanded.title": "ขยายส่วนเครื่องมือ edit", "settings.general.row.editToolPartsExpanded.description": "แสดงส่วนเครื่องมือ edit, write และ patch แบบขยายตามค่าเริ่มต้นในไทม์ไลน์", + "settings.general.row.showSessionProgressBar.title": "แสดงแถบความคืบหน้าของเซสชัน", + "settings.general.row.showSessionProgressBar.description": + "แสดงแถบความคืบหน้าแบบเคลื่อนไหวที่ด้านบนของเซสชันเมื่อเอเจนต์กำลังทำงาน", "settings.general.row.wayland.title": "ใช้ Wayland แบบเนทีฟ", "settings.general.row.wayland.description": "ปิดใช้งาน X11 fallback บน Wayland ต้องรีสตาร์ท", "settings.general.row.wayland.tooltip": "บน Linux ที่มีจอภาพรีเฟรชเรตแบบผสม Wayland แบบเนทีฟอาจเสถียรกว่า", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index a49256cc13f..a5c3f4b47b5 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -231,7 +231,7 @@ export const dict = { "common.default": "Varsayılan", "common.attachment": "ek", - "prompt.placeholder.shell": "Kabuk komutu girin...", + "prompt.placeholder.shell": "Kabuk komutu girin... {{example}}", "prompt.placeholder.normal": 'Bir şeyler sorun... "{{example}}"', "prompt.placeholder.simple": "Bir şeyler sorun...", "prompt.placeholder.summarizeComments": "Yorumları özetle…", @@ -642,7 +642,9 @@ export const dict = { "settings.general.row.theme.title": "Tema", "settings.general.row.theme.description": "Kilo'un temasını özelleştirin.", "settings.general.row.font.title": "Kod Yazı Tipi", - "settings.general.row.font.description": "Kod bloklarında ve terminallerde kullanılan yazı tipini özelleştirin", + "settings.general.row.font.description": "Kod bloklarında kullanılan yazı tipini özelleştirin", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "Arayüz Yazı Tipi", "settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin", "settings.general.row.followup.title": "Takip davranışı", @@ -659,6 +661,10 @@ export const dict = { "settings.general.row.editToolPartsExpanded.description": "Zaman çizelgesinde düzenleme, yazma ve yama araç bileşenlerini varsayılan olarak genişletilmiş göster", + "settings.general.row.showSessionProgressBar.title": "Oturum ilerleme çubuğunu göster", + "settings.general.row.showSessionProgressBar.description": + "Ajan çalışırken oturumun üst kısmında animasyonlu ilerleme çubuğunu göster", + "settings.general.row.wayland.title": "Yerel Wayland kullan", "settings.general.row.wayland.description": "Wayland'da X11 geri dönüşünü devre dışı bırak. Yeniden başlatma gerektirir.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 840c5ac9e11..52c20f9b5e7 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -249,7 +249,7 @@ export const dict = { "common.default": "默认", "common.attachment": "附件", - "prompt.placeholder.shell": "输入 shell 命令...", + "prompt.placeholder.shell": "输入 shell 命令... {{example}}", "prompt.placeholder.normal": '随便问点什么... "{{example}}"', "prompt.placeholder.simple": "随便问点什么...", "prompt.placeholder.summarizeComments": "总结评论…", @@ -631,7 +631,9 @@ export const dict = { "settings.general.row.theme.title": "主题", "settings.general.row.theme.description": "自定义 Kilo 的主题。", "settings.general.row.font.title": "代码字体", - "settings.general.row.font.description": "自定义代码块和终端使用的字体", + "settings.general.row.font.description": "自定义代码块使用的字体", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "界面字体", "settings.general.row.uiFont.description": "自定义整个界面使用的字体", "settings.general.row.followup.title": "跟进消息行为", @@ -644,6 +646,8 @@ export const dict = { "settings.general.row.shellToolPartsExpanded.description": "默认在时间线中展开 shell 工具部分", "settings.general.row.editToolPartsExpanded.title": "展开编辑工具部分", "settings.general.row.editToolPartsExpanded.description": "默认在时间线中展开 edit、write 和 patch 工具部分", + "settings.general.row.showSessionProgressBar.title": "显示会话进度条", + "settings.general.row.showSessionProgressBar.description": "当智能体正在工作时,在会话顶部显示动画进度条", "settings.general.row.wayland.title": "使用原生 Wayland", "settings.general.row.wayland.description": "在 Wayland 上禁用 X11 回退。需要重启。", "settings.general.row.wayland.tooltip": "在混合刷新率显示器的 Linux 系统上,原生 Wayland 可能更稳定。", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index dcd680c9b93..4e94db10abc 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -150,8 +150,7 @@ export const dict = { "provider.connect.oauth.code.invalid": "授權碼無效", "provider.connect.oauth.auto.visit.prefix": "造訪 ", "provider.connect.oauth.auto.visit.link": "此連結", - "provider.connect.oauth.auto.visit.suffix": - " 並輸入以下程式碼,以連線你的帳戶並在 Kilo 中使用 {{provider}} 模型。", + "provider.connect.oauth.auto.visit.suffix": " 並輸入以下程式碼,以連線你的帳戶並在 Kilo 中使用 {{provider}} 模型。", "provider.connect.oauth.auto.confirmationCode": "確認碼", "provider.connect.toast.connected.title": "{{provider}} 已連線", "provider.connect.toast.connected.description": "現在可以使用 {{provider}} 模型了。", @@ -227,7 +226,7 @@ export const dict = { "common.default": "預設", "common.attachment": "附件", - "prompt.placeholder.shell": "輸入 shell 命令...", + "prompt.placeholder.shell": "輸入 shell 命令... {{example}}", "prompt.placeholder.normal": '隨便問點什麼... "{{example}}"', "prompt.placeholder.simple": "隨便問點什麼...", "prompt.placeholder.summarizeComments": "摘要評論…", @@ -626,7 +625,9 @@ export const dict = { "settings.general.row.theme.title": "主題", "settings.general.row.theme.description": "自訂 Kilo 的主題。", "settings.general.row.font.title": "程式碼字型", - "settings.general.row.font.description": "自訂程式碼區塊和終端機使用的字型", + "settings.general.row.font.description": "自訂程式碼區塊使用的字型", + "settings.general.row.terminalFont.title": "Terminal Font", + "settings.general.row.terminalFont.description": "Customise the font used in the terminal", "settings.general.row.uiFont.title": "介面字型", "settings.general.row.uiFont.description": "自訂整個介面使用的字型", "settings.general.row.followup.title": "後續追問行為", @@ -640,6 +641,8 @@ export const dict = { "settings.general.row.shellToolPartsExpanded.description": "在時間軸中預設展開 shell 工具區塊", "settings.general.row.editToolPartsExpanded.title": "展開 edit 工具區塊", "settings.general.row.editToolPartsExpanded.description": "在時間軸中預設展開 edit、write 和 patch 工具區塊", + "settings.general.row.showSessionProgressBar.title": "顯示工作階段進度列", + "settings.general.row.showSessionProgressBar.description": "當代理程式正在運作時,在工作階段頂部顯示動畫進度列", "settings.general.row.wayland.title": "使用原生 Wayland", "settings.general.row.wayland.description": "在 Wayland 上停用 X11 後備模式。需要重新啟動。", "settings.general.row.wayland.tooltip": "在混合更新率螢幕的 Linux 系統上,原生 Wayland 可能更穩定。", diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 629ac80a869..8db576dd834 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -1,5 +1,12 @@ @import "@opencode-ai/ui/styles/tailwind"; +@font-face { + font-family: "JetBrainsMono Nerd Font Mono"; + src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2"); + font-weight: normal; + font-style: normal; +} + @layer components { @keyframes session-progress-whip { 0% { @@ -66,4 +73,13 @@ width: auto; } } + + @keyframes fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } } diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 427b4823b5f..36514f56c63 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,8 +1,8 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createEffect, createMemo, type ParentProps, Show } from "solid-js" +import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" @@ -23,11 +23,10 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) }) - createEffect(() => { - const id = params.id - if (!id) return - void sync.session.sync(id) - }) + createResource( + () => params.id, + (id) => sync.session.sync(id), + ) return ( = (props) => { } async function installUpdate() { - if (!platform.update || !platform.restart) return + if (!platform.updateAndRestart) return await platform - .update() - .then(() => platform.restart!()) + .updateAndRestart() .then(() => setStore("actionError", undefined)) .catch((err) => { setStore("actionError", formatError(err, language.t)) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 4c795b96832..46cacdf627a 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -3,7 +3,7 @@ import { Button } from "@opencode-ai/ui/button" import { Logo } from "@opencode-ai/ui/logo" import { useLayout } from "@/context/layout" import { useNavigate } from "@solidjs/router" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { Icon } from "@opencode-ai/ui/icon" import { usePlatform } from "@/context/platform" import { DateTime } from "luxon" diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 777adc1a12a..c638ec5b203 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -13,11 +13,11 @@ import { type Accessor, } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" -import { useNavigate, useParams } from "@solidjs/router" +import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { Persist, persisted } from "@/utils/persist" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { decode64 } from "@/utils/base64" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" @@ -25,7 +25,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tooltip } from "@opencode-ai/ui/tooltip" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { Session, type Message } from "@kilocode/sdk/v2/client" import { usePlatform } from "@/context/platform" import { useSettings } from "@/context/settings" @@ -48,8 +48,8 @@ import { } from "@/context/global-sync/session-prefetch" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" -import { Binary } from "@opencode-ai/util/binary" -import { retry } from "@opencode-ai/util/retry" +import { Binary } from "@opencode-ai/shared/util/binary" +import { retry } from "@opencode-ai/shared/util/retry" import { playSoundById } from "@/utils/sound" import { createAim } from "@/utils/aim" import { setNavigate } from "@/utils/notification-click" @@ -127,14 +127,17 @@ export default function Layout(props: ParentProps) { const theme = useTheme() const language = useLanguage() const initialDirectory = decode64(params.dir) + const location = useLocation() const route = createMemo(() => { const slug = params.dir if (!slug) return { slug, dir: "" } const dir = decode64(slug) if (!dir) return { slug, dir: "" } + const store = globalSync.peek(dir, { bootstrap: false }) return { slug, - dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir, + store, + dir: store[0].path.directory || dir, } }) const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const)) @@ -363,7 +366,7 @@ export default function Layout(props: ParentProps) { const useUpdatePolling = () => onMount(() => { - if (!platform.checkUpdate || !platform.update || !platform.restart) return + if (!platform.checkUpdate || !platform.updateAndRestart) return let toastId: number | undefined let interval: ReturnType | undefined @@ -381,8 +384,7 @@ export default function Layout(props: ParentProps) { { label: language.t("toast.update.action.installRestart"), onClick: async () => { - await platform.update!() - await platform.restart!() + await platform.updateAndRestart!() }, }, { @@ -704,7 +706,7 @@ export default function Layout(props: ParentProps) { createEffect(() => { const active = new Set(visibleSessionDirs()) - for (const directory of [...prefetchedByDir.keys()]) { + for (const directory of prefetchedByDir.keys()) { if (active.has(directory)) continue prefetchedByDir.delete(directory) } @@ -956,7 +958,7 @@ export default function Layout(props: ParentProps) { // warm up child store to prevent flicker globalSync.child(target.worktree) - openProject(target.worktree) + void openProject(target.worktree) } function navigateSessionByUnseen(offset: number) { @@ -1094,7 +1096,7 @@ export default function Layout(props: ParentProps) { disabled: !params.dir || !params.id, onSelect: () => { const session = currentSessions().find((s) => s.id === params.id) - if (session) archiveSession(session) + if (session) void archiveSession(session) }, }, { @@ -1360,11 +1362,11 @@ export default function Layout(props: ParentProps) { if (!server.isLocal()) return for (const directory of collectOpenProjectDeepLinks(urls)) { - openProject(directory) + void openProject(directory) } for (const link of collectNewSessionDeepLinks(urls)) { - openProject(link.directory, false) + void openProject(link.directory, false) const slug = base64Encode(link.directory) if (link.prompt) { setSessionHandoff(slug, { prompt: link.prompt }) @@ -1453,11 +1455,11 @@ export default function Layout(props: ParentProps) { function resolve(result: string | string[] | null) { if (Array.isArray(result)) { for (const directory of result) { - openProject(directory, false) + void openProject(directory, false) } - navigateToProject(result[0]) + void navigateToProject(result[0]) } else if (result) { - openProject(result) + void openProject(result) } } @@ -1825,7 +1827,7 @@ export default function Layout(props: ParentProps) { const next = new Set(dirs) for (const directory of next) { if (loadedSessionDirs.has(directory)) continue - globalSync.project.loadSessions(directory) + void globalSync.project.loadSessions(directory) } loadedSessionDirs.clear() @@ -2100,196 +2102,198 @@ export default function Layout(props: ParentProps) { } > - <> -
-
-
- { - const item = project() - if (!item) return - renameProject(item, next) - }} - class="text-14-medium text-text-strong truncate" - displayClass="text-14-medium text-text-strong truncate" - stopPropagation - /> + {(project) => ( + <> +
+
+
+ { + const item = project() + if (!item) return + void renameProject(item, next) + }} + class="text-14-medium text-text-strong truncate" + displayClass="text-14-medium text-text-strong truncate" + stopPropagation + /> - - - {worktree().replace(homedir(), "~")} - - + + + {worktree().replace(homedir(), "~")} + + +
+ + + + + + { + const item = project() + if (!item) return + showEditProjectDialog(item) + }} + > + {language.t("common.edit")} + + { + const item = project() + if (!item) return + toggleProjectWorkspaces(item) + }} + > + + {workspacesEnabled() + ? language.t("sidebar.workspaces.disable") + : language.t("sidebar.workspaces.enable")} + + + + + {language.t("sidebar.project.clearNotifications")} + + + + { + const dir = worktree() + if (!dir) return + closeProject(dir) + }} + > + {language.t("common.close")} + + + +
- - - - - - { - const item = project() - if (!item) return - showEditProjectDialog(item) - }} - > - {language.t("common.edit")} - - { - const item = project() - if (!item) return - toggleProjectWorkspaces(item) - }} - > - - {workspacesEnabled() - ? language.t("sidebar.workspaces.disable") - : language.t("sidebar.workspaces.enable")} - - - - - {language.t("sidebar.project.clearNotifications")} - - - - { - const dir = worktree() - if (!dir) return - closeProject(dir) - }} - > - {language.t("common.close")} - - - -
-
-
- + +
+ +
+
+ +
+ + } + > <>
-
- +
+ + + +
{ + if (!panelProps.mobile) scrollContainerRef = el + }} + class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" + > + + + {(directory) => ( + + )} + + +
+ + store.activeWorkspace} + workspaceLabel={workspaceLabel} + /> + +
- } - > - <> -
- -
-
- - - -
{ - if (!panelProps.mobile) scrollContainerRef = el - }} - class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" - > - - - {(directory) => ( - - )} - - -
- - store.activeWorkspace} - workspaceLabel={workspaceLabel} - /> - -
-
- - -
- +
+
+ + )}
+ {autoselecting() ?? ""}
diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index c4774e61b65..d1723b6e27a 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,4 +1,4 @@ -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { type Session } from "@kilocode/sdk/v2/client" type SessionStore = { @@ -31,7 +31,7 @@ function sortSessions(now: number) { const isRootVisibleSession = (session: Session, directory: string) => workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived -const roots = (store: SessionStore) => +export const roots = (store: SessionStore) => (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now)) diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index bc8bade3bcd..adb46170a87 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -4,7 +4,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { A, useParams } from "@solidjs/router" import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js" import { useGlobalSync } from "@/context/global-sync" @@ -19,6 +19,12 @@ import { childSessionOnPath, hasProjectPermissions } from "./helpers" const KILO_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" +export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) { + return id === KILO_PROJECT_ID + ? "https://kilo.ai/favicon.svg" + : (icon?.override ?? (icon?.color ? undefined : icon?.url)) +} + export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const globalSync = useGlobalSync() const notification = useNotification() @@ -42,9 +48,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
{
- + {(child) => (
- +
)}
diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 7c9ae1aafba..076e1ef88b5 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -1,6 +1,6 @@ import { createMemo, For, Show, type Accessor, type JSX } from "solid-js" import { createStore } from "solid-js/store" -import { base64Encode } from "@opencode-ai/util/encode" +import { base64Encode } from "@opencode-ai/shared/util/encode" import { Button } from "@opencode-ai/ui/button" import { ContextMenu } from "@opencode-ai/ui/context-menu" import { HoverCard } from "@opencode-ai/ui/hover-card" diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 805830f4b64..a39e28cdb3b 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -3,8 +3,8 @@ import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "so import { createStore } from "solid-js/store" import { createSortable } from "@thisbeyond/solid-dnd" import { createMediaQuery } from "@solid-primitives/media" -import { base64Encode } from "@opencode-ai/util/encode" -import { getFilename } from "@opencode-ai/util/path" +import { base64Encode } from "@opencode-ai/shared/util/encode" +import { getFilename } from "@opencode-ai/shared/util/path" import { Button } from "@opencode-ai/ui/button" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" @@ -14,10 +14,11 @@ import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip } from "@opencode-ai/ui/tooltip" import { type Session } from "@kilocode/sdk/v2/client" import { type LocalProject } from "@/context/layout" -import { useGlobalSync } from "@/context/global-sync" +import { loadSessionsQuery, useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" import { sortedRootSessions, workspaceKey } from "./helpers" +import { useQuery } from "@tanstack/solid-query" type InlineEditorComponent = (props: { id: string @@ -277,7 +278,7 @@ const WorkspaceSessionList = (props: { class="flex w-full text-left justify-start text-14-regular text-text-weak pl-2 pr-10" size="large" onClick={(e: MouseEvent) => { - props.loadMore() + void props.loadMore() ;(e.currentTarget as HTMLButtonElement).blur() }} > @@ -316,12 +317,11 @@ export const SortableWorkspace = (props: { }) const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local())) const boot = createMemo(() => open() || active()) - const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) const count = createMemo(() => sessions()?.length ?? 0) const hasMore = createMemo(() => workspaceStore.sessionTotal > count()) + const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) })) const busy = createMemo(() => props.ctx.isBusy(props.directory)) - const wasBusy = createMemo((prev) => prev || busy(), false) - const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy()) + const loading = () => query.isLoading && count() === 0 const touch = createMediaQuery("(hover: none)") const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id))) const loadMore = async () => { @@ -426,7 +426,7 @@ export const SortableWorkspace = (props: { mobile={props.mobile} ctx={props.ctx} showNew={showNew} - loading={loading} + loading={() => query.isLoading && count() === 0} sessions={sessions} hasMore={hasMore} loadMore={loadMore} @@ -452,10 +452,10 @@ export const LocalWorkspace = (props: { }) const slug = createMemo(() => base64Encode(props.project.worktree)) const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) - const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const count = createMemo(() => sessions()?.length ?? 0) - const loading = createMemo(() => !booted() && count() === 0) + const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) })) const hasMore = createMemo(() => workspace().store.sessionTotal > count()) + const loading = () => query.isLoading && count() === 0 const loadMore = async () => { workspace().setStore("limit", (limit) => (limit ?? 0) + 5) await globalSync.project.loadSessions(props.project.worktree) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index deb088490e6..787b2199735 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,6 +1,6 @@ -import type { Project, UserMessage, VcsFileDiff } from "@kilocode/sdk/v2" +import type { Project, UserMessage } from "@kilocode/sdk/v2" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useMutation } from "@tanstack/solid-query" +import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" import { batch, onCleanup, @@ -13,6 +13,7 @@ import { on, onMount, untrack, + createResource, } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import { createMediaQuery } from "@solid-primitives/media" @@ -27,7 +28,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { Button } from "@opencode-ai/ui/button" import { showToast } from "@opencode-ai/ui/toast" -import { checksum } from "@opencode-ai/util/encode" +import { checksum } from "@opencode-ai/shared/util/encode" import { useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" import { useComments } from "@/context/comments" @@ -323,6 +324,7 @@ export default function Page() { const local = useLocal() const file = useFile() const sync = useSync() + const queryClient = useQueryClient() const dialog = useDialog() const language = useLanguage() const sdk = useSDK() @@ -432,8 +434,6 @@ export default function Page() { const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const isChildSession = createMemo(() => !!info()?.parentID) const diffs = createMemo(() => (params.id ? list(sync.data.session_diff[params.id]) : [])) - const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) - const hasSessionReview = createMemo(() => sessionCount() > 0) const canReview = createMemo(() => !!sync.project) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ @@ -443,8 +443,6 @@ export default function Page() { review: reviewTab, hasReview: canReview, }) - const contextOpen = tabState.contextOpen - const openedTabs = tabState.openedTabs const activeTab = tabState.activeTab const activeFileTab = tabState.activeFileTab const revertMessageID = createMemo(() => info()?.revert?.messageID) @@ -487,7 +485,7 @@ export default function Page() { if (!tab) return const path = file.pathFromTab(tab) - if (path) file.load(path) + if (path) void file.load(path) }) createEffect( @@ -521,26 +519,6 @@ export default function Page() { deferRender: false, }) - const [vcs, setVcs] = createStore<{ - diff: { - git: VcsFileDiff[] - branch: VcsFileDiff[] - } - ready: { - git: boolean - branch: boolean - } - }>({ - diff: { - git: [] as VcsFileDiff[], - branch: [] as VcsFileDiff[], - }, - ready: { - git: false, - branch: false, - }, - }) - const [followup, setFollowup] = persisted( Persist.workspace(sdk.directory, "followup", ["followup.v1"]), createStore<{ @@ -574,68 +552,6 @@ export default function Page() { let todoTimer: number | undefined let diffFrame: number | undefined let diffTimer: number | undefined - const vcsTask = new Map>() - const vcsRun = new Map() - - const bumpVcs = (mode: VcsMode) => { - const next = (vcsRun.get(mode) ?? 0) + 1 - vcsRun.set(mode, next) - return next - } - - const resetVcs = (mode?: VcsMode) => { - const list = mode ? [mode] : (["git", "branch"] as const) - list.forEach((item) => { - bumpVcs(item) - vcsTask.delete(item) - setVcs("diff", item, []) - setVcs("ready", item, false) - }) - } - - const loadVcs = (mode: VcsMode, force = false) => { - if (sync.project?.vcs !== "git") return Promise.resolve() - if (!force && vcs.ready[mode]) return Promise.resolve() - - if (force) { - if (vcsTask.has(mode)) bumpVcs(mode) - vcsTask.delete(mode) - setVcs("ready", mode, false) - } - - const current = vcsTask.get(mode) - if (current) return current - - const run = bumpVcs(mode) - - const task = sdk.client.vcs - .diff({ mode }) - .then((result) => { - if (vcsRun.get(mode) !== run) return - setVcs("diff", mode, list(result.data)) - setVcs("ready", mode, true) - }) - .catch((error) => { - if (vcsRun.get(mode) !== run) return - console.debug("[session-review] failed to load vcs diff", { mode, error }) - setVcs("diff", mode, []) - setVcs("ready", mode, true) - }) - .finally(() => { - if (vcsTask.get(mode) === task) vcsTask.delete(mode) - }) - - vcsTask.set(mode, task) - return task - } - - const refreshVcs = () => { - resetVcs() - const mode = untrack(vcsMode) - if (!mode) return - if (!untrack(wantsReview)) return - void loadVcs(mode, true) - } createComputed((prev) => { const open = desktopReviewOpen() @@ -666,21 +582,52 @@ export default function Page() { list.push("turn") return list }) + const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") + const wantsReview = createMemo(() => + isDesktop() + ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") + : store.mobileTab === "changes", + ) const vcsMode = createMemo(() => { if (store.changes === "git" || store.changes === "branch") return store.changes }) - const reviewDiffs = createMemo(() => { - if (store.changes === "git") return list(vcs.diff.git) - if (store.changes === "branch") return list(vcs.diff.branch) + const vcsKey = createMemo( + () => ["session-vcs", sdk.directory, sync.data.vcs?.branch ?? "", sync.data.vcs?.default_branch ?? ""] as const, + ) + const vcsQuery = createQuery(() => { + const mode = vcsMode() + const enabled = wantsReview() && sync.project?.vcs === "git" + + return { + queryKey: [...vcsKey(), mode] as const, + enabled, + staleTime: Number.POSITIVE_INFINITY, + gcTime: 60 * 1000, + queryFn: mode + ? () => + sdk.client.vcs + .diff({ mode }) + .then((result) => list(result.data)) + .catch((error) => { + console.debug("[session-review] failed to load vcs diff", { mode, error }) + return [] + }) + : skipToken, + } + }) + const refreshVcs = () => void queryClient.invalidateQueries({ queryKey: vcsKey() }) + const reviewDiffs = () => { + if (store.changes === "git" || store.changes === "branch") + // avoids suspense + return vcsQuery.isFetched ? (vcsQuery.data ?? []) : [] return turnDiffs() - }) - const reviewCount = createMemo(() => reviewDiffs().length) - const hasReview = createMemo(() => reviewCount() > 0) - const reviewReady = createMemo(() => { - if (store.changes === "git") return vcs.ready.git - if (store.changes === "branch") return vcs.ready.branch + } + const reviewCount = () => reviewDiffs().length + const hasReview = () => reviewCount() > 0 + const reviewReady = () => { + if (store.changes === "git" || store.changes === "branch") return !vcsQuery.isPending return true - }) + } const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" @@ -808,8 +755,9 @@ export default function Page() { const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs - createEffect( - on([() => sdk.directory, () => params.id] as const, ([, id]) => { + const [sessionSync] = createResource( + () => [sdk.directory, params.id] as const, + ([directory, id]) => { if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) refreshFrame = undefined @@ -820,13 +768,10 @@ export default function Page() { const stale = !cached ? false : (() => { - const info = getSessionPrefetch(sdk.directory, id) + const info = getSessionPrefetch(directory, id) if (!info) return true return Date.now() - info.at > SESSION_PREFETCH_TTL })() - untrack(() => { - void sync.session.sync(id) - }) refreshFrame = requestAnimationFrame(() => { refreshFrame = undefined @@ -838,7 +783,9 @@ export default function Page() { }) }, 0) }) - }), + + return sync.session.sync(id) + }, ) createEffect( @@ -900,27 +847,6 @@ export default function Page() { ), ) - createEffect( - on( - () => sdk.directory, - () => { - resetVcs() - }, - { defer: true }, - ), - ) - - createEffect( - on( - () => [sync.data.vcs?.branch, sync.data.vcs?.default_branch] as const, - (next, prev) => { - if (prev === undefined || same(next, prev)) return - refreshVcs() - }, - { defer: true }, - ), - ) - const stopVcs = sdk.event.listen((evt) => { if (evt.details.type !== "file.watcher.updated") return const props = @@ -1054,13 +980,6 @@ export default function Page() { } } - const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") - const wantsReview = createMemo(() => - isDesktop() - ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") - : store.mobileTab === "changes", - ) - createEffect(() => { const list = changesOptions() if (list.includes(store.changes)) return @@ -1069,22 +988,12 @@ export default function Page() { setStore("changes", next) }) - createEffect(() => { - const mode = vcsMode() - if (!mode) return - if (!wantsReview()) return - void loadVcs(mode) - }) - createEffect( on( () => sync.data.session_status[params.id ?? ""]?.type, (next, prev) => { - const mode = vcsMode() - if (!mode) return - if (!wantsReview()) return if (next !== "idle" || prev === undefined || prev === "idle") return - void loadVcs(mode, true) + refreshVcs() }, { defer: true }, ), @@ -1885,6 +1794,7 @@ export default function Page() { return (
+ {sessionSync() ?? ""}
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index cb761752361..37bffcd2fa5 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -6,7 +6,7 @@ import type { FileSearchHandle } from "@opencode-ai/ui/file" import { useFileComponent } from "@opencode-ai/ui/context/file" import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { createLineCommentController } from "@opencode-ai/ui/line-comment-annotations" -import { sampledChecksum } from "@opencode-ai/util/encode" +import { sampledChecksum } from "@opencode-ai/shared/util/encode" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tabs } from "@opencode-ai/ui/tabs" @@ -378,12 +378,6 @@ export function FileTabContent(props: { tab: string }) { requestAnimationFrame(() => comments.clearFocus()) }) - const cancelCommenting = () => { - const p = path() - if (p) file.setSelectedLines(p, null) - setNote("commenting", null) - } - let prev = { loaded: false, ready: false, diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index f3215f68508..e136ba9991b 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -117,7 +117,7 @@ export const createOpenReviewFile = (input: { input.openTab(tab) input.setActive(tab) } - if (maybePromise instanceof Promise) maybePromise.then(open) + if (maybePromise instanceof Promise) void maybePromise.then(open) else open() }) } diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 0d424ee5b6f..90e74e90a88 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -15,8 +15,8 @@ import { ScrollView } from "@opencode-ai/ui/scroll-view" import { TextField } from "@opencode-ai/ui/text-field" import type { AssistantMessage, Message as MessageType, Part, TextPart, UserMessage } from "@kilocode/sdk/v2" import { showToast } from "@opencode-ai/ui/toast" -import { Binary } from "@opencode-ai/util/binary" -import { getFilename } from "@opencode-ai/util/path" +import { Binary } from "@opencode-ai/shared/util/binary" +import { getFilename } from "@opencode-ai/shared/util/path" import { Popover as KobaltePopover } from "@kobalte/core/popover" import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture" import { SessionContextUsage } from "@/components/session-context-usage" @@ -259,7 +259,7 @@ export function MessageTimeline(props: { if (!id) return idle return sync.data.session_status[id] ?? idle }) - const working = createMemo(() => !!pending() || sessionStatus().type !== "idle") + const working = createMemo(() => sessionStatus().type !== "idle") const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent)) const [timeoutDone, setTimeoutDone] = createSignal(true) @@ -721,7 +721,7 @@ export function MessageTimeline(props: { "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, }} > - +
- + {(id) => (
@@ -878,12 +878,12 @@ export function MessageTimeline(props: { - void archiveSession(id())}> + void archiveSession(id)}> {language.t("common.archive")} dialog.show(() => )} + onSelect={() => dialog.show(() => )} > {language.t("common.delete")} diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index f0c1ae186c0..47cfb330cbd 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,4 +1,4 @@ -import { createEffect, createSignal, onCleanup, type JSX } from "solid-js" +import { createEffect, onCleanup, type JSX } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import type { SnapshotFileDiff, VcsFileDiff } from "@kilocode/sdk/v2" import { SessionReview } from "@opencode-ai/ui/session-review" diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 3e882761b52..77d30ba3a82 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -19,6 +19,9 @@ import { useCommand } from "@/context/command" import { useFile, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" +import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" +import { useSync } from "@/context/sync" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" @@ -39,6 +42,9 @@ export function SessionSidePanel(props: { size: Sizing }) { const layout = useLayout() + const platform = usePlatform() + const settings = useSettings() + const sync = useSync() const file = useFile() const language = useLanguage() const command = useCommand() @@ -46,9 +52,15 @@ export function SessionSidePanel(props: { const { sessionKey, tabs, view } = useSessionLayout() const isDesktop = createMediaQuery("(min-width: 768px)") + const shown = createMemo( + () => + platform.platform !== "desktop" || + import.meta.env.VITE_KILO_CHANNEL !== "beta" || + settings.general.showFileTree(), + ) const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) - const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened()) + const fileOpen = createMemo(() => isDesktop() && shown() && layout.fileTree.opened()) const open = createMemo(() => reviewOpen() || fileOpen()) const reviewTab = createMemo(() => isDesktop()) const panelWidth = createMemo(() => { @@ -341,98 +353,99 @@ export function SessionSidePanel(props: {
-
+
- - - - {props.reviewCount()}{" "} - {language.t( - props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", - )} - - - {language.t("session.files.all")} - - - - - - - {language.t("common.loading")} - {language.t("common.loading.ellipsis")} -
- } - > + + + + {props.reviewCount()}{" "} + {language.t( + props.reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other", + )} + + + {language.t("session.files.all")} + + + + + + + {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
+ } + > + props.focusReviewDiff(node.path)} + /> +
+ + + + + + {empty(language.t("session.files.empty"))} + props.focusReviewDiff(node.path)} + onFileClick={(node) => openTab(file.tab(node.path))} /> - - - {empty(props.empty())} - - - - - {empty(language.t("session.files.empty"))} - - openTab(file.tab(node.path))} - /> - - - - -
- -
props.size.start()}> - { - props.size.touch() - layout.fileTree.resize(width) - }} - /> + + + +
-
-
+ +
props.size.start()}> + { + props.size.touch() + layout.fileTree.resize(width) + }} + /> +
+
+
+
diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index db0dd1e9fab..37556067231 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -7,12 +7,14 @@ import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { useLocal } from "@/context/local" import { usePermission } from "@/context/permission" +import { usePlatform } from "@/context/platform" import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" +import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { showToast } from "@opencode-ai/ui/toast" -import { findLast } from "@opencode-ai/util/array" +import { findLast } from "@opencode-ai/shared/util/array" import { createSessionTabs } from "@/pages/session/helpers" import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@kilocode/sdk/v2" @@ -39,8 +41,10 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const language = useLanguage() const local = useLocal() const permission = usePermission() + const platform = usePlatform() const prompt = usePrompt() const sdk = useSDK() + const settings = useSettings() const sync = useSync() const terminal = useTerminal() const layout = useLayout() @@ -66,6 +70,8 @@ export const useSessionCommands = (actions: SessionCommandContext) => { }) const activeFileTab = tabState.activeFileTab const closableTab = tabState.closableTab + const shown = () => + platform.platform !== "desktop" || import.meta.env.VITE_KILO_CHANNEL !== "beta" || settings.general.showFileTree() const idle = { type: "idle" as const } const status = () => sync.data.session_status[params.id ?? ""] ?? idle @@ -457,12 +463,16 @@ export const useSessionCommands = (actions: SessionCommandContext) => { keybind: "mod+shift+r", onSelect: () => view().reviewPanel.toggle(), }), - viewCommand({ - id: "fileTree.toggle", - title: language.t("command.fileTree.toggle"), - keybind: "mod+\\", - onSelect: () => layout.fileTree.toggle(), - }), + ...(shown() + ? [ + viewCommand({ + id: "fileTree.toggle", + title: language.t("command.fileTree.toggle"), + keybind: "mod+\\", + onSelect: () => layout.fileTree.toggle(), + }), + ] + : []), viewCommand({ id: "input.focus", title: language.t("command.input.focus"), diff --git a/packages/app/src/utils/base64.ts b/packages/app/src/utils/base64.ts index c1f9d88c6e9..f60dff2b6d8 100644 --- a/packages/app/src/utils/base64.ts +++ b/packages/app/src/utils/base64.ts @@ -1,4 +1,4 @@ -import { base64Decode } from "@opencode-ai/util/encode" +import { base64Decode } from "@opencode-ai/shared/util/encode" export function decode64(value: string | undefined) { if (value === undefined) return diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 3dcbeb7d367..0cac30cb1e2 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -1,6 +1,6 @@ import { Platform, usePlatform } from "@/context/platform" import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage" -import { checksum } from "@opencode-ai/util/encode" +import { checksum } from "@opencode-ai/shared/util/encode" import { createResource, type Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" @@ -469,7 +469,7 @@ export function persisted( state, setState, init, - Object.assign(() => ready() === true, { + Object.assign(() => (ready.loading ? false : ready.latest === true), { promise: init instanceof Promise ? init : undefined, }), ] diff --git a/packages/app/src/utils/runtime-adapters.test.ts b/packages/app/src/utils/runtime-adapters.test.ts index 9f408b8eb7e..49552e179c2 100644 --- a/packages/app/src/utils/runtime-adapters.test.ts +++ b/packages/app/src/utils/runtime-adapters.test.ts @@ -46,7 +46,9 @@ describe("runtime adapters", () => { }) test("resolves speech recognition constructor with webkit precedence", () => { + // oxlint-disable-next-line no-extraneous-class class SpeechCtor {} + // oxlint-disable-next-line no-extraneous-class class WebkitCtor {} const ctor = getSpeechRecognitionCtor({ SpeechRecognition: SpeechCtor, diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index 3a8e7f99b7f..139641882c5 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -16,7 +16,10 @@ export function createSdkForServer({ return createKiloClient({ ...config, - headers: { ...config.headers, ...auth }, + headers: { + ...(config.headers instanceof Headers ? Object.fromEntries(config.headers.entries()) : config.headers), + ...auth, + }, baseUrl: server.url, }) } diff --git a/packages/console/core/migrations/20260417071612_tidy_diamondback/snapshot.json b/packages/console/core/migrations/20260417071612_tidy_diamondback/snapshot.json new file mode 100644 index 00000000000..2152bfa76fe --- /dev/null +++ b/packages/console/core/migrations/20260417071612_tidy_diamondback/snapshot.json @@ -0,0 +1,2567 @@ +{ + "version": "6", + "dialect": "mysql", + "id": "93c492af-c95b-4213-9fc2-38c3dd10374d", + "prevIds": ["a09a925d-6cdd-4e7c-b8b1-11c259928b4c"], + "ddl": [ + { + "name": "account", + "entityType": "tables" + }, + { + "name": "auth", + "entityType": "tables" + }, + { + "name": "benchmark", + "entityType": "tables" + }, + { + "name": "billing", + "entityType": "tables" + }, + { + "name": "lite", + "entityType": "tables" + }, + { + "name": "payment", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "name": "usage", + "entityType": "tables" + }, + { + "name": "ip_rate_limit", + "entityType": "tables" + }, + { + "name": "ip", + "entityType": "tables" + }, + { + "name": "key_rate_limit", + "entityType": "tables" + }, + { + "name": "model_rate_limit", + "entityType": "tables" + }, + { + "name": "key", + "entityType": "tables" + }, + { + "name": "model", + "entityType": "tables" + }, + { + "name": "provider", + "entityType": "tables" + }, + { + "name": "user", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "account" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "auth" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "auth" + }, + { + "type": "enum('email','github','google')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subject", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "auth" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "agent", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "mediumtext", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "result", + "entityType": "columns", + "table": "benchmark" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(32)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_type", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(4)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_method_last4", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "balance", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "billing" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "billing" + }, + { + "type": "boolean", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_trigger", + "entityType": "columns", + "table": "billing" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_amount", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_error", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_reload_locked_till", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "enum('20','100','200')", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "subscription_plan", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_booked", + "entityType": "columns", + "table": "billing" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_subscription_selected", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(28)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite_subscription_id", + "entityType": "columns", + "table": "billing" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "lite", + "entityType": "columns", + "table": "billing" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "weekly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_weekly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_updated", + "entityType": "columns", + "table": "lite" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "invoice_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "payment_id", + "entityType": "columns", + "table": "payment" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "amount", + "entityType": "columns", + "table": "payment" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_refunded", + "entityType": "columns", + "table": "payment" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "payment" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "rolling_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "fixed_usage", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_rolling_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_fixed_updated", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "usage" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "input_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "output_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "reasoning_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_read_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_5m_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cache_write_1h_tokens", + "entityType": "columns", + "table": "usage" + }, + { + "type": "bigint", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "cost", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "usage" + }, + { + "type": "json", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "enrichment", + "entityType": "columns", + "table": "usage" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(10)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "ip_rate_limit" + }, + { + "type": "varchar(45)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "ip", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "ip" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "ip" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "usage", + "entityType": "columns", + "table": "ip" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "key_rate_limit" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "model_rate_limit" + }, + { + "type": "varchar(40)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "interval", + "entityType": "columns", + "table": "model_rate_limit" + }, + { + "type": "int", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "count", + "entityType": "columns", + "table": "model_rate_limit" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "key", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "key" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_used", + "entityType": "columns", + "table": "key" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "model" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "model", + "entityType": "columns", + "table": "model" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "provider" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(64)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "provider", + "entityType": "columns", + "table": "provider" + }, + { + "type": "text", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "credentials", + "entityType": "columns", + "table": "provider" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "account_id", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_seen", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "color", + "entityType": "columns", + "table": "user" + }, + { + "type": "enum('admin','member')", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "role", + "entityType": "columns", + "table": "user" + }, + { + "type": "int", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_limit", + "entityType": "columns", + "table": "user" + }, + { + "type": "bigint", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "monthly_usage", + "entityType": "columns", + "table": "user" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_monthly_usage_updated", + "entityType": "columns", + "table": "user" + }, + { + "type": "varchar(30)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "varchar(255)", + "notNull": true, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(now())", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": true, + "autoIncrement": false, + "default": "(CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3))", + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "timestamp(3)", + "notNull": false, + "autoIncrement": false, + "default": null, + "onUpdateNow": false, + "onUpdateNowFsp": null, + "charSet": null, + "collation": null, + "generated": null, + "name": "time_deleted", + "entityType": "columns", + "table": "workspace" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "auth", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "benchmark", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "billing", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "lite", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "payment", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "subscription", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "usage", + "entityType": "pks" + }, + { + "columns": ["ip", "interval"], + "name": "PRIMARY", + "table": "ip_rate_limit", + "entityType": "pks" + }, + { + "columns": ["ip"], + "name": "PRIMARY", + "table": "ip", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "key_rate_limit", + "entityType": "pks" + }, + { + "columns": ["key", "interval"], + "name": "PRIMARY", + "table": "model_rate_limit", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "key", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "model", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "provider", + "entityType": "pks" + }, + { + "columns": ["workspace_id", "id"], + "name": "PRIMARY", + "table": "user", + "entityType": "pks" + }, + { + "columns": ["id"], + "name": "PRIMARY", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "provider", + "isExpression": false + }, + { + "value": "subject", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "provider", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "account_id", + "entityType": "indexes", + "table": "auth" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "time_created", + "entityType": "indexes", + "table": "benchmark" + }, + { + "columns": [ + { + "value": "customer_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_customer_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "subscription_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_subscription_id", + "entityType": "indexes", + "table": "billing" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "lite" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "user_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_user_id", + "entityType": "indexes", + "table": "subscription" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "usage_time_created", + "entityType": "indexes", + "table": "usage" + }, + { + "columns": [ + { + "value": "key", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_key", + "entityType": "indexes", + "table": "key" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "model", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "model_workspace_model", + "entityType": "indexes", + "table": "model" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "provider", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "workspace_provider", + "entityType": "indexes", + "table": "provider" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + }, + { + "value": "email", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "user_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "account_id", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_account_id", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "email", + "isExpression": false + } + ], + "isUnique": false, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "global_email", + "entityType": "indexes", + "table": "user" + }, + { + "columns": [ + { + "value": "slug", + "isExpression": false + } + ], + "isUnique": true, + "using": null, + "algorithm": null, + "lock": null, + "nameExplicit": true, + "name": "slug", + "entityType": "indexes", + "table": "workspace" + } + ], + "renames": [] +} diff --git a/packages/containers/bun-node/Dockerfile b/packages/containers/bun-node/Dockerfile index 045ff7512ce..6998577bd53 100644 --- a/packages/containers/bun-node/Dockerfile +++ b/packages/containers/bun-node/Dockerfile @@ -6,7 +6,7 @@ FROM ${REGISTRY}/build/base:24.04 SHELL ["/bin/bash", "-lc"] ARG NODE_VERSION=24.4.0 -ARG BUN_VERSION=1.3.11 +ARG BUN_VERSION=1.3.13 ENV BUN_INSTALL=/opt/bun ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin diff --git a/packages/desktop-electron/electron.vite.config.ts b/packages/desktop-electron/electron.vite.config.ts index 723d01ebbc6..22eedeada64 100644 --- a/packages/desktop-electron/electron.vite.config.ts +++ b/packages/desktop-electron/electron.vite.config.ts @@ -53,6 +53,10 @@ export default defineConfig({ build: { rollupOptions: { input: { index: "src/preload/index.ts" }, + output: { + format: "cjs", + entryFileNames: "[name].js", + }, }, }, }, @@ -60,6 +64,9 @@ export default defineConfig({ plugins: [appPlugin], publicDir: "../../../app/public", root: "src/renderer", + define: { + "import.meta.env.VITE_KILO_CHANNEL": JSON.stringify(channel), + }, build: { rollupOptions: { input: { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 101b5ae6a50..bde4ac24ed5 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "7.2.14", + "version": "7.2.30", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", @@ -30,6 +30,7 @@ "electron-store": "^10", "electron-updater": "^6", "electron-window-state": "^5.0.3", + "drizzle-orm": "catalog:", "marked": "^15", "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -53,7 +54,7 @@ "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "@valibot/to-json-schema": "1.6.0", - "electron": "40.8.5", + "electron": "41.2.1", "electron-builder": "^26", "electron-vite": "^5", "solid-js": "catalog:", diff --git a/packages/desktop-electron/src/main/apps.ts b/packages/desktop-electron/src/main/apps.ts index 2b460378948..174da94a5d9 100644 --- a/packages/desktop-electron/src/main/apps.ts +++ b/packages/desktop-electron/src/main/apps.ts @@ -20,7 +20,7 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string try { if (path.startsWith("~")) { const suffix = path.slice(1) - const cmd = `wslpath ${flag} \"$HOME${suffix.replace(/\"/g, '\\"')}\"` + const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"` const output = execFileSync("wsl", ["-e", "sh", "-lc", cmd]) return output.toString().trim() } @@ -28,7 +28,7 @@ export function wslPath(path: string, mode: "windows" | "linux" | null): string const output = execFileSync("wsl", ["-e", "wslpath", flag, path]) return output.toString().trim() } catch (error) { - throw new Error(`Failed to run wslpath: ${String(error)}`) + throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error }) } } diff --git a/packages/desktop-electron/src/main/index.ts b/packages/desktop-electron/src/main/index.ts index 5cee8a97194..f00ba5d5732 100644 --- a/packages/desktop-electron/src/main/index.ts +++ b/packages/desktop-electron/src/main/index.ts @@ -28,8 +28,10 @@ const APP_IDS: Record = { beta: "ai.opencode.desktop.beta", prod: "ai.opencode.desktop", } +const appId = app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev" app.setName(app.isPackaged ? APP_NAMES[CHANNEL] : "OpenCode Dev") -app.setPath("userData", join(app.getPath("appData"), app.isPackaged ? APP_IDS[CHANNEL] : "ai.opencode.desktop.dev")) +app.setAppUserModelId(appId) +app.setPath("userData", join(app.getPath("appData"), appId)) const { autoUpdater } = pkg import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types" @@ -40,7 +42,14 @@ import { initLogging } from "./logging" import { parseMarkdown } from "./markdown" import { createMenu } from "./menu" import { getDefaultServerUrl, getWslConfig, setDefaultServerUrl, setWslConfig, spawnLocalServer } from "./server" -import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows" +import { + createLoadingWindow, + createMainWindow, + registerRendererProtocol, + setBackgroundColor, + setDockIcon, +} from "./windows" +import { drizzle } from "drizzle-orm/node-sqlite/driver" import type { Server } from "virtual:opencode-server" const initEmitter = new EventEmitter() @@ -103,6 +112,7 @@ function setupApp() { void app.whenReady().then(async () => { app.setAsDefaultProtocolClient("opencode") + registerRendererProtocol() setDockIcon() setupAutoUpdater() await initialize() @@ -137,15 +147,6 @@ async function initialize() { const url = `http://${hostname}:${port}` const password = randomUUID() - logger.log("spawning sidecar", { url }) - const { listener, health } = await spawnLocalServer(hostname, port, password) - server = listener - serverReady.resolve({ - url, - username: "kilo", // kilocode_change - password, - }) - const loadingTask = (async () => { logger.log("sidecar connection started", { url }) @@ -156,10 +157,32 @@ async function initialize() { if (progress.type === "Done") sqliteDone?.resolve() }) + if (needsMigration) { + const { Database, JsonMigration } = await import("virtual:opencode-server") + await JsonMigration.run(drizzle({ client: Database.Client().$client }), { + progress: (event: { current: number; total: number }) => { + const percent = Math.round(event.current / event.total) * 100 + initEmitter.emit("sqlite", { type: "InProgress", value: percent }) + }, + }) + initEmitter.emit("sqlite", { type: "Done" }) + + sqliteDone?.resolve() + } + if (needsMigration) { await sqliteDone?.promise } + logger.log("spawning sidecar", { url }) + const { listener, health } = await spawnLocalServer(hostname, port, password) + server = listener + serverReady.resolve({ + url, + username: "kilo", // kilocode_change + password, + }) + await Promise.race([ health.wait, delay(30_000).then(() => { @@ -172,15 +195,10 @@ async function initialize() { logger.log("loading task finished") })() - const globals = { - updaterEnabled: UPDATER_ENABLED, - deepLinks: pendingDeepLinks, - } - if (needsMigration) { const show = await Promise.race([loadingTask.then(() => false), delay(1_000).then(() => true)]) if (show) { - overlay = createLoadingWindow(globals) + overlay = createLoadingWindow() await delay(1_000) } } @@ -192,7 +210,7 @@ async function initialize() { await loadingComplete.promise } - mainWindow = createMainWindow(globals) + mainWindow = createMainWindow() wireMenu() overlay?.close() @@ -229,6 +247,8 @@ registerIpcHandlers({ initEmitter.off("step", listener) } }, + getWindowConfig: () => ({ updaterEnabled: UPDATER_ENABLED }), + consumeInitialDeepLinks: () => pendingDeepLinks.splice(0), getDefaultServerUrl: () => getDefaultServerUrl(), setDefaultServerUrl: (url) => setDefaultServerUrl(url), getWslConfig: () => Promise.resolve(getWslConfig()), @@ -317,11 +337,16 @@ function setupAutoUpdater() { }) } -let updateReady = false +let downloadedUpdateVersion: string | undefined async function checkUpdate() { if (!UPDATER_ENABLED) return { updateAvailable: false } - updateReady = false + if (downloadedUpdateVersion) { + logger.log("returning cached downloaded update", { + version: downloadedUpdateVersion, + }) + return { updateAvailable: true, version: downloadedUpdateVersion } + } logger.log("checking for updates", { currentVersion: app.getVersion(), channel: autoUpdater.channel, @@ -347,7 +372,7 @@ async function checkUpdate() { logger.log("update available", { version }) await autoUpdater.downloadUpdate() logger.log("update download completed", { version }) - updateReady = true + downloadedUpdateVersion = version return { updateAvailable: true, version } } catch (error) { logger.error("update check failed", error) @@ -356,7 +381,15 @@ async function checkUpdate() { } async function installUpdate() { - if (!updateReady) return + if (!downloadedUpdateVersion) { + logger.log("install update skipped", { + reason: "no downloaded update ready", + }) + return + } + logger.log("installing downloaded update", { + version: downloadedUpdateVersion, + }) killSidecar() autoUpdater.quitAndInstall() } diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 52d87ed7ee3..8dbca8eea17 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -2,7 +2,14 @@ import { execFile } from "node:child_process" import { BrowserWindow, Notification, app, clipboard, dialog, ipcMain, shell } from "electron" import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" -import type { InitStep, ServerReadyData, SqliteMigrationProgress, TitlebarTheme, WslConfig } from "../preload/types" +import type { + InitStep, + ServerReadyData, + SqliteMigrationProgress, + TitlebarTheme, + WindowConfig, + WslConfig, +} from "../preload/types" import { getStore } from "./store" import { setTitlebar } from "./windows" @@ -14,6 +21,8 @@ const pickerFilters = (ext?: string[]) => { type Deps = { killSidecar: () => void awaitInitialization: (sendStep: (step: InitStep) => void) => Promise + getWindowConfig: () => Promise | WindowConfig + consumeInitialDeepLinks: () => Promise | string[] getDefaultServerUrl: () => Promise | string | null setDefaultServerUrl: (url: string | null) => Promise | void getWslConfig: () => Promise @@ -37,6 +46,8 @@ export function registerIpcHandlers(deps: Deps) { const send = (step: InitStep) => event.sender.send("init-step", step) return deps.awaitInitialization(send) }) + ipcMain.handle("get-window-config", () => deps.getWindowConfig()) + ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks()) ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl()) ipcMain.handle("set-default-server-url", (_event: IpcMainInvokeEvent, url: string | null) => deps.setDefaultServerUrl(url), diff --git a/packages/desktop-electron/src/main/menu.ts b/packages/desktop-electron/src/main/menu.ts index fcf209fb670..0d9a697fa90 100644 --- a/packages/desktop-electron/src/main/menu.ts +++ b/packages/desktop-electron/src/main/menu.ts @@ -47,7 +47,7 @@ export function createMenu(deps: Deps) { { label: "New Window", accelerator: "Cmd+Shift+N", - click: () => createMainWindow({ updaterEnabled: UPDATER_ENABLED }), + click: () => createMainWindow(), }, { type: "separator" }, { role: "close" }, diff --git a/packages/desktop-electron/src/main/migrate.ts b/packages/desktop-electron/src/main/migrate.ts index bad1349eeba..70e3dc9c750 100644 --- a/packages/desktop-electron/src/main/migrate.ts +++ b/packages/desktop-electron/src/main/migrate.ts @@ -4,7 +4,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" import { CHANNEL } from "./constants" -import { getStore, store } from "./store" +import { getStore } from "./store" const TAURI_MIGRATED_KEY = "tauriMigrated" @@ -67,7 +67,7 @@ function migrateFile(datPath: string, filename: string) { } export function migrate() { - if (store.get(TAURI_MIGRATED_KEY)) { + if (getStore().get(TAURI_MIGRATED_KEY)) { log.log("tauri migration: already done, skipping") return } @@ -77,7 +77,7 @@ export function migrate() { if (!existsSync(dir)) { log.log("tauri migration: no tauri data directory found, nothing to migrate") - store.set(TAURI_MIGRATED_KEY, true) + getStore().set(TAURI_MIGRATED_KEY, true) return } @@ -87,5 +87,5 @@ export function migrate() { } log.log("tauri migration: complete") - store.set(TAURI_MIGRATED_KEY, true) + getStore().set(TAURI_MIGRATED_KEY, true) } diff --git a/packages/desktop-electron/src/main/server.ts b/packages/desktop-electron/src/main/server.ts index 72a83b5a05f..8c8f0895e93 100644 --- a/packages/desktop-electron/src/main/server.ts +++ b/packages/desktop-electron/src/main/server.ts @@ -1,33 +1,33 @@ import { app } from "electron" import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants" import { getUserShell, loadShellEnv } from "./shell-env" -import { store } from "./store" +import { getStore } from "./store" export type WslConfig = { enabled: boolean } export type HealthCheck = { wait: Promise } export function getDefaultServerUrl(): string | null { - const value = store.get(DEFAULT_SERVER_URL_KEY) + const value = getStore().get(DEFAULT_SERVER_URL_KEY) return typeof value === "string" ? value : null } export function setDefaultServerUrl(url: string | null) { if (url) { - store.set(DEFAULT_SERVER_URL_KEY, url) + getStore().set(DEFAULT_SERVER_URL_KEY, url) return } - store.delete(DEFAULT_SERVER_URL_KEY) + getStore().delete(DEFAULT_SERVER_URL_KEY) } export function getWslConfig(): WslConfig { - const value = store.get(WSL_ENABLED_KEY) + const value = getStore().get(WSL_ENABLED_KEY) return { enabled: typeof value === "boolean" ? value : false } } export function setWslConfig(config: WslConfig) { - store.set(WSL_ENABLED_KEY, config.enabled) + getStore().set(WSL_ENABLED_KEY, config.enabled) } export async function spawnLocalServer(hostname: string, port: number, password: string) { @@ -39,6 +39,7 @@ export async function spawnLocalServer(hostname: string, port: number, password: hostname, username: "opencode", password, + cors: ["oc://renderer"], }) const wait = (async () => { diff --git a/packages/desktop-electron/src/main/shell-env.ts b/packages/desktop-electron/src/main/shell-env.ts index 8453a5730d1..f57677323c5 100644 --- a/packages/desktop-electron/src/main/shell-env.ts +++ b/packages/desktop-electron/src/main/shell-env.ts @@ -82,7 +82,7 @@ export function loadShellEnv(shell: string) { export function mergeShellEnv(shell: Record | null, env: Record) { return { - ...(shell || {}), + ...shell, ...env, } } diff --git a/packages/desktop-electron/src/main/store.ts b/packages/desktop-electron/src/main/store.ts index cf2d25b110c..61f0c0a4938 100644 --- a/packages/desktop-electron/src/main/store.ts +++ b/packages/desktop-electron/src/main/store.ts @@ -4,12 +4,14 @@ import { SETTINGS_STORE } from "./constants" const cache = new Map() +// We cannot instantiate the electron-store at module load time because +// module import hoisting causes this to run before app.setPath("userData", ...) +// in index.ts has executed, which would result in files being written to the default directory +// (e.g. bad: %APPDATA%\@opencode-ai\desktop-electron\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings). export function getStore(name = SETTINGS_STORE) { const cached = cache.get(name) if (cached) return cached - const next = new Store({ name, fileExtension: "" }) + const next = new Store({ name, fileExtension: "", accessPropertiesByDotNotation: false }) cache.set(name, next) return next } - -export const store = getStore(SETTINGS_STORE) diff --git a/packages/desktop-electron/src/main/windows.ts b/packages/desktop-electron/src/main/windows.ts index 192e2dab9c9..337e1ca0bcc 100644 --- a/packages/desktop-electron/src/main/windows.ts +++ b/packages/desktop-electron/src/main/windows.ts @@ -1,15 +1,24 @@ import windowState from "electron-window-state" -import { app, BrowserWindow, nativeImage, nativeTheme } from "electron" -import { dirname, join } from "node:path" -import { fileURLToPath } from "node:url" +import { app, BrowserWindow, net, nativeImage, nativeTheme, protocol } from "electron" +import { dirname, isAbsolute, join, relative, resolve } from "node:path" +import { fileURLToPath, pathToFileURL } from "node:url" import type { TitlebarTheme } from "../preload/types" -type Globals = { - updaterEnabled: boolean - deepLinks?: string[] -} - const root = dirname(fileURLToPath(import.meta.url)) +const rendererRoot = join(root, "../renderer") +const rendererProtocol = "oc" +const rendererHost = "renderer" + +protocol.registerSchemesAsPrivileged([ + { + scheme: rendererProtocol, + privileges: { + secure: true, + standard: true, + supportFetchAPI: true, + }, + }, +]) let backgroundColor: string | undefined @@ -54,7 +63,7 @@ export function setDockIcon() { if (!icon.isEmpty()) app.dock?.setIcon(icon) } -export function createMainWindow(globals: Globals) { +export function createMainWindow() { const state = windowState({ defaultWidth: 1280, defaultHeight: 800, @@ -84,15 +93,29 @@ export function createMainWindow(globals: Globals) { } : {}), webPreferences: { - preload: join(root, "../preload/index.mjs"), - sandbox: false, + preload: join(root, "../preload/index.js"), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, }, }) + win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => { + const { requestHeaders } = details + upsertKeyValue(requestHeaders, "Access-Control-Allow-Origin", ["*"]) + callback({ requestHeaders }) + }) + + win.webContents.session.webRequest.onHeadersReceived((details, callback) => { + const { responseHeaders = {} } = details + upsertKeyValue(responseHeaders, "Access-Control-Allow-Origin", ["*"]) + upsertKeyValue(responseHeaders, "Access-Control-Allow-Headers", ["*"]) + callback({ responseHeaders }) + }) + state.manage(win) loadWindow(win, "index.html") wireZoom(win) - injectGlobals(win, globals) win.once("ready-to-show", () => { win.show() @@ -101,7 +124,7 @@ export function createMainWindow(globals: Globals) { return win } -export function createLoadingWindow(globals: Globals) { +export function createLoadingWindow() { const mode = tone() const win = new BrowserWindow({ width: 640, @@ -120,17 +143,37 @@ export function createLoadingWindow(globals: Globals) { } : {}), webPreferences: { - preload: join(root, "../preload/index.mjs"), - sandbox: false, + preload: join(root, "../preload/index.js"), + contextIsolation: true, + nodeIntegration: false, + sandbox: true, }, }) loadWindow(win, "loading.html") - injectGlobals(win, globals) return win } +export function registerRendererProtocol() { + if (protocol.isProtocolHandled(rendererProtocol)) return + + protocol.handle(rendererProtocol, (request) => { + const url = new URL(request.url) + if (url.host !== rendererHost) { + return new Response("Not found", { status: 404 }) + } + + const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`) + const rel = relative(rendererRoot, file) + if (rel.startsWith("..") || isAbsolute(rel)) { + return new Response("Not found", { status: 404 }) + } + + return net.fetch(pathToFileURL(file).toString()) + }) +} + function loadWindow(win: BrowserWindow, html: string) { const devUrl = process.env.ELECTRON_RENDERER_URL if (devUrl) { @@ -139,25 +182,25 @@ function loadWindow(win: BrowserWindow, html: string) { return } - void win.loadFile(join(root, `../renderer/${html}`)) + void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`) } - -function injectGlobals(win: BrowserWindow, globals: Globals) { - win.webContents.on("dom-ready", () => { - const deepLinks = globals.deepLinks ?? [] - const data = { - updaterEnabled: globals.updaterEnabled, - deepLinks: Array.isArray(deepLinks) ? deepLinks.splice(0) : deepLinks, - } - void win.webContents.executeJavaScript( - `window.__KILO__ = Object.assign(window.__KILO__ ?? {}, ${JSON.stringify(data)})`, - ) - }) -} - function wireZoom(win: BrowserWindow) { win.webContents.setZoomFactor(1) win.webContents.on("zoom-changed", () => { win.webContents.setZoomFactor(1) }) } + +function upsertKeyValue(obj: Record, keyToChange: string, value: any) { + const keyToChangeLower = keyToChange.toLowerCase() + for (const key of Object.keys(obj)) { + if (key.toLowerCase() === keyToChangeLower) { + // Reassign old key + obj[key] = value + // Done + return + } + } + // Insert at end instead + obj[keyToChange] = value +} diff --git a/packages/desktop-electron/src/preload/index.ts b/packages/desktop-electron/src/preload/index.ts index 296fcb2f1cc..6261419ca55 100644 --- a/packages/desktop-electron/src/preload/index.ts +++ b/packages/desktop-electron/src/preload/index.ts @@ -11,6 +11,8 @@ const api: ElectronAPI = { ipcRenderer.removeListener("init-step", handler) }) }, + getWindowConfig: () => ipcRenderer.invoke("get-window-config"), + consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"), getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"), setDefaultServerUrl: (url) => ipcRenderer.invoke("set-default-server-url", url), getWslConfig: () => ipcRenderer.invoke("get-wsl-config"), diff --git a/packages/desktop-electron/src/preload/types.ts b/packages/desktop-electron/src/preload/types.ts index f8e6d52c7db..6e22954d18f 100644 --- a/packages/desktop-electron/src/preload/types.ts +++ b/packages/desktop-electron/src/preload/types.ts @@ -15,10 +15,16 @@ export type TitlebarTheme = { mode: "light" | "dark" } +export type WindowConfig = { + updaterEnabled: boolean +} + export type ElectronAPI = { killSidecar: () => Promise installCli: () => Promise awaitInitialization: (onStep: (step: InitStep) => void) => Promise + getWindowConfig: () => Promise + consumeInitialDeepLinks: () => Promise getDefaultServerUrl: () => Promise setDefaultServerUrl: (url: string | null) => Promise getWslConfig: () => Promise diff --git a/packages/desktop-electron/src/renderer/env.d.ts b/packages/desktop-electron/src/renderer/env.d.ts index d1590ff0486..6dff3baf1c4 100644 --- a/packages/desktop-electron/src/renderer/env.d.ts +++ b/packages/desktop-electron/src/renderer/env.d.ts @@ -4,8 +4,6 @@ declare global { interface Window { api: ElectronAPI __OPENCODE__?: { - updaterEnabled?: boolean - wsl?: boolean deepLinks?: string[] } } diff --git a/packages/desktop-electron/src/renderer/html.test.ts b/packages/desktop-electron/src/renderer/html.test.ts index bd8281c2fbe..1fc5c87178b 100644 --- a/packages/desktop-electron/src/renderer/html.test.ts +++ b/packages/desktop-electron/src/renderer/html.test.ts @@ -9,9 +9,9 @@ const root = resolve(dir, "../..") const html = async (name: string) => Bun.file(join(dir, name)).text() /** - * Electron loads renderer HTML via `win.loadFile()` which uses the `file://` - * protocol. Absolute paths like `src="/foo.js"` resolve to the filesystem root - * (e.g. `file:///C:/foo.js` on Windows) instead of relative to the app bundle. + * Packaged Electron windows load renderer HTML via the privileged `oc://` + * protocol. Root-relative asset paths like `src="/foo.js"` would resolve from + * the protocol origin root instead of relative to the current HTML entrypoint. * * All local resource references must use relative paths (`./`). */ diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 54a7ea2b57b..a1a11adf198 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -20,7 +20,6 @@ import { createEffect, createResource, onCleanup, onMount, Show } from "solid-js import { render } from "solid-js/web" import pkg from "../../package.json" import { initI18n, t } from "./i18n" -import { UPDATER_ENABLED } from "./updater" import { webviewZoom } from "./webview-zoom" import "./styles.css" import { useTheme } from "@opencode-ai/ui/theme" @@ -43,8 +42,7 @@ const emitDeepLinks = (urls: string[]) => { } const listenForDeepLinks = () => { - const startUrls = window.__KILO__?.deepLinks ?? [] - if (startUrls.length) emitDeepLinks(startUrls) + void window.api.consumeInitialDeepLinks().then((urls) => emitDeepLinks(urls)) return window.api.onDeepLink((urls) => emitDeepLinks(urls)) } @@ -57,13 +55,21 @@ const createPlatform = (): Platform => { return undefined })() + const isWslEnabled = async () => { + if (os !== "windows") return false + return window.api + .getWslConfig() + .then((config) => config.enabled) + .catch(() => false) + } + const wslHome = async () => { - if (os !== "windows" || !window.__KILO__?.wsl) return undefined + if (!(await isWslEnabled())) return undefined return window.api.wslPath("~", "windows").catch(() => undefined) } const handleWslPicker = async (result: T | null): Promise => { - if (!result || !window.__KILO__?.wsl) return result + if (!result || !(await isWslEnabled())) return result if (Array.isArray(result)) { return Promise.all(result.map((path) => window.api.wslPath(path, "linux").catch(() => path))) as any } @@ -137,7 +143,7 @@ const createPlatform = (): Platform => { if (os === "windows") { const resolvedApp = app ? await window.api.resolveAppPath(app).catch(() => null) : null const resolvedPath = await (async () => { - if (window.__KILO__?.wsl) { + if (await isWslEnabled()) { const converted = await window.api.wslPath(path, "windows").catch(() => null) if (converted) return converted } @@ -159,12 +165,14 @@ const createPlatform = (): Platform => { storage, checkUpdate: async () => { - if (!UPDATER_ENABLED()) return { updateAvailable: false } + const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false })) + if (!config.updaterEnabled) return { updateAvailable: false } return window.api.checkUpdate() }, - update: async () => { - if (!UPDATER_ENABLED()) return + updateAndRestart: async () => { + const config = await window.api.getWindowConfig().catch(() => ({ updaterEnabled: false })) + if (!config.updaterEnabled) return await window.api.installUpdate() }, @@ -194,11 +202,7 @@ const createPlatform = (): Platform => { return fetch(input, init) }, - getWslEnabled: async () => { - const next = await window.api.getWslConfig().catch(() => null) - if (next) return next.enabled - return window.__KILO__!.wsl ?? false - }, + getWslEnabled: () => isWslEnabled(), setWslEnabled: async (enabled) => { await window.api.setWslConfig({ enabled }) @@ -249,6 +253,7 @@ listenForDeepLinks() render(() => { const platform = createPlatform() + const [windowConfig] = createResource(() => window.api.getWindowConfig().catch(() => ({ updaterEnabled: false }))) const loadLocale = async () => { const current = await platform.storage?.("opencode.global.dat").getItem("language") const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") @@ -325,7 +330,15 @@ render(() => { return ( - + {(_) => { return ( window.__KILO__?.updaterEnabled ?? false - export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) { await initI18n() try { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 21d53b38e90..05310cc3de3 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.2.14", + "version": "7.2.30", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 0b50db807e8..3da24107350 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2299,6 +2299,53 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kilo-desktop" +version = "0.0.0" +dependencies = [ + "chrono", + "comrak", + "dirs", + "futures", + "gtk", + "listeners", + "objc2 0.6.3", + "objc2-web-kit", + "process-wrap", + "reqwest 0.12.24", + "semver", + "serde", + "serde_json", + "specta", + "specta-typescript", + "tauri", + "tauri-build 2.5.2", + "tauri-plugin-clipboard-manager", + "tauri-plugin-decorum", + "tauri-plugin-deep-link", + "tauri-plugin-dialog", + "tauri-plugin-http", + "tauri-plugin-notification", + "tauri-plugin-opener", + "tauri-plugin-os", + "tauri-plugin-process", + "tauri-plugin-shell", + "tauri-plugin-single-instance", + "tauri-plugin-store", + "tauri-plugin-updater", + "tauri-plugin-window-state", + "tauri-specta", + "tokio", + "tokio-stream", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uuid", + "webkit2gtk", + "windows-core 0.62.2", + "windows-sys 0.61.2", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -3093,53 +3140,6 @@ dependencies = [ "pathdiff", ] -[[package]] -name = "kilo-desktop" -version = "0.0.0" -dependencies = [ - "chrono", - "comrak", - "dirs", - "futures", - "gtk", - "listeners", - "objc2 0.6.3", - "objc2-web-kit", - "process-wrap", - "reqwest 0.12.24", - "semver", - "serde", - "serde_json", - "specta", - "specta-typescript", - "tauri", - "tauri-build 2.5.2", - "tauri-plugin-clipboard-manager", - "tauri-plugin-decorum", - "tauri-plugin-deep-link", - "tauri-plugin-dialog", - "tauri-plugin-http", - "tauri-plugin-notification", - "tauri-plugin-opener", - "tauri-plugin-os", - "tauri-plugin-process", - "tauri-plugin-shell", - "tauri-plugin-single-instance", - "tauri-plugin-store", - "tauri-plugin-updater", - "tauri-plugin-window-state", - "tauri-specta", - "tokio", - "tokio-stream", - "tracing", - "tracing-appender", - "tracing-subscriber", - "uuid", - "webkit2gtk", - "windows-core 0.62.2", - "windows-sys 0.61.2", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -4163,9 +4163,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "ring", "rustls-pki-types", diff --git a/packages/desktop/src-tauri/release/appstream.metainfo.xml b/packages/desktop/src-tauri/release/appstream.metainfo.xml index ed21a0e5072..c15633a5a44 100644 --- a/packages/desktop/src-tauri/release/appstream.metainfo.xml +++ b/packages/desktop/src-tauri/release/appstream.metainfo.xml @@ -33,6 +33,9 @@ + + https://github.com/anomalyco/opencode/releases/tag/v1.4.0 + https://github.com/Kilo-Org/kilocode/releases/tag/v1.0.223 diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 30f02b3c30d..cbca92a9825 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -32,6 +32,7 @@ "icons/dev/icon.ico" ], "active": true, + "category": "DeveloperTool", "targets": ["deb", "rpm", "dmg", "nsis", "app"], "externalBin": ["sidecars/kilo-cli"], "linux": { diff --git a/packages/desktop/src/entry.tsx b/packages/desktop/src/entry.tsx index b1c9f13f9c4..0e43d85fae9 100644 --- a/packages/desktop/src/entry.tsx +++ b/packages/desktop/src/entry.tsx @@ -1,5 +1,5 @@ if (location.pathname === "/loading") { - import("./loading") + void import("./loading") } else { - import("./") + void import("./") } diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 33af57e1d76..2717cd1e418 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -297,10 +297,15 @@ const createPlatform = (): Platform => { return { updateAvailable: true, version: next.version } }, - update: async () => { + updateAndRestart: async () => { if (!UPDATER_ENABLED || !update) return if (ostype() === "windows") await commands.killSidecar().catch(() => undefined) - await update.install().catch(() => undefined) + const installed = await update + .install() + .then(() => true) + .catch(() => false) + if (!installed) return + await relaunch() }, restart: async () => { @@ -410,7 +415,7 @@ const createPlatform = (): Platform => { } let menuTrigger = null as null | ((id: string) => void) -createMenu((id) => { +void createMenu((id) => { menuTrigger?.(id) }) void listenForDeepLinks() diff --git a/packages/desktop/src/loading.tsx b/packages/desktop/src/loading.tsx index a02f1a95e55..bcea016be49 100644 --- a/packages/desktop/src/loading.tsx +++ b/packages/desktop/src/loading.tsx @@ -48,7 +48,7 @@ render(() => { }) onCleanup(() => { - listener.then((cb) => cb()) + void listener.then((cb) => cb()) timers.forEach(clearTimeout) }) }) diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index cdfe49bc07a..21cfdbb6c47 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -186,5 +186,5 @@ export async function createMenu(trigger: (id: string) => void) { }), ], }) - menu.setAsAppMenu() + void menu.setAsAppMenu() } diff --git a/packages/desktop/src/webview-zoom.ts b/packages/desktop/src/webview-zoom.ts index 06f46a3afd3..46de208b0e0 100644 --- a/packages/desktop/src/webview-zoom.ts +++ b/packages/desktop/src/webview-zoom.ts @@ -17,7 +17,7 @@ const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_Z const applyZoom = (next: number) => { setWebviewZoom(next) - invoke("plugin:webview|set_webview_zoom", { + void invoke("plugin:webview|set_webview_zoom", { value: next, }) } diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 0de0e0eee0b..260340f1e3a 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "1.4.6" +version = "7.2.30" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.6/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.30/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.6/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.30/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.6/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.30/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.6/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.30/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.4.6/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.30/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/AGENTS.md b/packages/kilo-docs/AGENTS.md index 0eed736250a..5bab0319bdc 100644 --- a/packages/kilo-docs/AGENTS.md +++ b/packages/kilo-docs/AGENTS.md @@ -30,15 +30,21 @@ Use the Markdoc image tag format: Note that this site is served under kilo.ai/docs so the `/docs` prefix **must** be present in every image path. +#### Generated screenshots + +When updating screenshots for active docs pages, prefer generated screenshot-test assets from `packages/kilo-docs/public/img/screenshot-tests/` and reference them as `/docs/img/screenshot-tests/...`. Only replace a hand-captured image when the generated screenshot matches the docs content closely. Do not replace screenshots in VSCode Legacy docs tabs or sections. + +If a docs page references a generated VS Code visual-regression screenshot, record that usage in `packages/kilo-vscode/tests/visual-regression.spec.ts` by adding the story ID to the `DOCS` map. Keep `packages/kilo-vscode/tests/visual-regression.spec.mts` in sync while that file exists. If no matching generated screenshot exists, add or update a Storybook story in `packages/kilo-vscode/webview-ui/src/stories/` and let visual-regression CI generate the baseline. + Image attributes: -| Attribute | Type | Required | Description | -| --------- | ------ | -------- | ------------------------------------------- | -| `src` | String | Yes | The image source URL | -| `alt` | String | Yes | Alternative text for the image | -| `width` | String | No | Width of the image (e.g., '500px', '80%') | -| `height` | String | No | Height of the image (e.g., '300px', 'auto') | -| `caption` | String | No | Caption displayed below the image | +| Attribute | Type | Required | Description | +|---|---|---|---| +| `src` | String | Yes | The image source URL | +| `alt` | String | Yes | Alternative text for the image | +| `width` | String | No | Width of the image (e.g., '500px', '80%') | +| `height` | String | No | Height of the image (e.g., '300px', 'auto') | +| `caption` | String | No | Caption displayed below the image | ### Callouts @@ -52,11 +58,11 @@ You can report any bugs or feedback by chatting with us in our [Discord server]( Callout attributes: -| Attribute | Type | Default | Description | -| ----------- | ------- | ------- | ------------------------------------------------- | -| `title` | String | - | Optional custom title for the callout | -| `type` | String | "note" | One of: generic, note, tip, info, warning, danger | -| `collapsed` | Boolean | false | When true, the callout starts collapsed | +| Attribute | Type | Default | Description | +|---|---|---|---| +| `title` | String | - | Optional custom title for the callout | +| `type` | String | "note" | One of: generic, note, tip, info, warning, danger | +| `collapsed` | Boolean | false | When true, the callout starts collapsed | ### Codicons diff --git a/packages/kilo-docs/components/TopNav.tsx b/packages/kilo-docs/components/TopNav.tsx index 1f1dafae5a1..d0a7734a1dd 100644 --- a/packages/kilo-docs/components/TopNav.tsx +++ b/packages/kilo-docs/components/TopNav.tsx @@ -215,6 +215,15 @@ interface TopNavProps { showMobileMenuButton?: boolean } +function preview(url: string) { + if (typeof window === "undefined" || !URL.canParse(url)) return url + + const value = new URL(url) + if (value.hostname !== "kilo.ai" || !value.pathname.startsWith("/docs")) return url + + return `${window.location.origin}${value.pathname}${value.search}${value.hash}` +} + export function TopNav({ onMobileMenuToggle, isMobileMenuOpen = false, showMobileMenuButton = true }: TopNavProps) { const router = useRouter() const [openDropdown, setOpenDropdown] = useState(null) @@ -250,6 +259,12 @@ export function TopNav({ onMobileMenuToggle, isMobileMenuOpen = false, showMobil indexName: process.env.NEXT_PUBLIC_ALGOLIA_INDEX_NAME || "docsearch", apiKey: process.env.NEXT_PUBLIC_ALGOLIA_API_KEY || "24b09689d5b4223813d9b8e48563c8f6", askAi: process.env.NEXT_PUBLIC_ALGOLIA_ASSISTANT_ID || "askAIDemo", + transformItems(items) { + return items.map((item) => ({ + ...item, + url: preview(item.url), + })) + }, }) }, []) diff --git a/packages/kilo-docs/docs/getting-started/devcontainer-persistence.md b/packages/kilo-docs/docs/getting-started/devcontainer-persistence.md index e61c1d31a57..378a881085c 100644 --- a/packages/kilo-docs/docs/getting-started/devcontainer-persistence.md +++ b/packages/kilo-docs/docs/getting-started/devcontainer-persistence.md @@ -40,11 +40,11 @@ The Kilo Code dev container is pre-configured with named volumes to preserve you ## Storage Locations -| Data Type | Container Path | -| ------------ | ------------------------------------------------------------------------- | -| Threads | `/root/.vscode-remote/data/User/globalStorage/kilocode.kilo-code/tasks/` | -| Settings | `/root/.vscode-remote/data/User/settings/` | -| Cache | `/root/.vscode-remote/data/User/globalStorage/kilocode.kilo-code/cache/` | +| Data Type | Container Path | +|---|---| +| Threads | `/root/.vscode-remote/data/User/globalStorage/kilocode.kilo-code/tasks/` | +| Settings | `/root/.vscode-remote/data/User/settings/` | +| Cache | `/root/.vscode-remote/data/User/globalStorage/kilocode.kilo-code/cache/` | | Vector Store | `/root/.vscode-remote/data/User/globalStorage/kilocode.kilo-code/vector/` | ## Troubleshooting diff --git a/packages/kilo-docs/docs/getting-started/switching-from-cline.md b/packages/kilo-docs/docs/getting-started/switching-from-cline.md index 707ab74c527..8f04bb864ca 100644 --- a/packages/kilo-docs/docs/getting-started/switching-from-cline.md +++ b/packages/kilo-docs/docs/getting-started/switching-from-cline.md @@ -259,15 +259,15 @@ In-line ghost-text completions with tab to complete. Works alongside the agent m ## Feature Mapping -| Cline Feature | Kilo Equivalent | Notes | -| ------------------ | ---------------------------------- | ------------------------------------------------------------------------------- | -| Plan mode | Orchestrator, Architect, Ask modes | Architect plans, Ask explains, Orchestrate distributes tasks across other modes | -| Act mode | Code mode | Implementation | -| Plan/Act toggle | Mode dropdown | More granular control | -| Checkpoints | Sessions + Checkpoints | Sessions preserve mode + context | -| Background editing | Fast Apply | Sequential but instant | -| Single agent | Five specialized modes | Purpose-built for each task | -| Local only | Multi-platform | IDE, CLI, web, mobile | +| Cline Feature | Kilo Equivalent | Notes | +|---|---|---| +| Plan mode | Orchestrator, Architect, Ask modes | Architect plans, Ask explains, Orchestrate distributes tasks across other modes | +| Act mode | Code mode | Implementation | +| Plan/Act toggle | Mode dropdown | More granular control | +| Checkpoints | Sessions + Checkpoints | Sessions preserve mode + context | +| Background editing | Fast Apply | Sequential but instant | +| Single agent | Five specialized modes | Purpose-built for each task | +| Local only | Multi-platform | IDE, CLI, web, mobile | --- diff --git a/packages/kilo-docs/lib/nav/ai-providers.ts b/packages/kilo-docs/lib/nav/ai-providers.ts index bb9f109ae75..92ad10f6504 100644 --- a/packages/kilo-docs/lib/nav/ai-providers.ts +++ b/packages/kilo-docs/lib/nav/ai-providers.ts @@ -42,6 +42,8 @@ export const AiProvidersNav: NavSection[] = [ links: [ { href: "/ai-providers/vertex", children: "Google Vertex AI" }, { href: "/ai-providers/bedrock", children: "AWS Bedrock" }, + { href: "/ai-providers/alibaba", children: "Alibaba Cloud" }, + { href: "/ai-providers/cloudflare", children: "Cloudflare" }, { href: "/ai-providers/groq", children: "Groq" }, { href: "/ai-providers/cerebras", children: "Cerebras" }, { href: "/ai-providers/fireworks", children: "Fireworks AI" }, @@ -68,6 +70,7 @@ export const AiProvidersNav: NavSection[] = [ { href: "/ai-providers/moonshot", children: "Moonshot" }, { href: "/ai-providers/ovhcloud", children: "OVHcloud" }, { href: "/ai-providers/sap-ai-core", children: "SAP AI Core" }, + { href: "/ai-providers/venice", children: "Venice AI" }, ], }, { diff --git a/packages/kilo-docs/lib/nav/automate.ts b/packages/kilo-docs/lib/nav/automate.ts index ce3ddf622b8..3bbab572fc9 100644 --- a/packages/kilo-docs/lib/nav/automate.ts +++ b/packages/kilo-docs/lib/nav/automate.ts @@ -33,6 +33,11 @@ export const AutomateNav: NavSection[] = [ href: "/automate/extending/shell-integration", children: "Shell Integration", }, + { + href: "/automate/extending/plugins", + children: "Plugins", + platform: "new", + }, { href: "/automate/extending/auto-launch", children: "Auto-launch Configuration", diff --git a/packages/kilo-docs/lib/nav/code-with-ai.ts b/packages/kilo-docs/lib/nav/code-with-ai.ts index e1d7b8f039d..3668c18ef72 100644 --- a/packages/kilo-docs/lib/nav/code-with-ai.ts +++ b/packages/kilo-docs/lib/nav/code-with-ai.ts @@ -91,6 +91,7 @@ export const CodeWithAiNav: NavSection[] = [ children: "Task Todo List", }, { href: "/code-with-ai/features/checkpoints", children: "Checkpoints" }, + { href: "/code-with-ai/features/file-encoding", children: "File Encoding" }, ], }, ], diff --git a/packages/kilo-docs/lib/nav/tools.ts b/packages/kilo-docs/lib/nav/tools.ts index 8b68d48b174..87fe6b3271e 100644 --- a/packages/kilo-docs/lib/nav/tools.ts +++ b/packages/kilo-docs/lib/nav/tools.ts @@ -12,7 +12,7 @@ export const ToolsNav: NavSection[] = [ { href: "/automate/tools/search-files", children: "search_files" }, { href: "/automate/tools/list-files", children: "list_files" }, { href: "/automate/tools/list-code-definition-names", children: "list_code_definition_names" }, - { href: "/automate/tools/codebase-search", children: "codebase_search" }, + { href: "/automate/tools/semantic-search", children: "semantic_search" }, ], }, { diff --git a/packages/kilo-docs/lychee.toml b/packages/kilo-docs/lychee.toml index f86612c02b0..7e38c2c03cc 100644 --- a/packages/kilo-docs/lychee.toml +++ b/packages/kilo-docs/lychee.toml @@ -9,8 +9,8 @@ retry_wait_time = 5 exclude_path = ["node_modules"] accept = [ - "200", - "403", + "200", + "403", "429", "301", "307", @@ -25,6 +25,10 @@ exclude = [ '^https?://(api|console)\.mistral\.ai', '^https?://(app|console)\.requesty\.ai', '^https?://(cloud|console)\.google\.ai', + # API endpoint prefixes are valid but return 4xx to plain GET link checks. + '^https?://ai-gateway\.vercel\.sh/v1/?$', + '^https?://api\.voyageai\.com/v1/embeddings/?$', + '^https?://generativelanguage\.googleapis\.com/v1beta/openai/?$', 'https://opencode.ai/pages/config', '^localhost:3000/', 'https://tbench.ai/', diff --git a/packages/kilo-docs/mappingplan.md b/packages/kilo-docs/mappingplan.md index f61bd750bfa..d94063a4f67 100644 --- a/packages/kilo-docs/mappingplan.md +++ b/packages/kilo-docs/mappingplan.md @@ -2,176 +2,176 @@ ### Get Started -| New Item | Existing Page(s) | -| ------------------------------ | ----------------------------------------------------------------------- | -| Introduction / Overview | `index`, `getting-started/concepts` | -| Installation | `getting-started/installing` | -| Quickstart | `getting-started/your-first-task` | -| Setup & Authentication | `getting-started/setting-up`, `getting-started/connecting-api-provider` | -| AI Providers | `basic-usage/connecting-providers`, `providers/*` (all of them) | -| Settings | `basic-usage/settings-management` | -| Adding Credits | `basic-usage/adding-credits` | -| FAQ | Keep if it exists | -| Migrating from Cursor/Windsurf | `advanced-usage/migrating-from-cursor-windsurf` | +| New Item | Existing Page(s) | +|---|---| +| Introduction / Overview | `index`, `getting-started/concepts` | +| Installation | `getting-started/installing` | +| Quickstart | `getting-started/your-first-task` | +| Setup & Authentication | `getting-started/setting-up`, `getting-started/connecting-api-provider` | +| AI Providers | `basic-usage/connecting-providers`, `providers/*` (all of them) | +| Settings | `basic-usage/settings-management` | +| Adding Credits | `basic-usage/adding-credits` | +| FAQ | Keep if it exists | +| Migrating from Cursor/Windsurf | `advanced-usage/migrating-from-cursor-windsurf` | --- ### Code with AI -| New Item | Existing Page(s) | -| ----------------------------------- | -------------------------------------------------------------------------- | -| **Platforms** (subheader) | | -| VS Code Extension | Needs new page (or pull from install) | -| JetBrains Extension | Needs new page | -| CLI | `cli` | -| Cloud Agent | `advanced-usage/cloud-agent` (partial) | -| Mobile Apps | Needs new page | -| Slack | `slack` | -| **Working with Agents** (subheader) | | -| The Chat Interface | `basic-usage/the-chat-interface` | -| Context & Mentions | `basic-usage/context-mentions` | -| Using Modes | `basic-usage/using-modes` | -| Orchestrator Mode | `basic-usage/orchestrator-mode` | -| Model Selection | `basic-usage/model-selection-guide` | -| **Features** (subheader) | | -| Autocomplete | `basic-usage/autocomplete/index`, `basic-usage/autocomplete/mistral-setup` | -| Code Actions | `features/code-actions` | -| Browser Use | `features/browser-use` | -| Git Commit Generation | `basic-usage/git-commit-generation` | -| Checkpoints | `features/checkpoints` | -| Enhance Prompt | `features/enhance-prompt` | -| Fast Edits | `features/fast-edits` | -| Task Todo List | `basic-usage/task-todo-list` | -| **Context & Indexing** (subheader) | | -| Codebase Indexing | `features/codebase-indexing` | -| Large Projects | `advanced-usage/large-projects` | -| **Customization** (subheader) | | -| Custom Modes | `agent-behavior/custom-modes` | -| Custom Rules | `agent-behavior/custom-rules` | -| Custom Instructions | `agent-behavior/custom-instructions` | -| agents.md | `agent-behavior/agents-md` | -| Workflows | `agent-behavior/workflows` | -| Skills | `agent-behavior/skills` | -| Prompt Engineering | `agent-behavior/prompt-engineering` | -| **App Builder** (subheader) | | -| App Builder | `advanced-usage/appbuilder` | +| New Item | Existing Page(s) | +|---|---| +| **Platforms** (subheader) | | +| VS Code Extension | Needs new page (or pull from install) | +| JetBrains Extension | Needs new page | +| CLI | `cli` | +| Cloud Agent | `advanced-usage/cloud-agent` (partial) | +| Mobile Apps | Needs new page | +| Slack | `slack` | +| **Working with Agents** (subheader) | | +| The Chat Interface | `basic-usage/the-chat-interface` | +| Context & Mentions | `basic-usage/context-mentions` | +| Using Modes | `basic-usage/using-modes` | +| Orchestrator Mode | `basic-usage/orchestrator-mode` | +| Model Selection | `basic-usage/model-selection-guide` | +| **Features** (subheader) | | +| Autocomplete | `basic-usage/autocomplete/index`, `basic-usage/autocomplete/mistral-setup` | +| Code Actions | `features/code-actions` | +| Browser Use | `features/browser-use` | +| Git Commit Generation | `basic-usage/git-commit-generation` | +| Checkpoints | `features/checkpoints` | +| Enhance Prompt | `features/enhance-prompt` | +| Fast Edits | `features/fast-edits` | +| Task Todo List | `basic-usage/task-todo-list` | +| **Context & Indexing** (subheader) | | +| Codebase Indexing | `features/codebase-indexing` | +| Large Projects | `advanced-usage/large-projects` | +| **Customization** (subheader) | | +| Custom Modes | `agent-behavior/custom-modes` | +| Custom Rules | `agent-behavior/custom-rules` | +| Custom Instructions | `agent-behavior/custom-instructions` | +| agents.md | `agent-behavior/agents-md` | +| Workflows | `agent-behavior/workflows` | +| Skills | `agent-behavior/skills` | +| Prompt Engineering | `agent-behavior/prompt-engineering` | +| **App Builder** (subheader) | | +| App Builder | `advanced-usage/appbuilder` | --- ### Collaborate -| New Item | Existing Page(s) | -| ------------------------------------- | --------------------------------------------------- | -| Sessions & Sharing | `advanced-usage/sessions` | -| **Kilo for Teams** (subheader) | | -| About Plans | `plans/about` | -| Getting Started with Teams | `plans/getting-started` | -| Dashboard | `plans/dashboard` | -| Team Management | `plans/team-management` | -| Custom Modes (Org) | `plans/custom-modes` | -| Billing | `plans/billing` | -| Analytics | `plans/analytics` | -| **AI Adoption Dashboard** (subheader) | | -| Overview | `plans/adoption-dashboard/overview` | -| Understanding Your Score | `plans/adoption-dashboard/understanding-your-score` | -| Improving Your Score | `plans/adoption-dashboard/improving-your-score` | -| For Team Leads | `plans/adoption-dashboard/for-team-leads` | -| **Enterprise** (subheader) | | -| SSO | `plans/enterprise/SSO` | -| Model Access Controls | `plans/enterprise/model-access` | -| Audit Logs | `plans/enterprise/audit-logs` | -| Migration | `plans/migration` | +| New Item | Existing Page(s) | +|---|---| +| Sessions & Sharing | `advanced-usage/sessions` | +| **Kilo for Teams** (subheader) | | +| About Plans | `plans/about` | +| Getting Started with Teams | `plans/getting-started` | +| Dashboard | `plans/dashboard` | +| Team Management | `plans/team-management` | +| Custom Modes (Org) | `plans/custom-modes` | +| Billing | `plans/billing` | +| Analytics | `plans/analytics` | +| **AI Adoption Dashboard** (subheader) | | +| Overview | `plans/adoption-dashboard/overview` | +| Understanding Your Score | `plans/adoption-dashboard/understanding-your-score` | +| Improving Your Score | `plans/adoption-dashboard/improving-your-score` | +| For Team Leads | `plans/adoption-dashboard/for-team-leads` | +| **Enterprise** (subheader) | | +| SSO | `plans/enterprise/SSO` | +| Model Access Controls | `plans/enterprise/model-access` | +| Audit Logs | `plans/enterprise/audit-logs` | +| Migration | `plans/migration` | --- ### Automate -| New Item | Existing Page(s) | -| ------------------------------ | ------------------------------------- | -| Integrations Overview | `advanced-usage/integrations` | -| Code Reviews | `advanced-usage/code-reviews` | -| Agent Manager | `advanced-usage/agent-manager` | -| **Extending Kilo** (subheader) | | -| Local Models | `advanced-usage/local-models` | -| Shell Integration | `features/shell-integration` | -| Auto-launch Configuration | `features/auto-launch-configuration` | -| **MCP** (subheader) | | -| MCP Overview | `features/mcp/overview` | -| Using MCP in Kilo Code | `features/mcp/using-mcp-in-kilo-code` | -| Using MCP in CLI | `features/mcp/using-mcp-in-cli` | -| What is MCP | `features/mcp/what-is-mcp` | -| Server Transports | `features/mcp/server-transports` | -| MCP vs API | `features/mcp/mcp-vs-api` | -| **Tools (subheader)** | | -| How Tools Work | `basic-usage/how-tools-work` | -| Tool Details | ALL of Tool reference | +| New Item | Existing Page(s) | +|---|---| +| Integrations Overview | `advanced-usage/integrations` | +| Code Reviews | `advanced-usage/code-reviews` | +| Agent Manager | `advanced-usage/agent-manager` | +| **Extending Kilo** (subheader) | | +| Local Models | `advanced-usage/local-models` | +| Shell Integration | `features/shell-integration` | +| Auto-launch Configuration | `features/auto-launch-configuration` | +| **MCP** (subheader) | | +| MCP Overview | `features/mcp/overview` | +| Using MCP in Kilo Code | `features/mcp/using-mcp-in-kilo-code` | +| Using MCP in CLI | `features/mcp/using-mcp-in-cli` | +| What is MCP | `features/mcp/what-is-mcp` | +| Server Transports | `features/mcp/server-transports` | +| MCP vs API | `features/mcp/mcp-vs-api` | +| **Tools (subheader)** | | +| How Tools Work | `basic-usage/how-tools-work` | +| Tool Details | ALL of Tool reference | --- ### Deploy & Secure -| New Item | Existing Page(s) | -| ---------------- | ----------------------------------------------------------------------- | -| Deploy | `advanced-usage/deploy` | -| Managed Indexing | `advanced-usage/managed-indexing` | +| New Item | Existing Page(s) | +|---|---| +| Deploy | `advanced-usage/deploy` | +| Managed Indexing | `advanced-usage/managed-indexing` | | Security Reviews | `contributing/architecture/security-reviews` (move out of contributing) | --- ### Contributing -| New Item | Existing Page(s) | -| ---------------------------- | -------------------------------------------------------------- | -| Contributing Overview | `contributing/index` | -| Development Environment | `contributing/development-environment` | -| **Architecture** (subheader) | | -| Architecture Overview | `contributing/architecture/index` | -| Annual Billing | `contributing/architecture/annual-billing` | -| Enterprise MCP Controls | `contributing/architecture/enterprise-mcp-controls` | -| Onboarding Improvements | `contributing/architecture/onboarding-engagement-improvements` | -| Organization Modes Library | `contributing/architecture/organization-modes-library` | -| Track Repo URL | `contributing/architecture/track-repo-url` | -| Vercel AI Gateway | `contributing/architecture/vercel-ai-gateway` | -| Voice Transcription | `contributing/architecture/voice-transcription` | +| New Item | Existing Page(s) | +|---|---| +| Contributing Overview | `contributing/index` | +| Development Environment | `contributing/development-environment` | +| **Architecture** (subheader) | | +| Architecture Overview | `contributing/architecture/index` | +| Annual Billing | `contributing/architecture/annual-billing` | +| Enterprise MCP Controls | `contributing/architecture/enterprise-mcp-controls` | +| Onboarding Improvements | `contributing/architecture/onboarding-engagement-improvements` | +| Organization Modes Library | `contributing/architecture/organization-modes-library` | +| Track Repo URL | `contributing/architecture/track-repo-url` | +| Vercel AI Gateway | `contributing/architecture/vercel-ai-gateway` | +| Voice Transcription | `contributing/architecture/voice-transcription` | --- ## Pages to Add (Don't Exist Yet) -| Section | New Page Needed | -| --------------- | --------------------------------------- | -| Get Started | "Choosing Your Surface" (decision tree) | -| Code with AI | VS Code dedicated page | -| Code with AI | JetBrains dedicated page | -| Code with AI | Web App dedicated page | -| Code with AI | Mobile Apps (iOS/Android) | -| Collaborate | Team Workspaces overview | -| Collaborate | Permissions & Roles | -| Automate | Triage Agent | -| Automate | Auto-fix Agent | -| Automate | GitHub Actions guide | -| Automate | Webhooks & Triggers | -| Deploy & Secure | Security Scanning Agent | -| Deploy & Secure | Environment Configuration | -| Deploy & Secure | Secrets Management | -| Contributing | Roadmap | -| Contributing | Community / Discord | +| Section | New Page Needed | +|---|---| +| Get Started | "Choosing Your Surface" (decision tree) | +| Code with AI | VS Code dedicated page | +| Code with AI | JetBrains dedicated page | +| Code with AI | Web App dedicated page | +| Code with AI | Mobile Apps (iOS/Android) | +| Collaborate | Team Workspaces overview | +| Collaborate | Permissions & Roles | +| Automate | Triage Agent | +| Automate | Auto-fix Agent | +| Automate | GitHub Actions guide | +| Automate | Webhooks & Triggers | +| Deploy & Secure | Security Scanning Agent | +| Deploy & Secure | Environment Configuration | +| Deploy & Secure | Secrets Management | +| Contributing | Roadmap | +| Contributing | Community / Discord | --- ## Pages to Remove from Nav / Condense -| Page | Recommendation | -| ------------------------------------- | -------------------------------------------------------- | -| ☑️ `features/system-notifications` | Fold into Settings or remove | -| ❎ `features/more-features` | Remove | -| ☑️ `features/suggested-responses` | Fold into Chat Interface | -| ☑️ `features/auto-approving-actions` | Fold into Settings | -| ☑️ `advanced-usage/auto-cleanup` | Fold into Settings | -| ❎ `features/model-temperature` | Remove | -| ☑️ `advanced-usage/rate-limits-costs` | Fold into Adding Credits or AI Providers | -| ❎ `features/footgun-prompting` | Remove | -| ☑️ `tips-and-tricks` | Could become a blog post or fold relevant bits elsewhere | +| Page | Recommendation | +|---|---| +| ☑️ `features/system-notifications` | Fold into Settings or remove | +| ❎ `features/more-features` | Remove | +| ☑️ `features/suggested-responses` | Fold into Chat Interface | +| ☑️ `features/auto-approving-actions` | Fold into Settings | +| ☑️ `advanced-usage/auto-cleanup` | Fold into Settings | +| ❎ `features/model-temperature` | Remove | +| ☑️ `advanced-usage/rate-limits-costs` | Fold into Adding Credits or AI Providers | +| ❎ `features/footgun-prompting` | Remove | +| ☑️ `tips-and-tricks` | Could become a blog post or fold relevant bits elsewhere | --- diff --git a/packages/kilo-docs/markdoc/partials/cli-commands-table.md b/packages/kilo-docs/markdoc/partials/cli-commands-table.md index d8b6b89e33e..6243af9f0a8 100644 --- a/packages/kilo-docs/markdoc/partials/cli-commands-table.md +++ b/packages/kilo-docs/markdoc/partials/cli-commands-table.md @@ -1,27 +1,27 @@ -| Command | Description | -| ------------------------- | ---------------------------------------------------- | -| `kilo acp` | start ACP (Agent Client Protocol) server | -| `kilo mcp` | manage MCP (Model Context Protocol) servers | -| `kilo [project]` | start kilo tui | -| `kilo attach ` | attach to a running kilo server | -| `kilo run [message..]` | run kilo with a message | -| `kilo debug` | debugging and troubleshooting tools | -| `kilo auth` | manage AI providers and credentials | -| `kilo agent` | manage agents | -| `kilo upgrade [target]` | upgrade kilo to the latest or a specific version | -| `kilo uninstall` | uninstall kilo and remove all related files | -| `kilo serve` | starts a headless kilo server | -| `kilo models [provider]` | list all available models | -| `kilo stats` | show token usage and cost statistics | -| `kilo export [sessionID]` | export session data as JSON | -| `kilo import ` | import session data from JSON file or URL | -| `kilo pr ` | fetch and checkout a GitHub PR branch, then run kilo | -| `kilo session` | manage sessions | -| `kilo remote` | enable remote connection for real-time session relay | -| `kilo db` | database tools | -| `kilo config` | configuration tools | -| `kilo plugin ` | install plugin and update config | -| `kilo help [command]` | show full CLI reference | -| `kilo completion` | generate shell completion script | +| Command | Description | +|---|---| +| `kilo acp` | start ACP (Agent Client Protocol) server | +| `kilo mcp` | manage MCP (Model Context Protocol) servers | +| `kilo [project]` | start kilo tui | +| `kilo attach ` | attach to a running kilo server | +| `kilo run [message..]` | run kilo with a message | +| `kilo debug` | debugging and troubleshooting tools | +| `kilo auth` | manage AI providers and credentials | +| `kilo agent` | manage agents | +| `kilo upgrade [target]` | upgrade kilo to the latest or a specific version | +| `kilo uninstall` | uninstall kilo and remove all related files | +| `kilo serve` | starts a headless kilo server | +| `kilo models [provider]` | list all available models | +| `kilo stats` | show token usage and cost statistics | +| `kilo export [sessionID]` | export session data as JSON | +| `kilo import ` | import session data from JSON file or URL | +| `kilo pr ` | fetch and checkout a GitHub PR branch, then run kilo | +| `kilo session` | manage sessions | +| `kilo remote` | enable remote connection for real-time session relay | +| `kilo db` | database tools | +| `kilo config` | configuration tools | +| `kilo plugin ` | install plugin and update config | +| `kilo help [command]` | show full CLI reference | +| `kilo completion` | generate shell completion script | diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 9d3bdb2e478..10097f5e6a2 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.2.14", + "version": "7.2.30", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-docs/pages/ai-providers/alibaba.md b/packages/kilo-docs/pages/ai-providers/alibaba.md new file mode 100644 index 00000000000..63ac7038406 --- /dev/null +++ b/packages/kilo-docs/pages/ai-providers/alibaba.md @@ -0,0 +1,79 @@ +--- +sidebar_label: Alibaba Cloud +--- + +# Using Alibaba Cloud With Kilo Code + +Kilo Code supports Alibaba Cloud Model Studio (DashScope) through the native Alibaba AI SDK provider. Use it to run Qwen and other DashScope-hosted models directly from Kilo. + +**Website:** [https://www.alibabacloud.com/product/modelstudio](https://www.alibabacloud.com/product/modelstudio) + +## Getting an API Key + +1. Sign in to Alibaba Cloud Model Studio or DashScope. +2. Open the API key section for your workspace. +3. Create a key and copy it immediately. +4. Store the key securely; do not commit it to your repository. + +## Configuration in Kilo Code + +{% tabs %} +{% tab label="VSCode (Legacy)" %} + +Use the **OpenAI Compatible** provider if the legacy provider list does not include Alibaba Cloud. Set the base URL and API key from your DashScope account, then choose or enter a supported model ID. + +{% /tab %} +{% tab label="VSCode" %} + +Open **Settings** (gear icon) and go to the **Providers** tab to add Alibaba Cloud. Enter your DashScope API key and choose a supported model from the model picker. + +The extension stores this in your `kilo.json` config file. You can also edit the config file directly — see the **CLI** tab for the file format. + +{% /tab %} +{% tab label="CLI" %} + +Set the DashScope API key as an environment variable or configure it in your `kilo.json` config file: + +**Environment variable:** + +```bash +export DASHSCOPE_API_KEY="your-api-key" +``` + +**Config file** (`~/.config/kilo/kilo.json` or `./kilo.json`): + +```jsonc +{ + "provider": { + "alibaba": { + "env": ["DASHSCOPE_API_KEY"] + } + } +} +``` + +Then set your default model: + +```jsonc +{ + "model": "alibaba/qwen-plus" +} +``` + +Some regions and model catalogs may expose Alibaba models under `alibaba-cn` instead. In that case, use the same API key and set the model as `alibaba-cn/`. + +{% /tab %} +{% /tabs %} + +## Tips and Notes + +- **Native provider:** Kilo uses the native Alibaba AI SDK provider, not a generic compatibility shim, when you select the built-in Alibaba provider. +- **Cache controls:** Kilo forwards provider cache controls for compatible Alibaba/DashScope models, which can reduce repeated context cost where the model supports caching. +- **Regional catalogs:** Model availability varies by account, region, and gateway. Check the DashScope console for the exact model IDs available to your account. +- **Pricing and limits:** Refer to Alibaba Cloud Model Studio pricing and rate-limit documentation for current usage details. + +## Troubleshooting + +- **Authentication errors:** Verify `DASHSCOPE_API_KEY` is set in the same shell that launches Kilo, or stored in your Kilo provider config. +- **Model not found:** Confirm the model ID is available in your DashScope account and try the model picker to see Kilo's current catalog. +- **Region mismatch:** If your account uses the China-region catalog, try the `alibaba-cn/` provider prefix. diff --git a/packages/kilo-docs/pages/ai-providers/cloudflare.md b/packages/kilo-docs/pages/ai-providers/cloudflare.md new file mode 100644 index 00000000000..c7bb94a236e --- /dev/null +++ b/packages/kilo-docs/pages/ai-providers/cloudflare.md @@ -0,0 +1,128 @@ +--- +sidebar_label: Cloudflare +--- + +# Using Cloudflare With Kilo Code + +Kilo Code supports both Cloudflare Workers AI and Cloudflare AI Gateway. Workers AI runs Cloudflare-hosted models directly, while AI Gateway routes requests through your Cloudflare gateway to one or more upstream providers. + +**Website:** [https://developers.cloudflare.com/ai/](https://developers.cloudflare.com/ai/) + +## Provider Options + +| Provider ID | Use it for | Required values | +|---|---|---| +| `cloudflare-workers-ai` | Cloudflare-hosted Workers AI models | Account ID and API key | +| `cloudflare-ai-gateway` | Routing through Cloudflare AI Gateway | Account ID, Gateway ID, and Gateway API token | + +You can enter these values interactively with `/connect` in the TUI or `kilo auth` from the CLI, or provide them through environment variables. + +## Cloudflare Workers AI + +### Get Credentials + +1. Open the Cloudflare dashboard and select your account. +2. Copy the **Account ID** from the dashboard. +3. Create an API token that can invoke Workers AI. + +### Configure Kilo + +{% tabs %} +{% tab label="VSCode" %} + +Open **Settings** (gear icon) and go to the **Providers** tab to add Cloudflare Workers AI. If your account ID is not already available from the environment, Kilo prompts for it while connecting the provider. + +{% /tab %} +{% tab label="CLI" %} + +Use `/connect` in the TUI and choose Cloudflare Workers AI, or run: + +```bash +kilo auth cloudflare-workers-ai +``` + +Alternatively, set environment variables before launching Kilo: + +```bash +export CLOUDFLARE_ACCOUNT_ID="your-account-id" +export CLOUDFLARE_API_KEY="your-workers-ai-api-key" +``` + +Then choose a Workers AI model from the model picker, or set a default model such as: + +```jsonc +{ + "model": "cloudflare-workers-ai/@cf/meta/llama-3.1-8b-instruct" +} +``` + +{% /tab %} +{% /tabs %} + +## Cloudflare AI Gateway + +### Get Credentials + +1. Create or open a Cloudflare AI Gateway. +2. Copy your **Account ID** and **Gateway ID**. +3. Create a Gateway API token. + +### Configure Kilo + +{% tabs %} +{% tab label="VSCode" %} + +Open **Settings** (gear icon) and go to the **Providers** tab to add Cloudflare AI Gateway. Kilo prompts for the account ID and gateway ID when they are not already set in the environment. + +{% /tab %} +{% tab label="CLI" %} + +Use `/connect` in the TUI and choose Cloudflare AI Gateway, or run: + +```bash +kilo auth cloudflare-ai-gateway +``` + +Alternatively, set environment variables before launching Kilo: + +```bash +export CLOUDFLARE_ACCOUNT_ID="your-account-id" +export CLOUDFLARE_GATEWAY_ID="your-gateway-id" +export CLOUDFLARE_API_TOKEN="your-gateway-api-token" +``` + +`CF_AIG_TOKEN` is also accepted as an alternative to `CLOUDFLARE_API_TOKEN`. + +Then choose a gateway-backed model from the model picker, or set a default model such as: + +```jsonc +{ + "model": "cloudflare-ai-gateway/openai/gpt-5.1" +} +``` + +{% /tab %} +{% /tabs %} + +## Advanced Configuration + +If your organization provides a fully configured gateway URL, set `provider..options.baseURL`. When `baseURL` is configured, Kilo skips the built-in Account ID and Gateway ID checks because the URL already identifies the target service. + +```jsonc +{ + "provider": { + "cloudflare-ai-gateway": { + "options": { + "baseURL": "https://gateway.example.com/v1" + } + } + } +} +``` + +## Troubleshooting + +- **`CLOUDFLARE_ACCOUNT_ID is missing`** — set `CLOUDFLARE_ACCOUNT_ID` or reconnect the provider with `/connect` so Kilo can store the account ID in auth metadata. +- **`CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_GATEWAY_ID missing`** — set both values or run `kilo auth cloudflare-ai-gateway`. +- **`CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required`** — set one of those variables or reconnect Cloudflare AI Gateway with `kilo auth cloudflare-ai-gateway`. +- **Model errors through AI Gateway** — confirm the gateway route supports the selected upstream provider/model and that your Gateway API token has access. diff --git a/packages/kilo-docs/pages/ai-providers/groq.md b/packages/kilo-docs/pages/ai-providers/groq.md index 58fcf4f1657..6e0ab33b4fc 100644 --- a/packages/kilo-docs/pages/ai-providers/groq.md +++ b/packages/kilo-docs/pages/ai-providers/groq.md @@ -73,13 +73,13 @@ Then set your default model: Kilo Code supports the following models through Groq: -| Model ID | Provider | Context Window | Notes | -| ----------------------------- | ----------- | -------------- | ------------------------------------- | -| `moonshotai/kimi-k2-instruct` | Moonshot AI | 128K tokens | Optimized max_tokens limit configured | -| `llama-3.3-70b-versatile` | Meta | 128K tokens | High-performance Llama model | -| `llama-3.1-70b-versatile` | Meta | 128K tokens | Versatile reasoning capabilities | -| `llama-3.1-8b-instant` | Meta | 128K tokens | Fast inference for quick tasks | -| `mixtral-8x7b-32768` | Mistral AI | 32K tokens | Mixture of experts architecture | +| Model ID | Provider | Context Window | Notes | +|---|---|---|---| +| `moonshotai/kimi-k2-instruct` | Moonshot AI | 128K tokens | Optimized max_tokens limit configured | +| `llama-3.3-70b-versatile` | Meta | 128K tokens | High-performance Llama model | +| `llama-3.1-70b-versatile` | Meta | 128K tokens | Versatile reasoning capabilities | +| `llama-3.1-8b-instant` | Meta | 128K tokens | Fast inference for quick tasks | +| `mixtral-8x7b-32768` | Mistral AI | 32K tokens | Mixture of experts architecture | **Note:** Model availability may change. Refer to the [Groq documentation](https://console.groq.com/docs/models) for the latest model list and specifications. diff --git a/packages/kilo-docs/pages/ai-providers/index.md b/packages/kilo-docs/pages/ai-providers/index.md index f1598b785e5..a7c8c05fee3 100644 --- a/packages/kilo-docs/pages/ai-providers/index.md +++ b/packages/kilo-docs/pages/ai-providers/index.md @@ -22,6 +22,10 @@ Major AI companies offering powerful models via API: - **[Anthropic](/docs/ai-providers/anthropic)** - Claude models (Claude 4, Claude 3.5 Sonnet, etc.) - **[OpenAI](/docs/ai-providers/openai)** - GPT-4, GPT-4o, o1, and more - **[Google Gemini](/docs/ai-providers/gemini)** - Gemini Pro, Gemini Ultra +- **[Google Vertex AI](/docs/ai-providers/vertex)** - Google Cloud-hosted Gemini and partner models +- **[AWS Bedrock](/docs/ai-providers/bedrock)** - AWS-hosted foundation models +- **[Alibaba Cloud](/docs/ai-providers/alibaba)** - DashScope and Qwen models through Model Studio +- **[Cloudflare](/docs/ai-providers/cloudflare)** - Workers AI and Cloudflare AI Gateway - **[DeepSeek](/docs/ai-providers/deepseek)** - DeepSeek V3., R1 - **[Mistral](/docs/ai-providers/mistral)** - Mistral Large, Codestral @@ -40,15 +44,16 @@ Route requests through unified APIs with additional features: - **[OpenRouter](/docs/ai-providers/openrouter)** - Access multiple providers through one API - **[Glama](/docs/ai-providers/glama)** - Enterprise AI gateway - **[Requesty](/docs/ai-providers/requesty)** - Smart routing and fallbacks +- **[Cloudflare AI Gateway](/docs/ai-providers/cloudflare)** - Route providers through your Cloudflare account ## Choosing a Provider -| Priority | Recommended Provider | -| --------------- | --------------------------------------------------- | -| Ease of use | [Kilo Code (built-in)](/docs/ai-providers/kilocode) | -| Best value | Zhipu AI or Mistral | -| Privacy/Offline | Ollama or LM Studio | -| Enterprise | AWS Bedrock or Google Vertex | +| Priority | Recommended Provider | +|---|---| +| Ease of use | [Kilo Code (built-in)](/docs/ai-providers/kilocode) | +| Best value | Zhipu AI or Mistral | +| Privacy/Offline | Ollama or LM Studio | +| Enterprise | AWS Bedrock or Google Vertex | ## Why Use Multiple Providers? diff --git a/packages/kilo-docs/pages/ai-providers/mistral.md b/packages/kilo-docs/pages/ai-providers/mistral.md index e1ce39948b2..3feb13c6e9f 100644 --- a/packages/kilo-docs/pages/ai-providers/mistral.md +++ b/packages/kilo-docs/pages/ai-providers/mistral.md @@ -66,6 +66,12 @@ Then set your default model: {% /tab %} {% /tabs %} +## Reasoning Variants + +Mistral's adjustable reasoning support is exposed only for reasoning-capable Mistral Small 4 models: `mistral-small-2603` and `mistral-small-latest`. When one of these models is selected, Kilo offers a `high` variant that sends `reasoningEffort: "high"` to the Mistral provider. + +Other Mistral models do not get automatic reasoning variants, even if they appear in the same provider. See Mistral's [adjustable reasoning documentation](https://docs.mistral.ai/capabilities/reasoning/adjustable) for provider-level details. + ## Using Codestral [Codestral](https://docs.mistral.ai/capabilities/code_generation/) is a model specifically designed for code generation and interaction. diff --git a/packages/kilo-docs/pages/ai-providers/moonshot.md b/packages/kilo-docs/pages/ai-providers/moonshot.md index 7b92cf10287..463dc1bb59b 100644 --- a/packages/kilo-docs/pages/ai-providers/moonshot.md +++ b/packages/kilo-docs/pages/ai-providers/moonshot.md @@ -70,5 +70,6 @@ Then set your default model: - **Ultra-Long Context:** Kimi models excel at handling large codebases and complex projects with their extended context windows. - **Reasoning Capabilities:** The K2-Thinking variant provides enhanced problem-solving through extended reasoning chains. +- **Kimi-specific prompting:** Kilo automatically uses a Kimi-tuned system prompt for model IDs containing `kimi`; no extra configuration is required. - **Language Support:** Kimi models have strong support for both English and Chinese languages. - **Pricing:** Refer to the Moonshot.ai platform for current pricing information on different models. diff --git a/packages/kilo-docs/pages/ai-providers/openai-compatible.md b/packages/kilo-docs/pages/ai-providers/openai-compatible.md index 07498e8d57b..d8999c3d3b6 100644 --- a/packages/kilo-docs/pages/ai-providers/openai-compatible.md +++ b/packages/kilo-docs/pages/ai-providers/openai-compatible.md @@ -53,13 +53,25 @@ You'll find these settings in the Kilo Code settings panel (click the {% codicon - **Display name** — A human-readable name shown in the UI. - **Base URL** — The provider's OpenAI-compatible API endpoint (e.g., `https://api.your-provider.com/v1`). Kilo auto-fetches available models when a valid URL is entered. - **API key** — Your API key. Optional — leave empty if authentication is handled via headers. -- **Models** — Add models manually or select from the auto-fetched list. +- **Models** — Add models manually or select from the auto-fetched list (see [Automatic Model Detection](#automatic-model-detection) below). - **Headers** (optional) — Custom HTTP headers as key-value pairs. 4. Click **Submit** to save. The provider's models appear in the model picker. For additional model configuration (token limits, tool calling, variants), edit the `kilo.jsonc` config file directly — see the **CLI** tab or the [Custom Models](/docs/code-with-ai/agents/custom-models) guide. +### Automatic Model Detection + +When configuring a custom OpenAI-compatible provider, Kilo Code can automatically detect available models from your provider's `/v1/models` endpoint. + +Once you enter a valid **Base URL** and **API Key**, Kilo Code will query the provider and present a searchable model picker with all available models. You can: + +- **Search** with fuzzy matching (e.g., typing "gpt4o" finds "gpt-4o-mini") +- **Select** individual models to add to the provider configuration +- **Edit** an existing custom provider to add or remove models later + +This eliminates the need to manually look up and type model IDs. If auto-detection fails (for example, if the provider doesn't support the `/v1/models` endpoint), you can still enter model IDs manually. + {% /tab %} {% tab label="CLI" %} diff --git a/packages/kilo-docs/pages/ai-providers/venice.md b/packages/kilo-docs/pages/ai-providers/venice.md new file mode 100644 index 00000000000..2c45bb6032b --- /dev/null +++ b/packages/kilo-docs/pages/ai-providers/venice.md @@ -0,0 +1,76 @@ +--- +sidebar_label: Venice AI +--- + +# Using Venice AI With Kilo Code + +Kilo Code supports Venice AI through the native Venice provider. Venice offers privacy-focused access to open and reasoning-capable models through its API. + +**Website:** [https://venice.ai/](https://venice.ai/) + +## Getting an API Key + +1. Sign in to Venice AI. +2. Open your API settings. +3. Create a key for Kilo Code. +4. Copy the key immediately and store it securely. + +## Configuration in Kilo Code + +{% tabs %} +{% tab label="VSCode (Legacy)" %} + +Use the **OpenAI Compatible** provider if the legacy provider list does not include Venice AI. Enter your Venice API base URL, API key, and model ID from the Venice dashboard. + +{% /tab %} +{% tab label="VSCode" %} + +Open **Settings** (gear icon) and go to the **Providers** tab to add Venice AI and enter your API key. + +The extension stores this in your `kilo.json` config file. You can also edit the config file directly — see the **CLI** tab for the file format. + +{% /tab %} +{% tab label="CLI" %} + +Set the API key as an environment variable or configure it in your `kilo.json` config file: + +**Environment variable:** + +```bash +export VENICE_API_KEY="your-api-key" +``` + +**Config file** (`~/.config/kilo/kilo.json` or `./kilo.json`): + +```jsonc +{ + "provider": { + "venice": { + "env": ["VENICE_API_KEY"] + } + } +} +``` + +Then select a Venice model from the model picker, or set a default model after confirming the model ID in your account: + +```jsonc +{ + "model": "venice/" +} +``` + +{% /tab %} +{% /tabs %} + +## Tips and Notes + +- **Native provider:** Kilo uses the Venice AI SDK provider for built-in Venice models. +- **Reasoning models:** Some Venice models expose reasoning controls. Kilo maps supported reasoning effort and disable-thinking options when the selected model supports them. +- **Model availability:** Venice model IDs and access can vary by plan. Use the model picker or Venice dashboard for the current list. +- **Pricing:** Refer to Venice AI pricing and account limits for current token costs and rate limits. + +## Troubleshooting + +- **Invalid API key:** Verify `VENICE_API_KEY` is set in the same environment that launches Kilo, or reconnect the provider in Settings. +- **Model not available:** Confirm your Venice account has access to the selected model and try selecting it from the model picker. diff --git a/packages/kilo-docs/pages/ai-providers/vertex.md b/packages/kilo-docs/pages/ai-providers/vertex.md index abbb56da3b8..26fef6fbcf0 100644 --- a/packages/kilo-docs/pages/ai-providers/vertex.md +++ b/packages/kilo-docs/pages/ai-providers/vertex.md @@ -81,4 +81,5 @@ Then set your default model: ## Tips and Notes - **Permissions:** Ensure your Google Cloud account has the necessary permissions to access Vertex AI and the specific models you want to use. +- **Prompt caching:** Claude models served through Vertex AI support Kilo prompt caching. Kilo applies Anthropic cache controls and tracks cache write/read tokens when Vertex reports them. Native Vertex Gemini models use Google's implicit server-side caching; no extra Kilo configuration is required, and Gemini may not report cache write tokens. - **Pricing:** Refer to the [Vertex AI pricing](https://cloud.google.com/vertex-ai/pricing) page for details. diff --git a/packages/kilo-docs/pages/automate/agent-manager-workflows.md b/packages/kilo-docs/pages/automate/agent-manager-workflows.md index e7ba43e0bd1..abf6f34327c 100644 --- a/packages/kilo-docs/pages/automate/agent-manager-workflows.md +++ b/packages/kilo-docs/pages/automate/agent-manager-workflows.md @@ -174,18 +174,18 @@ Merge the most foundational one first. Then, in each remaining worktree, ask the ## Cheatsheet -| Situation | Where | -| ------------------------------------------------------ | ----------------------------- | -| Small, interactive task | Sidebar | -| Long task, want to do something else meanwhile | New worktree (`Cmd+N`) | -| Two or three approaches, pick the winner | Multi-version (`Cmd+Shift+N`) | -| Sidebar task outgrew the sidebar | Continue in Worktree | -| Separate conversation on the same branch | New tab (`Cmd+T`) | -| Long conversation, want a fresh context on same branch | New tab, summarize | -| Run the app to verify | Run script (`Cmd+E`) | -| One-off git or shell commands | Terminal (`Cmd+/`) | -| Team review | Push + `gh pr create` | -| Ship without ceremony | Apply to local | +| Situation | Where | +|---|---| +| Small, interactive task | Sidebar | +| Long task, want to do something else meanwhile | New worktree (`Cmd+N`) | +| Two or three approaches, pick the winner | Multi-version (`Cmd+Shift+N`) | +| Sidebar task outgrew the sidebar | Continue in Worktree | +| Separate conversation on the same branch | New tab (`Cmd+T`) | +| Long conversation, want a fresh context on same branch | New tab, summarize | +| Run the app to verify | Run script (`Cmd+E`) | +| One-off git or shell commands | Terminal (`Cmd+/`) | +| Team review | Push + `gh pr create` | +| Ship without ceremony | Apply to local | ## Related diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index 3cd9b2790cf..92114863421 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -57,15 +57,15 @@ You can also import a PR directly from the advanced new worktree dialog: open th The badge color reflects the most important signal, evaluated in priority order: -| State | Color | Condition | -| ----------------- | ---------------- | ------------------------------------------------------------ | -| Draft | Gray | PR is in draft state | -| Merged | Purple | PR has been merged | -| Closed | Red | PR was closed without merging | -| Checks failing | Red | Any CI check has failed | -| Changes requested | Yellow | A reviewer requested changes | -| Checks pending | Yellow (pulsing) | CI checks are still running | -| Open (default) | Green | PR is open, no failing or pending checks, no blocking review | +| State | Color | Condition | +|---|---|---| +| Draft | Gray | PR is in draft state | +| Merged | Purple | PR has been merged | +| Closed | Red | PR was closed without merging | +| Checks failing | Red | Any CI check has failed | +| Changes requested | Yellow | A reviewer requested changes | +| Checks pending | Yellow (pulsing) | CI checks are still running | +| Open (default) | Green | PR is open, no failing or pending checks, no blocking review | When checks are pending on an open PR, the badge pulses to indicate activity. @@ -187,10 +187,10 @@ The run button lets you start your project (dev server, build, tests, etc.) dire Create a script file in `.kilo/` using the appropriate filename for your platform: -| Platform | Filename (checked in order) | -| ------------- | ---------------------------------------------------------------------- | -| macOS / Linux | `.kilo/run-script`, `.kilo/run-script.sh` | -| Windows | `.kilo/run-script.ps1`, `.kilo/run-script.cmd`, `.kilo/run-script.bat` | +| Platform | Filename (checked in order) | +|---|---| +| macOS / Linux | `.kilo/run-script`, `.kilo/run-script.sh` | +| Windows | `.kilo/run-script.ps1`, `.kilo/run-script.cmd`, `.kilo/run-script.bat` | For example, on macOS / Linux create `.kilo/run-script`: @@ -209,10 +209,10 @@ If no run script exists yet, clicking the run button opens a template file for y Two extra variables are injected into the script's environment: -| Variable | Value | -| --------------- | --------------------------------------------------------------------- | +| Variable | Value | +|---|---| | `WORKTREE_PATH` | Working directory of the selected worktree (or repo root for "local") | -| `REPO_PATH` | Repository root | +| `REPO_PATH` | Repository root | ### Using the run button @@ -226,22 +226,22 @@ Agent Manager state is persisted in `.kilo/agent-manager.json`. Sessions, worktr ## Keyboard Shortcuts (Agent Manager Panel) -| Shortcut (macOS) | Shortcut (Windows/Linux) | Action | -| ------------------------ | ------------------------- | ------------------------------------------------ | -| `Cmd+Shift+M` | `Ctrl+Shift+M` | Open / focus Agent Manager (works from anywhere) | -| `Cmd+N` | `Ctrl+N` | New worktree | -| `Cmd+Shift+N` | `Ctrl+Shift+N` | New worktree (advanced options) | -| `Cmd+Shift+O` | `Ctrl+Shift+O` | Import/open worktree | -| `Cmd+Shift+W` | `Ctrl+Shift+W` | Close current worktree | -| `Cmd+T` | `Ctrl+T` | New tab (session) in worktree | -| `Cmd+W` | `Ctrl+W` | Close current tab | -| `Cmd+Alt+Up` / `Down` | `Ctrl+Alt+Up` / `Down` | Previous / next worktree | -| `Cmd+Alt+Left` / `Right` | `Ctrl+Alt+Left` / `Right` | Previous / next tab in worktree | -| `Cmd+/` | `Ctrl+/` | Focus terminal for current session | -| `Cmd+D` | `Ctrl+D` | Toggle diff panel | -| `Cmd+E` | `Ctrl+E` | Run / stop run script | -| `Cmd+Shift+/` | `Ctrl+Shift+/` | Show keyboard shortcuts | -| `Cmd+1` … `Cmd+9` | `Ctrl+1` … `Ctrl+9` | Jump to worktree/session by index | +| Shortcut (macOS) | Shortcut (Windows/Linux) | Action | +|---|---|---| +| `Cmd+Shift+M` | `Ctrl+Shift+M` | Open / focus Agent Manager (works from anywhere) | +| `Cmd+N` | `Ctrl+N` | New worktree | +| `Cmd+Shift+N` | `Ctrl+Shift+N` | New worktree (advanced options) | +| `Cmd+Shift+O` | `Ctrl+Shift+O` | Import/open worktree | +| `Cmd+Shift+W` | `Ctrl+Shift+W` | Close current worktree | +| `Cmd+T` | `Ctrl+T` | New tab (session) in worktree | +| `Cmd+W` | `Ctrl+W` | Close current tab | +| `Cmd+Alt+Up` / `Down` | `Ctrl+Alt+Up` / `Down` | Previous / next worktree | +| `Cmd+Alt+Left` / `Right` | `Ctrl+Alt+Left` / `Right` | Previous / next tab in worktree | +| `Cmd+/` | `Ctrl+/` | Focus terminal for current session | +| `Cmd+D` | `Ctrl+D` | Toggle diff panel | +| `Cmd+E` | `Ctrl+E` | Run / stop run script | +| `Cmd+Shift+/` | `Ctrl+Shift+/` | Show keyboard shortcuts | +| `Cmd+1` … `Cmd+9` | `Ctrl+1` … `Ctrl+9` | Jump to worktree/session by index | ## Troubleshooting diff --git a/packages/kilo-docs/pages/automate/auto-triage/overview.md b/packages/kilo-docs/pages/automate/auto-triage/overview.md index d72707762f3..ecbb51dc66e 100644 --- a/packages/kilo-docs/pages/automate/auto-triage/overview.md +++ b/packages/kilo-docs/pages/automate/auto-triage/overview.md @@ -23,12 +23,12 @@ When an issue arrives, Auto-Triage compares it against every previously-triaged An AI model of your choice reads the full title and body and assigns one of four classifications: -| Classification | Meaning | -| -------------- | ----------------------------------------------------------------------------------------------------------------------------- | -| **bug** | Existing, documented functionality is broken. Includes issues with stack traces, error messages, or clear reproduction steps. | -| **feature** | A request for new functionality or an enhancement to existing behaviour. | -| **question** | The reporter is asking for help, clarification, or pointing to a gap in documentation. | -| **unclear** | The issue does not contain enough information to determine intent. | +| Classification | Meaning | +|---|---| +| **bug** | Existing, documented functionality is broken. Includes issues with stack traces, error messages, or clear reproduction steps. | +| **feature** | A request for new functionality or an enhancement to existing behaviour. | +| **question** | The reporter is asking for help, clarification, or pointing to a gap in documentation. | +| **unclear** | The issue does not contain enough information to determine intent. | Along with the classification, the model produces a confidence score (0–1), a short summary of what the reporter wants, and its reasoning. @@ -76,17 +76,17 @@ All settings are found under **Auto-Triage -> Config**. ### Repository scope -| Setting | Description | -| ----------------------------- | ------------------------------------------------------------------------------------------------------------- | +| Setting | Description | +|---|---| | **Repository selection mode** | `all` — triage every accessible repository. `selected` — triage only the repositories you pick from the list. | ### Label filters These settings let you control which issues Auto-Triage processes, using labels already on the issue at the time it is opened. -| Setting | Description | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Skip labels** | If an issue carries **any** of these labels when it is opened, Auto-Triage will ignore it entirely. Useful for issues you handle manually, e.g. `wontfix` or `on-hold`. | +| Setting | Description | +|---|---| +| **Skip labels** | If an issue carries **any** of these labels when it is opened, Auto-Triage will ignore it entirely. Useful for issues you handle manually, e.g. `wontfix` or `on-hold`. | | **Required labels** | If set, Auto-Triage will only process issues that carry **all** of these labels. Useful for opt-in triage flows, e.g. requiring a `needs-triage` label before Auto-Triage runs. | > **Note:** Skip labels and required labels are also excluded from the set of labels the AI can apply. This keeps gating labels strictly under your control. @@ -99,13 +99,13 @@ The model used for classification. ## Ticket statuses -| Status | Meaning | -| ------------- | ------------------------------------------------------------------------------------------ | -| **pending** | Queued and waiting for a processing slot. | -| **analyzing** | The AI is actively processing the issue. | -| **actioned** | Triage completed. Labels applied, duplicate comment posted if applicable. | -| **failed** | Something went wrong. The error is shown in the ticket. You can retry. | -| **skipped** | The issue did not meet the configured requirements (wrong repo, skip label present, etc.). | +| Status | Meaning | +|---|---| +| **pending** | Queued and waiting for a processing slot. | +| **analyzing** | The AI is actively processing the issue. | +| **actioned** | Triage completed. Labels applied, duplicate comment posted if applicable. | +| **failed** | Something went wrong. The error is shown in the ticket. You can retry. | +| **skipped** | The issue did not meet the configured requirements (wrong repo, skip label present, etc.). | --- @@ -113,9 +113,9 @@ The model used for classification. Auto-Triage uses two reserved labels for tracking. You should create these in your GitHub repositories before enabling the feature: -| Label | Meaning | -| ---------------- | ----------------------------------------------------------------------------- | -| `kilo-triaged` | Applied to every issue that completes triage successfully. | +| Label | Meaning | +|---|---| +| `kilo-triaged` | Applied to every issue that completes triage successfully. | | `kilo-duplicate` | Applied alongside `kilo-triaged` when the issue is identified as a duplicate. | These labels are managed by Kilo and should not be added to your **skip labels** list. diff --git a/packages/kilo-docs/pages/automate/code-reviews/github.md b/packages/kilo-docs/pages/automate/code-reviews/github.md index 2547cd02c58..4268264b0d2 100644 --- a/packages/kilo-docs/pages/automate/code-reviews/github.md +++ b/packages/kilo-docs/pages/automate/code-reviews/github.md @@ -21,12 +21,12 @@ Connect your GitHub account via the [Integrations page](/docs/automate/integrati The GitHub App requests the following permissions: -| Permission | Access | Purpose | -| ------------------- | ------------ | -------------------------------- | -| Pull requests | Read & Write | Post review comments | -| Repository contents | Read | Analyze code | -| Issues | Read & Write | Post summary comments, reactions | -| Metadata | Read | List repositories | +| Permission | Access | Purpose | +|---|---|---| +| Pull requests | Read & Write | Post review comments | +| Repository contents | Read | Analyze code | +| Issues | Read & Write | Post summary comments, reactions | +| Metadata | Read | List repositories | ### Step 2: Configure the Review Agent @@ -47,14 +47,14 @@ The GitHub App requests the following permissions: Once configured, the Review Agent automatically runs when: -| PR Event | Triggers Review | -| ------------------------ | --------------- | -| PR opened | ✅ Yes | -| New commits pushed to PR | ✅ Yes | -| PR reopened | ✅ Yes | -| Draft PR marked ready | ✅ Yes | -| Draft PR opened | ❌ Skipped | -| PR closed | ❌ No | +| PR Event | Triggers Review | +|---|---| +| PR opened | ✅ Yes | +| New commits pushed to PR | ✅ Yes | +| PR reopened | ✅ Yes | +| Draft PR marked ready | ✅ Yes | +| Draft PR opened | ❌ Skipped | +| PR closed | ❌ No | ## What to Expect diff --git a/packages/kilo-docs/pages/automate/code-reviews/gitlab.md b/packages/kilo-docs/pages/automate/code-reviews/gitlab.md index 0087a26dd7e..11969ace77c 100644 --- a/packages/kilo-docs/pages/automate/code-reviews/gitlab.md +++ b/packages/kilo-docs/pages/automate/code-reviews/gitlab.md @@ -46,14 +46,14 @@ When you select repositories, Kilo **automatically creates webhooks** on each pr Once configured, the Review Agent automatically runs when: -| MR Event | Triggers Review | -| ------------------------ | --------------- | -| MR opened | ✅ Yes | -| New commits pushed to MR | ✅ Yes | -| MR reopened | ✅ Yes | -| Draft or WIP MR opened | ❌ Skipped | -| MR closed | ❌ No | -| MR merged | ❌ No | +| MR Event | Triggers Review | +|---|---| +| MR opened | ✅ Yes | +| New commits pushed to MR | ✅ Yes | +| MR reopened | ✅ Yes | +| Draft or WIP MR opened | ❌ Skipped | +| MR closed | ❌ No | +| MR merged | ❌ No | ## What to Expect diff --git a/packages/kilo-docs/pages/automate/code-reviews/overview.md b/packages/kilo-docs/pages/automate/code-reviews/overview.md index 5b04e1137d5..f72ecc48209 100644 --- a/packages/kilo-docs/pages/automate/code-reviews/overview.md +++ b/packages/kilo-docs/pages/automate/code-reviews/overview.md @@ -17,10 +17,10 @@ Kilo's **Code Reviews** feature automatically analyzes your pull or merge reques ## Supported Platforms -| Platform | Integration Type | Details | -| -------- | ---------------- | ------------------------------ | -| GitHub | GitHub App | [GitHub Setup Guide](./github) | -| GitLab | OAuth or PAT | [GitLab Setup Guide](./gitlab) | +| Platform | Integration Type | Details | +|---|---|---| +| GitHub | GitHub App | [GitHub Setup Guide](./github) | +| GitLab | OAuth or PAT | [GitLab Setup Guide](./gitlab) | ## Prerequisites diff --git a/packages/kilo-docs/pages/automate/extending/plugins.md b/packages/kilo-docs/pages/automate/extending/plugins.md new file mode 100644 index 00000000000..f5d07ac2f15 --- /dev/null +++ b/packages/kilo-docs/pages/automate/extending/plugins.md @@ -0,0 +1,609 @@ +--- +title: "Plugins" +description: "Extend the Kilo CLI with custom hooks, tools, auth providers, and more" +platform: new +--- + +# Plugins + +Plugins extend Kilo by hooking into events, adding custom tools, registering auth or model providers, and customizing runtime behavior. They are TypeScript or JavaScript modules loaded at startup, and work in both the Kilo CLI and the VS Code extension. + +## What plugins can do + +- **Add custom tools** the model can call (like `read`, `write`, `bash`). +- **Intercept tool calls** to mutate arguments, rewrite output, or block dangerous operations. +- **Subscribe to events** — sessions, messages, permissions, LSP diagnostics, file changes, etc. +- **Register auth providers** — OAuth or API-key flows for model providers. +- **Register model providers** — dynamic model catalogs. +- **Mutate chat parameters or headers** sent to the LLM. +- **Customize compaction** — inject or replace the prompt used when a session is compacted. +- **Inject shell environment variables** for commands executed by the agent or user. + +--- + +## Use a plugin + +There are three ways to load plugins. + +### From a config file + +Add an array of plugin specifiers to your config file: + +```json +{ + "$schema": "https://app.kilo.ai/config.json", + "plugin": [ + "@your-org/your-plugin", + "your-plugin@1.2.3", + ["your-plugin", { "apiKey": "{env:MY_API_KEY}" }], + "./plugins/local.ts", + "file:///abs/path/plugin.ts" + ] +} +``` + +Each entry can be: + +| Form | Loaded from | +|---|---| +| `"package-name"` | Latest version from npm | +| `"package-name@1.2.3"` | Pinned version from npm | +| `["package-name", { options }]` | npm package with options passed to the plugin function | +| `"./path/plugin.ts"` / `"file:///..."` | Local file (relative to the config file or absolute `file:` URL) | + +Config files live in the same locations as the rest of your CLI configuration — see the [CLI configuration reference](/docs/code-with-ai/platforms/cli#configuration). + +### From a plugin directory + +Drop TypeScript or JavaScript files into a `plugin/` or `plugins/` folder inside any config directory: + +- Global: `~/.config/kilo/plugin/` +- Project: `.kilo/plugin/`, `.kilocode/plugin/`, or `.opencode/plugin/` + +Every `.ts` or `.js` file in those directories is auto-registered at startup — no need to list them in the config file. + +```text +my-project/ +├── kilo.json +└── .kilo/ + └── plugin/ + ├── env-guard.ts + └── notifications.ts +``` + +### From the `kilo plugin` command + +Install an npm plugin and patch your config in one step: + +```bash +# Install into the current project's config +kilo plugin my-plugin + +# Install into your global config +kilo plugin my-plugin --global + +# Replace an existing entry +kilo plugin my-plugin --force +``` + +The command resolves the package, reads its `package.json` for plugin entrypoints, and writes the entry into the appropriate config file (currently `.opencode/opencode.jsonc` / `.opencode/tui.jsonc` for local installs, or `~/.config/kilo/opencode.jsonc` / `~/.config/kilo/tui.jsonc` for `--global`) while preserving JSONC comments. + +### How plugins are installed + +- **npm plugins** are installed automatically at startup using Bun. Packages and their dependencies are cached in Kilo's XDG cache directory (`~/.cache/kilo/` on Linux, `~/Library/Caches/kilo/` on macOS, `%LOCALAPPDATA%\kilo\` on Windows). +- **Pinned npm versions** like `my-plugin@1.2.3` install that exact version and do not check for newer registry versions. Bare package names resolve to `latest` and can refresh when the cached copy becomes stale. +- **Install scripts are disabled** for npm plugins. Kilo installs packages with lifecycle scripts such as `install` and `postinstall` blocked. +- **Local plugins** are loaded directly from the plugin directory. If your plugin imports external packages, add a `package.json` to your config directory (see [Dependencies](#dependencies)) — Kilo runs `bun install` on startup so imports resolve. + +### Load order + +Plugins from all sources run on every session. They load in this order: + +1. Internal built-ins (Kilo Gateway auth, Codex auth, Copilot auth, Cloudflare, etc.) +2. Global config plugin array (`~/.config/kilo/kilo.json`) +3. Global plugin directory (`~/.config/kilo/plugin/`) +4. Project config plugin array (`kilo.json` / `opencode.json`) +5. Project plugin directory (`.kilo/plugin/` and friends) + +Duplicates (same package, same version) are deduplicated. Hooks from multiple plugins run sequentially in load order. + +### Disabling external plugins + +Set the `KILO_PURE=1` environment variable to skip all external plugins — only built-in plugins will load. Useful for reproducible CI runs or debugging. + +--- + +## Create a plugin + +A plugin is a module that exports a function returning a set of [hooks](#hooks-reference). + +### Basic structure + +Create a file in your plugin directory: + +```ts +// .kilo/plugin/hello.ts +import type { Plugin } from "@kilocode/plugin" + +const hello: Plugin = async ({ project, client, $, directory, worktree }) => { + console.log("hello plugin loaded") + + return { + // hook implementations go here + } +} + +export default { id: "hello", server: hello } +``` + +The plugin function receives a context object: + +| Field | Description | +|---|---| +| `project` | Current project metadata. | +| `directory` | Current working directory for this session. | +| `worktree` | Git worktree root for this session. | +| `client` | A Kilo SDK client (`@kilocode/sdk`) for calling the local server. | +| `$` | [Bun's shell API](https://bun.com/docs/runtime/shell). | +| `serverUrl` | URL of the local Kilo server. | +| `experimental_workspace` | Register workspace adaptors (used by Agent Manager). | + +The function returns a `Hooks` object. Any second argument is the options object passed via config (e.g. the `{ apiKey: "..." }` from `["my-plugin", { apiKey: "..." }]`). + +### Register workspace adaptors + +Workspace adaptors let plugins add custom workspace targets to Kilo's workspace creation flow. This API is experimental and may change. + +```ts +import type { Plugin } from "@kilocode/plugin" +import { mkdir, rm } from "node:fs/promises" + +const WorkspacePlugin: Plugin = async ({ experimental_workspace }) => { + experimental_workspace.register("folder", { + name: "Folder", + description: "Create a blank folder", + configure(config) { + return { ...config, directory: `/tmp/kilo-${Date.now()}` } + }, + async create(config) { + await mkdir(config.directory!, { recursive: true }) + }, + async remove(config) { + await rm(config.directory!, { recursive: true, force: true }) + }, + target(config) { + return { type: "local", directory: config.directory! } + }, + }) + + return {} +} + +export default { id: "workspace-folder", server: WorkspacePlugin } +``` + +An adaptor implements `configure(config)`, `create(config, env, from?)`, `remove(config)`, and `target(config)`. `target` returns either `{ type: "local", directory }` for a local workspace or `{ type: "remote", url, headers? }` for a remote workspace endpoint. + +### Module shape + +Plugins must default-export a module descriptor. `id` is required for local-file plugins and inferred from `package.json#name` for npm plugins. + +```ts +import type { Plugin } from "@kilocode/plugin" + +const server: Plugin = async (ctx) => ({ + /* hooks */ +}) + +export default { + id: "my-plugin", + server, +} +``` + +An npm plugin can also expose a TUI entry point (`tui`) for [TUI plugins](#tui-plugins), but `server` and `tui` are separate modules. + +### Package manifest for npm plugins + +Published npm plugins should declare separate package entrypoints for each runtime they support. Kilo detects install targets from `package.json`: + +- `exports["./server"]` marks the package as a server plugin. +- `exports["./tui"]` marks the package as a TUI plugin. +- `main` is a server-only fallback when `exports` is not used. +- `oc-themes` marks a package as a TUI theme package, even when it has no `./tui` export. + +```json +{ + "name": "@acme/kilo-plugin", + "type": "module", + "main": "./dist/server.js", + "exports": { + "./server": { + "import": "./dist/server.js", + "config": { "apiKey": "{env:ACME_API_KEY}" } + }, + "./tui": { + "import": "./dist/tui.js", + "config": { "compact": true } + } + }, + "engines": { + "opencode": "^1.0.0" + } +} +``` + +The optional `config` object on an export becomes the default options tuple written to the user's config on first install. Keep server and TUI code in separate files; each runtime loads only the entrypoint that matches its target. + +Theme-only packages can omit code entrypoints and provide package-relative theme files: + +```json +{ + "name": "@acme/kilo-themes", + "oc-themes": ["themes/acme-dark.json", "themes/acme-light.json"] +} +``` + +`oc-themes` entries must be relative paths inside the package. Absolute paths, `file://` URLs, and paths that escape the package directory are rejected. Installed theme packages sync their themes on first install and when the package changes. + +### TypeScript support + +Install the plugin package locally and import its types: + +```bash +bun add -d @kilocode/plugin +``` + +```ts +import type { Plugin } from "@kilocode/plugin" +import { tool } from "@kilocode/plugin/tool" +``` + +Kilo automatically creates a `package.json` in config directories that contain a `plugin/` folder and installs `@kilocode/plugin` so types resolve out of the box. + +### Engine compatibility + +Declare a CLI version range to prevent a plugin from loading against an incompatible build: + +```json +{ + "name": "my-plugin", + "engines": { "opencode": "^7.0.0" } +} +``` + +If the running CLI does not satisfy the range, the plugin is skipped and a warning is surfaced. + +### Dependencies + +Local plugins and custom tools can use external npm packages. Add a `package.json` to your config directory: + +```json +// .kilo/package.json +{ + "dependencies": { + "shescape": "^2.1.0" + } +} +``` + +Kilo runs `bun install` at startup so your plugins can import the packages: + +```ts +// .kilo/plugin/escape-bash.ts +import { escape } from "shescape" +import type { Plugin } from "@kilocode/plugin" + +const EscapeBash: Plugin = async () => ({ + "tool.execute.before": async (input, output) => { + if (input.tool === "bash") { + output.args.command = escape(output.args.command) + } + }, +}) + +export default { id: "escape-bash", server: EscapeBash } +``` + +--- + +## Hooks reference + +Every hook is optional. Return only the ones you care about. + +### Lifecycle + +| Hook | Description | +|---|---| +| `config` | Receives the fully-resolved config at startup. Read-only — useful for inspection. | +| `event` | Called for **every** event on the internal bus (see [Events](#events)). | + +### Tools + +| Hook | Description | +|---|---| +| `tool` | Map of tool name → [tool definition](#custom-tools). Added tools are callable by the model. | +| `tool.execute.before` | Fires before a tool runs; you can mutate `output.args`. | +| `tool.execute.after` | Fires after a tool returns; you can rewrite `output.title`, `output.output`, `output.metadata`. | +| `tool.definition` | Mutate a tool's `description` and `parameters` before they are sent to the model. | + +### Chat + +| Hook | Description | +|---|---| +| `chat.message` | Fires when a new user message arrives. Inspect or modify `parts`. | +| `chat.params` | Mutate `temperature`, `topP`, `topK`, `maxOutputTokens`, provider `options`. | +| `chat.headers` | Add or replace HTTP headers on the LLM API call. | +| `permission.ask` | Auto-allow or auto-deny permission prompts. | +| `command.execute.before` | Intercept slash command execution; mutate the resulting `parts`. | +| `shell.env` | Inject environment variables into every shell command Kilo runs. | + +### Providers & auth + +| Hook | Description | +|---|---| +| `auth` | Register an auth method (OAuth or API key) for a provider, with interactive prompts. | +| `provider` | Dynamically supply a model catalog for a provider (useful for BYO-model gateways). | + +Provider hooks can replace or refresh the model catalog for a provider. The hook receives the provider definition and auth context, and returns a map of model ID to model metadata: + +```ts +import type { Plugin } from "@kilocode/plugin" + +const ProviderPlugin: Plugin = async () => ({ + provider: { + id: "my-gateway", + async models(provider, { auth }) { + const res = await fetch("https://gateway.example.com/models", { + headers: auth?.type === "api" ? { Authorization: `Bearer ${auth.key}` } : {}, + }) + return await res.json() + }, + }, +}) + +export default { id: "my-provider", server: ProviderPlugin } +``` + +Kilo fills provider/model IDs from the returned catalog and uses the returned models in the picker and provider router. + +### Experimental + +These hooks live behind the `experimental.` prefix and may change between releases. + +| Hook | Description | +|---|---| +| `experimental.chat.messages.transform` | Rewrite the full message history before it is sent to the model. | +| `experimental.chat.system.transform` | Modify the system prompt array. | +| `experimental.session.compacting` | Inject extra context (`output.context`) or replace the compaction prompt entirely (`output.prompt`). | +| `experimental.compaction.autocontinue` | Disable the synthetic "continue" turn that follows compaction. | +| `experimental.text.complete` | Post-process final text parts (e.g. append signatures, redact secrets). | + +### Events + +The `event` hook fires for every event on Kilo's internal bus. Common event types include: + +- **Session**: `session.created`, `session.updated`, `session.idle`, `session.error`, `session.deleted`, `session.compacted`, `session.diff`, `session.status` +- **Message**: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed` +- **Tool**: `tool.execute.before`, `tool.execute.after` +- **Permission**: `permission.asked`, `permission.replied` +- **File**: `file.edited`, `file.watcher.updated` +- **Shell**: `shell.env` +- **Command**: `command.executed` +- **LSP**: `lsp.updated`, `lsp.client.diagnostics` +- **Todo**: `todo.updated` +- **Server**: `server.connected` +- **Installation**: `installation.updated` + +```ts +const server: Plugin = async () => ({ + event: async ({ event }) => { + if (event.type === "session.idle") { + // session finished responding + } + }, +}) +``` + +--- + +## Custom tools + +Plugins can register tools the model can call alongside the built-in ones. Use the `tool()` helper for type-safety: + +```ts +// .kilo/plugin/database.ts +import type { Plugin } from "@kilocode/plugin" +import { tool } from "@kilocode/plugin/tool" + +const DatabasePlugin: Plugin = async () => ({ + tool: { + query: tool({ + description: "Run a read-only SQL query against the project database", + args: { + sql: tool.schema.string().describe("SQL query to execute"), + }, + async execute(args, context) { + const { directory, worktree } = context + // your query logic here + return `ran: ${args.sql}` + }, + }), + }, +}) + +export default { id: "database", server: DatabasePlugin } +``` + +`args` uses a [Zod](https://zod.dev) schema via `tool.schema`. The tool's `execute` function receives: + +- `args` — validated against your schema +- `context` — `{ sessionID, messageID, agent, directory, worktree, abort, metadata, ask }` + +### Name precedence + +If a custom tool uses the same name as a built-in tool, **the custom tool wins**. Prefer unique names unless you intentionally want to override a built-in (for example, to wrap `bash` with extra validation). + +### Alternative: standalone tool files + +For tools that don't need the full plugin context, drop them in a `tool/` or `tools/` folder inside any config directory — for example `.kilo/tool/database.ts` or `~/.config/kilo/tool/database.ts`. The filename becomes the tool name, and each file exports a `tool()` definition directly. The layout is identical to the [OpenCode custom tools guide](https://opencode.ai/docs/custom-tools); substitute `.kilo/` (or `.kilocode/` / `.opencode/`) for `.opencode/`. + +--- + +## Examples + +### Send a notification when a session finishes + +```ts +// .kilo/plugin/notify.ts +import type { Plugin } from "@kilocode/plugin" + +const Notify: Plugin = async ({ $ }) => ({ + event: async ({ event }) => { + if (event.type === "session.idle") { + await $`osascript -e 'display notification "Session complete!" with title "Kilo"'` + } + }, +}) + +export default { id: "notify", server: Notify } +``` + +{% callout type="tip" %} +The VS Code extension already emits system notifications when a session finishes or errors — this plugin is for the raw CLI / TUI. +{% /callout %} + +### Block reads of `.env` files + +```ts +// .kilo/plugin/env-guard.ts +import type { Plugin } from "@kilocode/plugin" + +const EnvGuard: Plugin = async () => ({ + "tool.execute.before": async (input, output) => { + if (input.tool === "read" && String(output.args.filePath).includes(".env")) { + throw new Error("reading .env files is blocked") + } + }, +}) + +export default { id: "env-guard", server: EnvGuard } +``` + +### Inject environment variables into every shell command + +```ts +// .kilo/plugin/inject-env.ts +import type { Plugin } from "@kilocode/plugin" + +const InjectEnv: Plugin = async () => ({ + "shell.env": async (input, output) => { + output.env.MY_API_KEY = "secret" + output.env.PROJECT_ROOT = input.cwd + }, +}) + +export default { id: "inject-env", server: InjectEnv } +``` + +### Structured logging + +Prefer `client.app.log()` over `console.log` so entries land in Kilo's log pipeline: + +```ts +import type { Plugin } from "@kilocode/plugin" + +const Logger: Plugin = async ({ client }) => { + await client.app.log({ + body: { + service: "my-plugin", + level: "info", + message: "plugin initialized", + extra: { version: "1.0.0" }, + }, + }) + return {} +} + +export default { id: "logger", server: Logger } +``` + +Levels: `debug`, `info`, `warn`, `error`. + +### Inject context during session compaction + +```ts +// .kilo/plugin/compaction.ts +import type { Plugin } from "@kilocode/plugin" + +const Compaction: Plugin = async () => ({ + "experimental.session.compacting": async (input, output) => { + output.context.push( + "## Persist across compaction\n- current task status\n- files being actively edited\n- key decisions", + ) + }, +}) + +export default { id: "compaction", server: Compaction } +``` + +Set `output.prompt` to replace the default compaction prompt entirely — when present, `output.context` is ignored. + +### Stop auto-continuing after compaction + +By default, Kilo sends a synthetic "continue" turn after compaction so the agent resumes the interrupted task. Use `experimental.compaction.autocontinue` to disable that turn for specific sessions or providers: + +```ts +const CompactionStop: Plugin = async () => ({ + "experimental.compaction.autocontinue": async (input, output) => { + if (input.overflow) output.enabled = false + }, +}) +``` + +The hook receives the `sessionID`, `agent`, `model`, `provider`, compacted `message`, and whether the compaction was caused by context overflow. `output.enabled` defaults to `true`. + +--- + +## TUI plugins + +Plugins can also target the Kilo TUI itself — registering slash commands, routes, slots, dialogs, and keybinds. TUI plugins are SolidJS modules exported from `"./tui"` in your plugin package, or theme-only packages declared with `oc-themes`. + +TUI plugins live in a separate module namespace (`@kilocode/plugin/tui`) and have their own API surface (`TuiPluginApi`). Because the TUI API is larger and still evolving, this guide doesn't cover it exhaustively — use the types in `@kilocode/plugin/tui` as the reference, and look at the built-in TUI plugins under `packages/opencode/src/cli/cmd/tui/feature-plugins/` for working examples. + +Common TUI APIs include: + +- `api.command.register(...)` to add commands and `api.command.show()` to open the command palette. +- `api.ui.Slot` to render a host slot or a custom plugin slot. +- `api.slots.register(...)` to define reusable custom slots for other plugins. +- `api.ui.Prompt` to render prompt components in prompt replacement slots. + +Host slots include `home_prompt_right`, `session_prompt`, `session_prompt_right`, and `home_footer`. The `session_prompt` slot replaces the default session prompt, while the `*_prompt_right` slots add controls next to the prompt metadata row. + +--- + +## Troubleshooting + +- **Plugin failed to load** — check the CLI logs with `kilo --print-logs --log-level DEBUG`. Load failures are also surfaced as session errors in the TUI and VS Code extension. +- **Plugin loaded but hooks never fire** — make sure the default export includes `server`: + + ```ts + export default { id: "my-plugin", server } + ``` + + Named function exports are also accepted for backwards compatibility but should be considered legacy. + +- **Package installed but not active in one runtime** — make sure the package exposes the matching entrypoint. Server plugins need `exports["./server"]` or `main`; TUI plugins need `exports["./tui"]` or valid `oc-themes`. Packages that only support the other runtime are skipped with a warning instead of causing a fatal load error. + +- **Local plugin can't find an npm import** — add a `package.json` in the config directory so `bun install` picks up the dependency (see [Dependencies](#dependencies)). +- **Plugin loads in dev but not in CI** — verify `KILO_PURE` is not set, and that npm-installed plugins are cached (Kilo's XDG cache directory — `~/.cache/kilo/` on Linux, `~/Library/Caches/kilo/` on macOS, `%LOCALAPPDATA%\kilo\` on Windows). Run with `--log-level DEBUG` to see install output. +- **Reset the plugin cache** — delete the `node_modules/` under Kilo's cache directory (or the `node_modules` cache under your config directory) and restart Kilo. + +--- + +## Reference + +- Types: [`@kilocode/plugin`](https://github.com/Kilo-Org/kilocode/tree/main/packages/plugin) — `Plugin`, `Hooks`, `PluginInput`, `ToolDefinition`, `AuthHook`, `ProviderHook`. +- Example plugin: [`packages/plugin/src/example.ts`](https://github.com/Kilo-Org/kilocode/blob/main/packages/plugin/src/example.ts) +- CLI command: [`kilo plugin`](/docs/code-with-ai/platforms/cli-reference#kilo-plugin) +- Upstream docs (behavior is identical to OpenCode): [opencode.ai/docs/plugins](https://opencode.ai/docs/plugins) and [opencode.ai/docs/custom-tools](https://opencode.ai/docs/custom-tools) diff --git a/packages/kilo-docs/pages/automate/extending/shell-integration.md b/packages/kilo-docs/pages/automate/extending/shell-integration.md index 4f1e3312846..0228a60fb57 100644 --- a/packages/kilo-docs/pages/automate/extending/shell-integration.md +++ b/packages/kilo-docs/pages/automate/extending/shell-integration.md @@ -391,10 +391,10 @@ When using the Kilo Code VS Code extension with the Agent Manager, each agent se ### Keyboard Shortcuts -| Shortcut | Action | -| --------------------------- | ---------------------------- | +| Shortcut | Action | +|---|---| | Cmd+/ | Focus the session's terminal | -| Cmd+. | Cycle agent mode | +| Cmd+. | Cycle agent mode | ### Terminal Context Menu Actions diff --git a/packages/kilo-docs/pages/automate/how-tools-work.md b/packages/kilo-docs/pages/automate/how-tools-work.md index 5086fdb075d..3f4d293c0cf 100644 --- a/packages/kilo-docs/pages/automate/how-tools-work.md +++ b/packages/kilo-docs/pages/automate/how-tools-work.md @@ -21,24 +21,24 @@ Describe what you want to accomplish in natural language, and Kilo Code will: {% tabs %} {% tab label="VSCode" %} -| Category | Purpose | Tool Names | -| :------- | :----------------------------------------- | :----------------------------------------------------------- | -| Read | Access file content and code structure | `read`, `glob`, `grep` | -| Edit | Create or modify files and code | `edit`, `multiedit`, `write`, `apply_patch` | -| Execute | Run commands and perform system operations | `bash` | -| Web | Fetch and search web content | `webfetch`, `websearch`, `codesearch` | -| Workflow | Manage task flow and sub-agents | `question`, `task`, `todowrite`, `todoread`, `plan`, `skill` | +| Category | Purpose | Tool Names | +|:---|:---|:---| +| Read | Access file content and code structure | `read`, `glob`, `grep` | +| Edit | Create or modify files and code | `edit`, `write`, `apply_patch` | +| Execute | Run commands and perform system operations | `bash` | +| Web | Fetch and search web content | `webfetch`, `websearch`, `codesearch` | +| Workflow | Manage task flow and sub-agents | `question`, `task`, `todowrite`, `todoread`, `plan`, `skill` | {% /tab %} {% tab label="VSCode (Legacy)" %} -| Category | Purpose | Tool Names | -| :------- | :----------------------------------------- | :----------------------------------------------------------------------- | -| Read | Access file content and code structure | `read_file`, `search_files`, `list_files`, `list_code_definition_names` | -| Edit | Create or modify files and code | `apply_diff`, `delete_file`, `write_to_file` | -| Execute | Run commands and perform system operations | `execute_command` | -| Browser | Interact with web content | `browser_action` | -| Workflow | Manage task flow and context | `ask_followup_question`, `attempt_completion`, `switch_mode`, `new_task` | +| Category | Purpose | Tool Names | +|:---|:---|:---| +| Read | Access file content and code structure | `read_file`, `search_files`, `list_files`, `list_code_definition_names` | +| Edit | Create or modify files and code | `apply_diff`, `delete_file`, `write_to_file` | +| Execute | Run commands and perform system operations | `execute_command` | +| Browser | Interact with web content | `browser_action` | +| Workflow | Manage task flow and context | `ask_followup_question`, `attempt_completion`, `switch_mode`, `new_task` | {% /tab %} {% /tabs %} @@ -101,15 +101,15 @@ Every tool use is subject to a permission check. The default action for any tool **Default permissions by tool:** -| Tool(s) | Default | -| :------------------------------------------- | :----------------------------------------------- | -| `read`, `glob`, `grep`, `list` | `ask` | -| `edit`, `write`, `multiedit`, `apply_patch` | `ask` | -| `bash` | `ask` (per-command) | -| `external_directory` | `ask` (when accessing paths outside the project) | -| `task` | `ask` | -| `webfetch`, `websearch`, `codesearch` | `ask` | -| `todowrite`, `todoread`, `question`, `skill` | `ask` | +| Tool(s) | Default | +|:---|:---| +| `read`, `glob`, `grep` | `ask` | +| `edit`, `write`, `apply_patch` | `ask` | +| `bash` | `ask` (per-command) | +| `external_directory` | `ask` (when accessing paths outside the project) | +| `task` | `ask` | +| `webfetch`, `websearch`, `codesearch` | `ask` | +| `todowrite`, `todoread`, `question`, `skill` | `ask` | No tools are auto-approved out of the box. You must explicitly grant `allow` in your config, or approve them at runtime. @@ -154,44 +154,43 @@ This safety mechanism ensures you maintain control over which files are modified {% tabs %} {% tab label="VSCode" %} -| Tool Name | Description | Category | -| :------------ | :----------------------------------------------------- | :------- | -| `read` | Reads file contents with line numbers | Read | -| `glob` | Finds files by glob pattern | Read | -| `grep` | Searches file contents with regex | Read | -| `edit` | Makes precise text replacements in a file | Edit | -| `multiedit` | Multiple edits in a single call | Edit | -| `write` | Creates new files or overwrites existing ones | Edit | -| `apply_patch` | Applies unified diffs (used with certain models) | Edit | -| `bash` | Runs shell commands | Execute | -| `webfetch` | Fetches a URL | Web | -| `websearch` | Searches the web (Kilo/OpenRouter users) | Web | -| `codesearch` | Semantic code search (Kilo/OpenRouter users) | Web | -| `question` | Asks you a clarifying question with selectable options | Workflow | -| `task` | Spawns a sub-agent session | Workflow | -| `todowrite` | Creates and updates a session TODO list | Workflow | -| `todoread` | Reads the current session TODO list | Workflow | -| `plan` | Enters structured planning mode | Workflow | -| `skill` | Invokes a reusable skill (Markdown instruction module) | Workflow | +| Tool Name | Description | Category | +|:---|:---|:---| +| `read` | Reads file contents with line numbers | Read | +| `glob` | Finds files by glob pattern | Read | +| `grep` | Searches file contents with regex | Read | +| `edit` | Makes precise text replacements in a file | Edit | +| `write` | Creates new files or overwrites existing ones | Edit | +| `apply_patch` | Applies unified diffs (used with certain models) | Edit | +| `bash` | Runs shell commands | Execute | +| `webfetch` | Fetches a URL | Web | +| `websearch` | Searches the web (Kilo/OpenRouter users) | Web | +| `codesearch` | Semantic code search (Kilo/OpenRouter users) | Web | +| `question` | Asks you a clarifying question with selectable options | Workflow | +| `task` | Spawns a sub-agent session | Workflow | +| `todowrite` | Creates and updates a session TODO list | Workflow | +| `todoread` | Reads the current session TODO list | Workflow | +| `plan` | Enters structured planning mode | Workflow | +| `skill` | Invokes a reusable skill (Markdown instruction module) | Workflow | {% /tab %} {% tab label="VSCode (Legacy)" %} -| Tool Name | Description | Category | -| :--------------------------- | :-------------------------------------------------- | :------- | -| `read_file` | Reads the content of a file with line numbers | Read | -| `search_files` | Searches for text or regex patterns across files | Read | -| `list_files` | Lists files and directories in a specified location | Read | -| `list_code_definition_names` | Lists code definitions like classes and functions | Read | -| `write_to_file` | Creates new files or overwrites existing ones | Edit | -| `apply_diff` | Makes precise changes to specific parts of a file | Edit | -| `delete_file` | Removes files from the workspace | Edit | -| `execute_command` | Runs commands in the VS Code terminal | Execute | -| `browser_action` | Performs actions in a headless browser | Browser | -| `ask_followup_question` | Asks you a clarifying question | Workflow | -| `attempt_completion` | Indicates the task is complete | Workflow | -| `switch_mode` | Changes to a different operational mode | Workflow | -| `new_task` | Creates a new subtask with a specific starting mode | Workflow | +| Tool Name | Description | Category | +|:---|:---|:---| +| `read_file` | Reads the content of a file with line numbers | Read | +| `search_files` | Searches for text or regex patterns across files | Read | +| `list_files` | Lists files and directories in a specified location | Read | +| `list_code_definition_names` | Lists code definitions like classes and functions | Read | +| `write_to_file` | Creates new files or overwrites existing ones | Edit | +| `apply_diff` | Makes precise changes to specific parts of a file | Edit | +| `delete_file` | Removes files from the workspace | Edit | +| `execute_command` | Runs commands in the VS Code terminal | Execute | +| `browser_action` | Performs actions in a headless browser | Browser | +| `ask_followup_question` | Asks you a clarifying question | Workflow | +| `attempt_completion` | Indicates the task is complete | Workflow | +| `switch_mode` | Changes to a different operational mode | Workflow | +| `new_task` | Creates a new subtask with a specific starting mode | Workflow | {% /tab %} {% /tabs %} diff --git a/packages/kilo-docs/pages/automate/integrations.md b/packages/kilo-docs/pages/automate/integrations.md index 02cbd4730bf..1daed7f4a6c 100644 --- a/packages/kilo-docs/pages/automate/integrations.md +++ b/packages/kilo-docs/pages/automate/integrations.md @@ -9,10 +9,10 @@ Kilo Integrations lets you connect your GitHub or GitLab account (soon Bitbucket ## Supported Platforms -| Platform | Integration Type | Details | -| -------- | ---------------- | ---------------------------------- | -| GitHub | GitHub App | [GitHub Setup](#connecting-github) | -| GitLab | OAuth or PAT | [GitLab Setup](#connecting-gitlab) | +| Platform | Integration Type | Details | +|---|---|---| +| GitHub | GitHub App | [GitHub Setup](#connecting-github) | +| GitLab | OAuth or PAT | [GitLab Setup](#connecting-gitlab) | ## What You Can Do With Integrations diff --git a/packages/kilo-docs/pages/automate/mcp/mcp-vs-api.md b/packages/kilo-docs/pages/automate/mcp/mcp-vs-api.md index ec7bde3f201..3b88aa8bdeb 100644 --- a/packages/kilo-docs/pages/automate/mcp/mcp-vs-api.md +++ b/packages/kilo-docs/pages/automate/mcp/mcp-vs-api.md @@ -9,14 +9,14 @@ Comparing REST APIs to the Model Context Protocol (MCP) is a category error. The ## Architectural Differences -| Feature | MCP | REST APIs | -| -------------------- | ---------------------------------------------------- | ------------------------------------------------- | -| State Management | **Stateful** - maintains context across interactions | **Stateless** - each request is independent | -| Connection Type | Persistent, bidirectional connections | One-way request/response | -| Communication Style | JSON-RPC based with ongoing sessions | HTTP-based with discrete requests | -| Context Handling | Context is intrinsic to the protocol | Context must be manually managed | -| Tool Discovery | Runtime discovery of available tools | Design-time integration requiring prior knowledge | -| Integration Approach | Runtime integration with dynamic capabilities | Design-time integration requiring code changes | +| Feature | MCP | REST APIs | +|---|---|---| +| State Management | **Stateful** - maintains context across interactions | **Stateless** - each request is independent | +| Connection Type | Persistent, bidirectional connections | One-way request/response | +| Communication Style | JSON-RPC based with ongoing sessions | HTTP-based with discrete requests | +| Context Handling | Context is intrinsic to the protocol | Context must be manually managed | +| Tool Discovery | Runtime discovery of available tools | Design-time integration requiring prior knowledge | +| Integration Approach | Runtime integration with dynamic capabilities | Design-time integration requiring code changes | ## Different Layers, Different Purposes diff --git a/packages/kilo-docs/pages/automate/mcp/server-transports.md b/packages/kilo-docs/pages/automate/mcp/server-transports.md index adbc9776952..13024437c24 100644 --- a/packages/kilo-docs/pages/automate/mcp/server-transports.md +++ b/packages/kilo-docs/pages/automate/mcp/server-transports.md @@ -180,19 +180,19 @@ Some scenarios benefit from a hybrid approach: ## Choosing Between STDIO and SSE -| Consideration | STDIO | SSE | -| -------------------- | ------------------------ | ----------------------------------- | -| **Location** | Local machine only | Local or remote | -| **Clients** | Single client | Multiple clients | -| **Performance** | Lower latency | Higher latency (network overhead) | -| **Setup Complexity** | Simpler | More complex (requires HTTP server) | -| **Security** | Inherently secure | Requires explicit security measures | -| **Network Access** | Not needed | Required | -| **Scalability** | Limited to local machine | Can distribute across network | -| **Deployment** | Per-user installation | Centralized installation | -| **Updates** | Distributed updates | Centralized updates | -| **Resource Usage** | Uses client resources | Uses server resources | -| **Dependencies** | Client-side dependencies | Server-side dependencies | +| Consideration | STDIO | SSE | +|---|---|---| +| **Location** | Local machine only | Local or remote | +| **Clients** | Single client | Multiple clients | +| **Performance** | Lower latency | Higher latency (network overhead) | +| **Setup Complexity** | Simpler | More complex (requires HTTP server) | +| **Security** | Inherently secure | Requires explicit security measures | +| **Network Access** | Not needed | Required | +| **Scalability** | Limited to local machine | Can distribute across network | +| **Deployment** | Per-user installation | Centralized installation | +| **Updates** | Distributed updates | Centralized updates | +| **Resource Usage** | Uses client resources | Uses server resources | +| **Dependencies** | Client-side dependencies | Server-side dependencies | ## Configuring Transports in Kilo Code diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md index dc6ea99fcdf..865cfa1ac28 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md @@ -15,10 +15,10 @@ MCP servers add to your context, so be careful with which ones you enable. Certa The CLI accepts several config filenames. The recommended file is `kilo.json`: -| Scope | Recommended Path | Also supported | -| ----------- | ------------------------------------ | --------------------------- | -| **Global** | `~/.config/kilo/kilo.json` | `kilo.jsonc`, `config.json` | -| **Project** | `./kilo.json` or `./.kilo/kilo.json` | `kilo.jsonc` | +| Scope | Recommended Path | Also supported | +|---|---|---| +| **Global** | `~/.config/kilo/kilo.json` | `kilo.jsonc`, `config.json` | +| **Project** | `./kilo.json` or `./.kilo/kilo.json` | `kilo.jsonc` | Project-level configuration takes precedence over global settings. @@ -63,13 +63,13 @@ Local MCP servers run on your machine and communicate via standard input/output. #### Local Server Options -| Option | Type | Required | Description | -| ------------- | ------- | -------- | -------------------------------------------------------------------- | -| `type` | String | Yes | Must be `"local"`. | -| `command` | Array | Yes | Command and arguments to run the MCP server. | -| `environment` | Object | No | Environment variables to set when running the server. | -| `enabled` | Boolean | No | Enable or disable the MCP server on startup. | -| `timeout` | Number | No | Timeout in ms for fetching tools from the MCP server. Default: 5000. | +| Option | Type | Required | Description | +|---|---|---|---| +| `type` | String | Yes | Must be `"local"`. | +| `command` | Array | Yes | Command and arguments to run the MCP server. | +| `environment` | Object | No | Environment variables to set when running the server. | +| `enabled` | Boolean | No | Enable or disable the MCP server on startup. | +| `timeout` | Number | No | Timeout in ms for fetching tools from the MCP server. Default: 5000. | ### Remote Servers @@ -92,22 +92,22 @@ Remote MCP servers are accessed over HTTP/HTTPS. Set `type` to `"remote"`. #### Remote Server Options -| Option | Type | Required | Description | -| --------- | ------- | -------- | -------------------------------------------------------------------- | -| `type` | String | Yes | Must be `"remote"`. | -| `url` | String | Yes | URL of the remote MCP server. | -| `enabled` | Boolean | No | Enable or disable the MCP server on startup. | -| `headers` | Object | No | HTTP headers to send with requests. | -| `timeout` | Number | No | Timeout in ms for fetching tools from the MCP server. Default: 5000. | +| Option | Type | Required | Description | +|---|---|---|---| +| `type` | String | Yes | Must be `"remote"`. | +| `url` | String | Yes | URL of the remote MCP server. | +| `enabled` | Boolean | No | Enable or disable the MCP server on startup. | +| `headers` | Object | No | HTTP headers to send with requests. | +| `timeout` | Number | No | Timeout in ms for fetching tools from the MCP server. Default: 5000. | ## Managing MCP Servers You can manage MCP servers from the CLI: -| Command | Description | -| --------------- | ------------------------------- | +| Command | Description | +|---|---| | `kilo mcp list` | List all configured MCP servers | -| `kilo mcp add` | Add an MCP server | +| `kilo mcp add` | Add an MCP server | | `kilo mcp auth` | Authenticate with an MCP server | Inside the interactive TUI, use the `/mcps` slash command to toggle MCP servers on or off. diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md index 18629f8bdf1..6f332372e0a 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md @@ -76,20 +76,20 @@ Remote servers support OAuth 2.0 authentication. If the server supports it, Kilo The CLI accepts several config filenames. The recommended file is `kilo.json`: -| Scope | Recommended Path | Also supported | -| ----------- | ------------------------------------ | -------------------------------------------------------------- | -| **Global** | `~/.config/kilo/kilo.json` | `kilo.jsonc`, `opencode.json`, `opencode.jsonc`, `config.json` | -| **Project** | `./kilo.json` or `./.kilo/kilo.json` | `kilo.jsonc`, `opencode.jsonc`, `opencode.json` | +| Scope | Recommended Path | Also supported | +|---|---|---| +| **Global** | `~/.config/kilo/kilo.json` | `kilo.jsonc`, `opencode.json`, `opencode.jsonc`, `config.json` | +| **Project** | `./kilo.json` or `./.kilo/kilo.json` | `kilo.jsonc`, `opencode.jsonc`, `opencode.json` | {% /tab %} {% tab label="VSCode (Legacy)" %} MCP server configurations can be managed at two levels: **global** (applies across all workspaces) and **project-level** (specific to a single project). Project-level configuration takes precedence over global settings. -| Scope | Path | Description | -| ----------- | -------------------- | --------------------------------------------------------------- | -| **Global** | `mcp_settings.json` | Accessible via VS Code settings. Applies across all workspaces. | -| **Project** | `.kilocode/mcp.json` | In your project root. Auto-detected by Kilo Code. | +| Scope | Path | Description | +|---|---|---| +| **Global** | `mcp_settings.json` | Accessible via VS Code settings. Applies across all workspaces. | +| **Project** | `.kilocode/mcp.json` | In your project root. Auto-detected by Kilo Code. | Project-level configs can be committed to version control to share with your team. @@ -196,13 +196,13 @@ In the VS Code extension, open **Settings → MCP**, click **Add Server**, and c #### Local Server Options -| Option | Type | Required | Description | -| ------------- | ------- | -------- | --------------------------------------------------------------------- | -| `type` | String | Yes | Must be `"local"`. | -| `command` | Array | Yes | Command and arguments to run the MCP server. | -| `environment` | Object | No | Environment variables to set when running the server. | -| `enabled` | Boolean | No | Enable or disable the MCP server on startup. | -| `timeout` | Number | No | Timeout in ms for fetching tools from the MCP server. Default: 30000. | +| Option | Type | Required | Description | +|---|---|---|---| +| `type` | String | Yes | Must be `"local"`. | +| `command` | Array | Yes | Command and arguments to run the MCP server. | +| `environment` | Object | No | Environment variables to set when running the server. | +| `enabled` | Boolean | No | Enable or disable the MCP server on startup. | +| `timeout` | Number | No | Timeout in ms for fetching tools from the MCP server. Default: 30000. | {% /tab %} {% tab label="VSCode (Legacy)" %} @@ -260,13 +260,13 @@ In the VS Code extension, open **Settings → MCP**, click **Add Server**, and c #### Remote Server Options -| Option | Type | Required | Description | -| --------- | ------- | -------- | --------------------------------------------------------------------- | -| `type` | String | Yes | Must be `"remote"`. | -| `url` | String | Yes | URL of the remote MCP server. | -| `enabled` | Boolean | No | Enable or disable the MCP server on startup. | -| `headers` | Object | No | HTTP headers to send with requests. | -| `timeout` | Number | No | Timeout in ms for fetching tools from the MCP server. Default: 30000. | +| Option | Type | Required | Description | +|---|---|---|---| +| `type` | String | Yes | Must be `"remote"`. | +| `url` | String | Yes | URL of the remote MCP server. | +| `enabled` | Boolean | No | Enable or disable the MCP server on startup. | +| `headers` | Object | No | HTTP headers to send with requests. | +| `timeout` | Number | No | Timeout in ms for fetching tools from the MCP server. Default: 30000. | {% /tab %} {% tab label="VSCode (Legacy)" %} @@ -339,13 +339,13 @@ The extension also supports the `{env:VARIABLE_NAME}` syntax in config files to ### CLI Commands -| Command | Description | -| ----------------- | ------------------------------- | -| `kilo mcp list` | List all configured MCP servers | -| `kilo mcp add` | Add an MCP server | -| `kilo mcp auth` | Authenticate with an MCP server | -| `kilo mcp logout` | Log out from an MCP server | -| `kilo mcp debug` | Debug an MCP server connection | +| Command | Description | +|---|---| +| `kilo mcp list` | List all configured MCP servers | +| `kilo mcp add` | Add an MCP server | +| `kilo mcp auth` | Authenticate with an MCP server | +| `kilo mcp logout` | Log out from an MCP server | +| `kilo mcp debug` | Debug an MCP server connection | ### Enabling or Disabling a Server @@ -513,20 +513,20 @@ In the VS Code extension, open **Settings → MCP**, click **Add Server**, and c Use `cmd` as the command and pass the package command as arguments: -| Field | Value | -| ------------- | ----------------------------------------------------------- | -| **Name** | `puppeteer` | -| **Command** | `cmd` | +| Field | Value | +|---|---| +| **Name** | `puppeteer` | +| **Command** | `cmd` | | **Arguments** | `/c`, `npx`, `-y`, `@modelcontextprotocol/server-puppeteer` | ### macOS and Linux Use the executable directly: -| Field | Value | -| ------------- | ---------------------------------------------- | -| **Name** | `puppeteer` | -| **Command** | `npx` | +| Field | Value | +|---|---| +| **Name** | `puppeteer` | +| **Command** | `npx` | | **Arguments** | `-y`, `@modelcontextprotocol/server-puppeteer` | {% /tab %} diff --git a/packages/kilo-docs/pages/automate/tools/index.md b/packages/kilo-docs/pages/automate/tools/index.md index 9949a96321d..30713b21384 100644 --- a/packages/kilo-docs/pages/automate/tools/index.md +++ b/packages/kilo-docs/pages/automate/tools/index.md @@ -16,15 +16,15 @@ Kilo Code implements a sophisticated tool system that allows AI models to intera Tools are organized into logical groups based on their functionality: -| Category | Purpose | Tools | Common Use | -| ------------------ | --------------------------------- | ------------------------------------------------------------ | --------------------------------------- | -| **Read Group** | File system reading and searching | `read`, `glob`, `grep` | Code exploration and analysis | -| **Edit Group** | File system modifications | `edit`, `multiedit`, `write`, `apply_patch` | Code changes and file manipulation | -| **Execute Group** | Shell command execution | `bash` | Running scripts, building projects | -| **Web Group** | Fetch and search web content | `webfetch`, `websearch`, `codesearch` | Research, documentation lookup | -| **Browser Group** | Web browser automation | `kilo-playwright_*` (via built-in Playwright MCP) | Browser testing and interaction | -| **MCP Group** | External tool integration | MCP server tools (namespaced as `{server}_{tool}`) | Specialized functionality via MCP | -| **Workflow Group** | Sub-agents and task management | `question`, `task`, `todowrite`, `todoread`, `plan`, `skill` | Context switching and task organization | +| Category | Purpose | Tools | Common Use | +|---|---|---|---| +| **Read Group** | File system reading and searching | `read`, `glob`, `grep` | Code exploration and analysis | +| **Edit Group** | File system modifications | `edit`, `write`, `apply_patch` | Code changes and file manipulation | +| **Execute Group** | Shell command execution | `bash` | Running scripts, building projects | +| **Web Group** | Fetch and search web content | `webfetch`, `websearch`, `codesearch` | Research, documentation lookup | +| **Browser Group** | Web browser automation | `kilo-playwright_*` (via built-in Playwright MCP) | Browser testing and interaction | +| **MCP Group** | External tool integration | MCP server tools (namespaced as `{server}_{tool}`) | Specialized functionality via MCP | +| **Workflow Group** | Sub-agents and task management | `question`, `task`, `todowrite`, `todoread`, `plan`, `skill` | Context switching and task organization | ### Always Available Tools @@ -49,10 +49,11 @@ These tools help Kilo Code understand your code and project: These tools help Kilo Code make changes to your code: - `edit` - Makes precise text replacements in a file -- `multiedit` - Multiple edits in a single call - `write` - Creates new files or fully overwrites existing ones - `apply_patch` - Applies unified diffs (used with certain models) +For multiple replacements in one file, Kilo uses repeated `edit` calls or a patch-style edit when the model supports it. + ### Execute Tools These tools help Kilo Code run commands: @@ -99,14 +100,14 @@ These tools help manage the conversation and task flow: Tools are organized into logical groups based on their functionality: -| Category | Purpose | Tools | Common Use | -| ------------------ | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | -| **Read Group** | File system reading and searching | [read_file](/docs/automate/tools/read-file), [search_files](/docs/automate/tools/search-files), [list_files](/docs/automate/tools/list-files), [list_code_definition_names](/docs/automate/tools/list-code-definition-names) | Code exploration and analysis | -| **Edit Group** | File system modifications | [apply_diff](/docs/automate/tools/apply-diff), [delete_file](/docs/automate/tools/delete-file), [write_to_file](/docs/automate/tools/write-to-file) | Code changes and file manipulation | -| **Browser Group** | Web automation | [browser_action](/docs/automate/tools/browser-action) | Web testing and interaction | -| **Command Group** | System command execution | [execute_command](/docs/automate/tools/execute-command) | Running scripts, building projects | -| **MCP Group** | External tool integration | [use_mcp_tool](/docs/automate/tools/use-mcp-tool), [access_mcp_resource](/docs/automate/tools/access-mcp-resource) | Specialized functionality through external servers | -| **Workflow Group** | Mode and task management | [switch_mode](/docs/automate/tools/switch-mode), [new_task](/docs/automate/tools/new-task), [ask_followup_question](/docs/automate/tools/ask-followup-question), [attempt_completion](/docs/automate/tools/attempt-completion), [update_todo_list](/docs/automate/tools/update-todo-list) | Context switching and task organization | +| Category | Purpose | Tools | Common Use | +|---|---|---|---| +| **Read Group** | File system reading and searching | [read_file](/docs/automate/tools/read-file), [search_files](/docs/automate/tools/search-files), [list_files](/docs/automate/tools/list-files), [list_code_definition_names](/docs/automate/tools/list-code-definition-names) | Code exploration and analysis | +| **Edit Group** | File system modifications | [apply_diff](/docs/automate/tools/apply-diff), [delete_file](/docs/automate/tools/delete-file), [write_to_file](/docs/automate/tools/write-to-file) | Code changes and file manipulation | +| **Browser Group** | Web automation | [browser_action](/docs/automate/tools/browser-action) | Web testing and interaction | +| **Command Group** | System command execution | [execute_command](/docs/automate/tools/execute-command) | Running scripts, building projects | +| **MCP Group** | External tool integration | [use_mcp_tool](/docs/automate/tools/use-mcp-tool), [access_mcp_resource](/docs/automate/tools/access-mcp-resource) | Specialized functionality through external servers | +| **Workflow Group** | Mode and task management | [switch_mode](/docs/automate/tools/switch-mode), [new_task](/docs/automate/tools/new-task), [ask_followup_question](/docs/automate/tools/ask-followup-question), [attempt_completion](/docs/automate/tools/attempt-completion), [update_todo_list](/docs/automate/tools/update-todo-list) | Context switching and task organization | ### Always Available Tools diff --git a/packages/kilo-docs/pages/automate/tools/codebase-search.md b/packages/kilo-docs/pages/automate/tools/semantic-search.md similarity index 92% rename from packages/kilo-docs/pages/automate/tools/codebase-search.md rename to packages/kilo-docs/pages/automate/tools/semantic-search.md index 3efe135d9a5..75867ee2d85 100644 --- a/packages/kilo-docs/pages/automate/tools/codebase-search.md +++ b/packages/kilo-docs/pages/automate/tools/semantic-search.md @@ -1,10 +1,10 @@ -# codebase_search +# semantic_search {% callout type="info" title="Setup Required" %} -The `codebase_search` tool is part of the [Codebase Indexing](/docs/customize/context/codebase-indexing) feature. It requires additional setup including an embedding provider and vector database. +The `semantic_search` tool is part of the [Codebase Indexing](/docs/customize/context/codebase-indexing) feature. It requires additional setup including an embedding provider and vector database. {% /callout %} -The `codebase_search` tool performs semantic searches across your entire codebase using AI embeddings. Unlike traditional text-based search, it understands the meaning of your queries and finds relevant code even when exact keywords don't match. +The `semantic_search` tool performs semantic searches across your entire codebase using AI embeddings. Unlike traditional text-based search, it understands the meaning of your queries and finds relevant code even when exact keywords don't match. --- @@ -70,7 +70,7 @@ This tool is only available when the Codebase Indexing feature is properly confi ## How It Works -When the `codebase_search` tool is invoked, it follows this process: +When the `semantic_search` tool is invoked, it follows this process: 1. **Availability Validation**: - Verifies that the CodeIndexManager is available and initialized @@ -112,33 +112,33 @@ When the `codebase_search` tool is invoked, it follows this process: **Good: Conceptual and specific** ```xml - + user authentication and password validation - + ``` **Good: Feature-focused** ```xml - + database connection pool setup - + ``` **Good: Problem-oriented** ```xml - + error handling for API requests - + ``` **Less effective: Too generic** ```xml - + function - + ``` ### Query Types That Work Well @@ -157,28 +157,28 @@ Use the optional `path` parameter to focus searches on specific parts of your co **Search within API modules:** ```xml - + endpoint validation middleware src/api - + ``` **Search in test files:** ```xml - + mock data setup patterns tests - + ``` **Search specific feature directories:** ```xml - + component state management src/components/auth - + ``` --- @@ -217,42 +217,42 @@ Each search result includes: Searching for authentication-related code across the entire project: ```xml - + user login and authentication logic - + ``` Finding database-related code in a specific directory: ```xml - + database connection and query execution src/data - + ``` Looking for error handling patterns in API code: ```xml - + HTTP error responses and exception handling src/api - + ``` Searching for testing utilities and mock setups: ```xml - + test setup and mock data creation tests - + ``` Finding configuration and environment setup code: ```xml - + environment variables and application configuration - + ``` diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index 7cf19caf359..c52b8dd6509 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -7,11 +7,11 @@ description: "Smart model routing that automatically selects the optimal AI mode Auto Model is a smart model routing system that automatically selects the optimal AI model based on the Kilo Code mode you're using. It comes in multiple tiers so you can balance cost and capability to fit your needs. -| Tier | Best For | Pricing | -| -------------------- | ------------------------------------------------- | ------- | -| `kilo-auto/frontier` | Maximum capability with the best available models | Paid | -| `kilo-auto/balanced` | Strong performance at a lower cost | Paid | -| `kilo-auto/free` | The best free models available | Free | +| Tier | Best For | Pricing | +|---|---|---| +| `kilo-auto/frontier` | Maximum capability with the best available models | Paid | +| `kilo-auto/balanced` | Strong performance at a lower cost | Paid | +| `kilo-auto/free` | The best free models available | Free | ## How It Works @@ -30,7 +30,7 @@ The underlying models behind each Auto Model tier are updated server-side as bet ## Tiers - **Frontier** — Routes to the latest and most capable paid models. Uses different models for reasoning-heavy tasks (planning, architecture, debugging) versus implementation tasks (coding, building, exploring), pairing the right capability to each type of work. -- **Balanced** — Uses a single cost-effective model across all modes. A good default for most developers who want strong AI assistance without paying frontier prices. +- **Balanced** — Routes to a cost-effective model for all modes. The specific model is selected based on the API interface in use, but does not vary by mode. A good default for most developers who want strong AI assistance without paying frontier prices. - **Free** — Routes to the best available free models on OpenRouter, splitting traffic across them. Because free model availability shifts over time as providers change promotional periods, the mapping is updated server-side — you always get the best free option without having to track what's currently available. Quality will be lower than paid tiers, and the models may change over time. ## Benefits diff --git a/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md b/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md index 38cab73d5bd..64621f7d7f9 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md @@ -137,12 +137,12 @@ Reference files and other context directly in your message using `@`: ## Common Mistakes to Avoid -| Instead of this... | Try this | -| --------------------------------- | ----------------------------------------------------------------------------------- | -| "Fix the code" | "Fix the bug in `calculateTotal` that returns incorrect results" | -| Assuming Kilo knows context | Use `@` to reference specific files | -| Multiple unrelated tasks | Submit one focused request at a time | -| Technical jargon overload | Clear, straightforward language works best | +| Instead of this... | Try this | +|---|---| +| "Fix the code" | "Fix the bug in `calculateTotal` that returns incorrect results" | +| Assuming Kilo knows context | Use `@` to reference specific files | +| Multiple unrelated tasks | Submit one focused request at a time | +| Technical jargon overload | Clear, straightforward language works best | | Using chat for tiny code changes. | Use [autocomplete](/docs/code-with-ai/features/autocomplete) for inline completions | **Why it matters:** Kilo Code works best when you communicate like you're talking to a smart teammate who needs clear direction. diff --git a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md index dd10ed38b05..1ef860fdca5 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md @@ -20,23 +20,24 @@ When you describe a task, the agent uses its tools — `read`, `grep`, `glob`, a Type `@` in the chat input to get autocomplete suggestions. You can mention: -| Mention | Description | Example | -| ------------ | ------------------------------------------- | --------------- | -| **File** | Attach a file's contents to your message | `@src/utils.ts` | -| **Terminal** | Include your active VS Code terminal output | `@terminal` | +| Mention | Description | Example | +|---|---|---| +| **File** | Attach a file's contents to your message | `@src/utils.ts` | +| **Terminal** | Include your active VS Code terminal output | `@terminal` | +| **Git Changes** | Attach uncommitted working-tree diffs and new files | `@git-changes` | -Selecting a suggestion inserts the mention and highlights it in the input. File contents and terminal output are attached as context when you send the message. +Selecting a suggestion inserts the mention and highlights it in the input. File contents, terminal output, and git changes are attached as context when you send the message. ### Drag and Drop You can also add file mentions by dragging and dropping: -| Source | How | Result | -| ------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------ | -| **Explorer / Editor tabs** | Drag a file or folder from VS Code's Explorer or an editor tab into the chat input | Inserts an `@/relative/path` mention | -| **Multiple files** | Drag several files at once | Inserts space-separated `@` mentions | -| **Agent Manager diff headers** | Drag a file header from the Agent Manager's diff panel into chat | Inserts an `@file` mention | -| **Images** | Hold **Shift** while dragging an image file from your OS file manager into the chat input | Attaches the image | +| Source | How | Result | +|---|---|---| +| **Explorer / Editor tabs** | Drag a file or folder from VS Code's Explorer or an editor tab into the chat input | Inserts an `@/relative/path` mention | +| **Multiple files** | Drag several files at once | Inserts space-separated `@` mentions | +| **Agent Manager diff headers** | Drag a file header from the Agent Manager's diff panel into chat | Inserts an `@file` mention | +| **Images** | Hold **Shift** while dragging an image file from your OS file manager into the chat input | Attaches the image | {% callout type="info" %} VS Code requires holding **Shift** when dragging files from outside the editor (e.g. Finder or Windows Explorer) into a webview. This applies to image drops — file drops from within VS Code (Explorer, editor tabs) work without Shift. @@ -52,23 +53,23 @@ Selected code and editor diagnostics (errors/warnings) are not included automati Rather than attaching file contents up front, the agent reads files on demand during its work: -| Tool | Purpose | Example | -| -------- | --------------------------------------------- | ------------------------------------------- | -| **read** | Read the contents of a specific file | Agent reads `src/utils.ts` to understand it | -| **glob** | Find files matching a pattern | Agent searches for `**/*.test.ts` | -| **grep** | Search file contents for a pattern | Agent searches for `function handleError` | -| **bash** | Run shell commands including `git` operations | Agent runs `git diff` or `git log` | +| Tool | Purpose | Example | +|---|---|---| +| **read** | Read the contents of a specific file | Agent reads `src/utils.ts` to understand it | +| **glob** | Find files matching a pattern | Agent searches for `**/*.test.ts` | +| **grep** | Search file contents for a pattern | Agent searches for `function handleError` | +| **bash** | Run shell commands including `git` operations | Agent runs `git diff` or `git log` | This means the agent can explore your entire project as needed, rather than being limited to files you explicitly mention. ## Best Practices -| Practice | Description | -| ------------------------------ | -------------------------------------------------------------------------------------------------- | -| **Describe the task clearly** | The agent finds context on its own — focus on _what_ you want done rather than _where_ the code is | -| **Mention files when helpful** | If you know the exact file, mention its path to save the agent a search step | -| **Keep editor tabs relevant** | Open tabs are passed as context, so keep relevant files open | -| **Trust the agent's tools** | The agent can search, read, and explore your codebase — let it do the discovery work | +| Practice | Description | +|---|---| +| **Describe the task clearly** | The agent finds context on its own — focus on _what_ you want done rather than _where_ the code is | +| **Mention files when helpful** | If you know the exact file, mention its path to save the agent a search step | +| **Keep editor tabs relevant** | Open tabs are passed as context, so keep relevant files open | +| **Trust the agent's tools** | The agent can search, read, and explore your codebase — let it do the discovery work | {% /tab %} {% tab label="CLI" %} @@ -92,23 +93,23 @@ In the terminal-based TUI, you can provide context in several ways: Rather than attaching file contents up front, the agent reads files on demand during its work: -| Tool | Purpose | Example | -| -------- | --------------------------------------------- | ------------------------------------------- | -| **read** | Read the contents of a specific file | Agent reads `src/utils.ts` to understand it | -| **glob** | Find files matching a pattern | Agent searches for `**/*.test.ts` | -| **grep** | Search file contents for a pattern | Agent searches for `function handleError` | -| **bash** | Run shell commands including `git` operations | Agent runs `git diff` or `git log` | +| Tool | Purpose | Example | +|---|---|---| +| **read** | Read the contents of a specific file | Agent reads `src/utils.ts` to understand it | +| **glob** | Find files matching a pattern | Agent searches for `**/*.test.ts` | +| **grep** | Search file contents for a pattern | Agent searches for `function handleError` | +| **bash** | Run shell commands including `git` operations | Agent runs `git diff` or `git log` | This means the agent can explore your entire project as needed, rather than being limited to files you explicitly mention. ## Best Practices -| Practice | Description | -| ------------------------------ | -------------------------------------------------------------------------------------------------- | -| **Describe the task clearly** | The agent finds context on its own — focus on _what_ you want done rather than _where_ the code is | -| **Mention files when helpful** | If you know the exact file, mention its path to save the agent a search step | -| **Use `kilo run -f`** | Pass key files with `-f` when using `kilo run` for immediate context | -| **Trust the agent's tools** | The agent can search, read, and explore your codebase — let it do the discovery work | +| Practice | Description | +|---|---| +| **Describe the task clearly** | The agent finds context on its own — focus on _what_ you want done rather than _where_ the code is | +| **Mention files when helpful** | If you know the exact file, mention its path to save the agent a search step | +| **Use `kilo run -f`** | Pass key files with `-f` when using `kilo run` for immediate context | +| **Trust the agent's tools** | The agent can search, read, and explore your codebase — let it do the discovery work | {% /tab %} {% tab label="VSCode (Legacy)" %} @@ -121,84 +122,84 @@ Context mentions are a powerful way to provide Kilo Code with specific informati {% image src="/docs/img/context-mentions/context-mentions-1.png" alt="File mention example showing a file being referenced with @ and its contents appearing in the conversation" width="600" caption="File mentions add actual code content into the conversation for direct reference and analysis." /%} -| Mention Type | Format | Description | Example Usage | -| --------------- | ---------------------- | ------------------------------------------- | ---------------------------------------- | -| **File** | `@/path/to/file.ts` | Includes file contents in request context | "Explain the function in @/src/utils.ts" | -| **Folder** | `@/path/to/folder/` | Provides directory structure in tree format | "What files are in @/src/components/?" | -| **Problems** | `@problems` | Includes VS Code Problems panel diagnostics | "@problems Fix all errors in my code" | -| **Terminal** | `@terminal` | Includes recent terminal command and output | "Fix the errors shown in @terminal" | -| **Git Commit** | `@a1b2c3d` | References specific commit by hash | "What changed in commit @a1b2c3d?" | -| **Git Changes** | `@git-changes` | Shows uncommitted changes | "Suggest a message for @git-changes" | -| **URL** | `@https://example.com` | Imports website content | "Summarize @https://docusaurus.io/" | +| Mention Type | Format | Description | Example Usage | +|---|---|---|---| +| **File** | `@/path/to/file.ts` | Includes file contents in request context | "Explain the function in @/src/utils.ts" | +| **Folder** | `@/path/to/folder/` | Provides directory structure in tree format | "What files are in @/src/components/?" | +| **Problems** | `@problems` | Includes VS Code Problems panel diagnostics | "@problems Fix all errors in my code" | +| **Terminal** | `@terminal` | Includes recent terminal command and output | "Fix the errors shown in @terminal" | +| **Git Commit** | `@a1b2c3d` | References specific commit by hash | "What changed in commit @a1b2c3d?" | +| **Git Changes** | `@git-changes` | Shows uncommitted changes | "Suggest a message for @git-changes" | +| **URL** | `@https://example.com` | Imports website content | "Summarize @https://docusaurus.io/" | ### File Mentions {% image src="/docs/img/context-mentions/context-mentions-1.png" alt="File mention example showing a file being referenced with @ and its contents appearing in the conversation" width="600" caption="File mentions incorporate source code with line numbers for precise references." /%} -| Capability | Details | -| --------------- | --------------------------------------------------------------- | -| **Format** | `@/path/to/file.ts` (always start with `/` from workspace root) | -| **Provides** | Complete file contents with line numbers | -| **Supports** | Text files, PDFs, and DOCX files (with text extraction) | -| **Works in** | Initial requests, feedback responses, and follow-up messages | -| **Limitations** | Very large files may be truncated; binary files not supported | +| Capability | Details | +|---|---| +| **Format** | `@/path/to/file.ts` (always start with `/` from workspace root) | +| **Provides** | Complete file contents with line numbers | +| **Supports** | Text files, PDFs, and DOCX files (with text extraction) | +| **Works in** | Initial requests, feedback responses, and follow-up messages | +| **Limitations** | Very large files may be truncated; binary files not supported | ### Folder Mentions {% image src="/docs/img/context-mentions/context-mentions-2.png" alt="Folder mention example showing directory contents being referenced in the chat" width="600" caption="Folder mentions display directory structure in a readable tree format." /%} -| Capability | Details | -| ------------ | ------------------------------------------------------ | -| **Format** | `@/path/to/folder/` (note trailing slash) | -| **Provides** | Hierarchical tree display with ├── and └── prefixes | -| **Includes** | Immediate child files and directories (not recursive) | -| **Best for** | Understanding project structure | -| **Tip** | Use with file mentions to check specific file contents | +| Capability | Details | +|---|---| +| **Format** | `@/path/to/folder/` (note trailing slash) | +| **Provides** | Hierarchical tree display with ├── and └── prefixes | +| **Includes** | Immediate child files and directories (not recursive) | +| **Best for** | Understanding project structure | +| **Tip** | Use with file mentions to check specific file contents | ### Problems Mention {% image src="/docs/img/context-mentions/context-mentions-3.png" alt="Problems mention example showing VS Code problems panel being referenced with @problems" width="600" caption="Problems mentions import diagnostics directly from VS Code's problems panel." /%} -| Capability | Details | -| ------------ | ----------------------------------------------------- | -| **Format** | `@problems` | +| Capability | Details | +|---|---| +| **Format** | `@problems` | | **Provides** | All errors and warnings from VS Code's problems panel | -| **Includes** | File paths, line numbers, and diagnostic messages | -| **Groups** | Problems organized by file for better clarity | -| **Best for** | Fixing errors without manual copying | +| **Includes** | File paths, line numbers, and diagnostic messages | +| **Groups** | Problems organized by file for better clarity | +| **Best for** | Fixing errors without manual copying | ### Terminal Mention {% image src="/docs/img/context-mentions/context-mentions-4.png" alt="Terminal mention example showing terminal output being included in Kilo Code's context" width="600" caption="Terminal mentions capture recent command output for debugging and analysis." /%} -| Capability | Details | -| -------------- | -------------------------------------------------- | -| **Format** | `@terminal` | -| **Captures** | Last command and its complete output | -| **Preserves** | Terminal state (doesn't clear the terminal) | -| **Limitation** | Limited to visible terminal buffer content | -| **Best for** | Debugging build errors or analyzing command output | +| Capability | Details | +|---|---| +| **Format** | `@terminal` | +| **Captures** | Last command and its complete output | +| **Preserves** | Terminal state (doesn't clear the terminal) | +| **Limitation** | Limited to visible terminal buffer content | +| **Best for** | Debugging build errors or analyzing command output | ### Git Mentions {% image src="/docs/img/context-mentions/context-mentions-5.png" alt="Git commit mention example showing commit details being analyzed by Kilo Code" width="600" caption="Git mentions provide commit details and diffs for context-aware version analysis." /%} -| Type | Format | Provides | Limitations | -| ------------------- | -------------- | --------------------------------------------------- | ------------------------------ | -| **Commit** | `@a1b2c3d` | Commit message, author, date, and complete diff | Only works in Git repositories | +| Type | Format | Provides | Limitations | +|---|---|---|---| +| **Commit** | `@a1b2c3d` | Commit message, author, date, and complete diff | Only works in Git repositories | | **Working Changes** | `@git-changes` | `git status` output and diff of uncommitted changes | Only works in Git repositories | ### URL Mentions {% image src="/docs/img/context-mentions/context-mentions-6.png" alt="URL mention example showing website content being converted to Markdown in the chat" width="600" caption="URL mentions import external web content and convert it to readable Markdown format." /%} -| Capability | Details | -| -------------- | ------------------------------------------------ | -| **Format** | `@https://example.com` | -| **Processing** | Uses headless browser to fetch content | -| **Cleaning** | Removes scripts, styles, and navigation elements | -| **Output** | Converts content to Markdown for readability | -| **Limitation** | Complex pages may not convert perfectly | +| Capability | Details | +|---|---| +| **Format** | `@https://example.com` | +| **Processing** | Uses headless browser to fetch content | +| **Cleaning** | Removes scripts, styles, and navigation elements | +| **Output** | Converts content to Markdown for readability | +| **Limitation** | Complex pages may not convert perfectly | ## How to Use Mentions @@ -216,14 +217,14 @@ The dropdown automatically suggests: ## Best Practices -| Practice | Description | -| -------------------------- | -------------------------------------------------------------------------------- | -| **Use specific paths** | Reference exact files rather than describing them | -| **Use relative paths** | Always start from workspace root: `@/src/file.ts` not `@C:/Projects/src/file.ts` | -| **Verify references** | Ensure paths and commit hashes are correct | -| **Click mentions** | Click mentions in chat history to open files or view content | -| **Eliminate copy-pasting** | Use mentions instead of manually copying code or errors | -| **Combine mentions** | "Fix @problems in @/src/component.ts using the pattern from commit @a1b2c3d" | +| Practice | Description | +|---|---| +| **Use specific paths** | Reference exact files rather than describing them | +| **Use relative paths** | Always start from workspace root: `@/src/file.ts` not `@C:/Projects/src/file.ts` | +| **Verify references** | Ensure paths and commit hashes are correct | +| **Click mentions** | Click mentions in chat history to open files or view content | +| **Eliminate copy-pasting** | Use mentions instead of manually copying code or errors | +| **Combine mentions** | "Fix @problems in @/src/component.ts using the pattern from commit @a1b2c3d" | {% /tab %} {% /tabs %} diff --git a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md index db8dc74c645..27678482729 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/custom-models.md @@ -76,30 +76,51 @@ The `model` key uses the format `provider_id/model_id`, where: All fields are optional. When a model ID matches one already in the built-in catalog, your values are merged on top of the defaults — you only need to specify what you want to override. -| Field | Type | Description | -| ------------- | --------- | ----------------------------------------------------------------------------- | -| `name` | `string` | Display name shown in the model picker | -| `id` | `string` | API-facing model ID sent to the provider. Defaults to the config key | -| `tool_call` | `boolean` | Whether the model supports tool/function calling | -| `reasoning` | `boolean` | Whether the model supports extended thinking | -| `temperature` | `boolean` | Whether the model supports the temperature parameter | -| `attachment` | `boolean` | Whether the model supports file attachments | -| `limit` | `object` | Token limits: `{ context, output, input? }` | -| `cost` | `object` | Pricing per million tokens: `{ input, output, cache_read?, cache_write? }` | -| `options` | `object` | Arbitrary provider-specific model options | -| `headers` | `object` | Custom HTTP headers to include in requests | -| `provider` | `object` | Override `{ npm?, api? }` — the AI SDK package or base API URL for this model | -| `variants` | `object` | Named variant configurations (e.g., different reasoning efforts) | +| Field | Type | Description | +|---|---|---| +| `name` | `string` | Display name shown in the model picker | +| `id` | `string` | API-facing model ID sent to the provider. Defaults to the config key | +| `tool_call` | `boolean` | Whether the model supports tool/function calling | +| `reasoning` | `boolean` | Whether the model supports extended thinking | +| `temperature` | `boolean` | Whether the model supports the temperature parameter | +| `attachment` | `boolean` | Whether the model supports file attachments | +| `modalities` | `object` | Optional. Supported input and output types: `{ input, output }` | +| `limit` | `object` | Token limits: `{ context, output, input? }` | +| `cost` | `object` | Pricing per million tokens: `{ input, output, cache_read?, cache_write? }` | +| `options` | `object` | Arbitrary provider-specific model options | +| `headers` | `object` | Custom HTTP headers to include in requests | +| `provider` | `object` | Override `{ npm?, api? }` — the AI SDK package or base API URL for this model | +| `variants` | `object` | Named variant configurations (e.g., different reasoning efforts) | + +### Modalities (modalities) + +The `modalities` object declares which content types the model can receive and produce. It is optional — omit it to use defaults from the catalog or fallback to text-only. When `modalities` is provided, both `input` and `output` arrays are required. Each array can include `text`, `image`, `audio`, `video`, or `pdf`. + +| Sub-field | Type | Required | Description | +|---|---|---|---| +| `input` | `array` | Yes (if present) | Content types the model accepts from the user | +| `output` | `array` | Yes (if present) | Content types the model can generate in response | + +For a standard text model that can also inspect images, use: + +```jsonc +"modalities": { + "input": ["text", "image"], + "output": ["text"] +} +``` + +If `modalities` is omitted and the model ID matches a models.dev catalog entry for that provider, Kilo uses the catalog's modalities. For completely custom models with no catalog match, Kilo defaults to text input and text output only. Set `attachment: true` alongside image, audio, video, or PDF input modalities when the provider supports sending those files as attachments. ### Token Limits (limit) The `limit` object controls how Kilo manages the model's context window and output length. These values are specified in **tokens**. -| Sub-field | Type | Required | Description | -| --------- | -------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `context` | `number` | No | The model's total context window size (e.g., `131072` for a 128K model). Used to determine when conversation history should be compacted to stay within the window. | -| `output` | `number` | No | The maximum number of tokens the model can generate in a single response. Sent to the provider as `max_tokens` or equivalent. Capped at 32,000 by default. | -| `input` | `number` | No | An optional stricter input limit. Some providers enforce an input token ceiling that is lower than the full context window. When set, compaction triggers against this value instead of `context`. | +| Sub-field | Type | Required | Description | +|---|---|---|---| +| `context` | `number` | No | The model's total context window size (e.g., `131072` for a 128K model). Used to determine when conversation history should be compacted to stay within the window. | +| `output` | `number` | No | The maximum number of tokens the model can generate in a single response. Sent to the provider as `max_tokens` or equivalent. Capped at 32,000 by default. | +| `input` | `number` | No | An optional stricter input limit. Some providers enforce an input token ceiling that is lower than the full context window. When set, compaction triggers against this value instead of `context`. | ```jsonc "limit": { @@ -308,18 +329,18 @@ You can also set options that apply to all models from a provider: "options": { "apiKey": "{env:OPENAI_API_KEY}", "baseURL": "https://my-proxy.example.com/v1", - "timeout": 120000, + "timeout": 300000, }, }, }, } ``` -| Option | Type | Description | -| --------- | ----------------- | ------------------------------------------------------ | -| `apiKey` | `string` | API key (supports `{env:VAR}` syntax) | -| `baseURL` | `string` | Override the provider's base API URL | -| `timeout` | `number \| false` | Request timeout in milliseconds, or `false` to disable | +| Option | Type | Description | +|---|---|---| +| `apiKey` | `string` | API key (supports `{env:VAR}` syntax) | +| `baseURL` | `string` | Override the provider's base API URL | +| `timeout` | `number \| false` | Request timeout in milliseconds. Defaults to `300000` (5 minutes); set to `false` to disable | ## Filtering Available Models diff --git a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md index 0c4eb040359..cf5c2d3ae5b 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md @@ -25,6 +25,7 @@ While the specifics change constantly, some principles stay consistent: {% tab label="VSCode" %} - Use the **model selector** in the chat prompt area to pick a model for the current session. You can also type `/models` to open the model picker. +- When the selected model supports variants, type `/variant` to open the reasoning effort selector. - Set per-agent defaults and a global default in the **Settings** panel (Models tab), or directly in the `kilo.jsonc` config file. - **Model precedence:** Session override → Last picked per agent → Per-agent config → Global config → Kilo Auto (free). - The model selector remembers the last model you picked for each agent — switching agents restores your previous choice. A manual pick always beats config settings; use the **reset button** (visible when your active model differs from config) to go back to the config default. @@ -76,6 +77,61 @@ Check [our provider docs](/docs/ai-providers) for specific context limits on eac **Recover from context limit errors:** If you hit the `input length and max tokens exceed context limit` error, you can recover by deleting a message, rolling back to a previous checkpoint, or switching over to a model with a long context window like Gemini for a message. {% /callout %} +## Models During Delegation + +When an agent delegates work to a subagent (via the `task` tool), the subagent **inherits the parent agent's model** by default. You can override this per subagent in your config: + +{% tabs %} +{% tab label="CLI" %} + +```json +{ + "agent": { + "explore": { + "model": "anthropic/claude-haiku-4-20250514" + } + } +} +``` + +This sets the `explore` subagent to always use Haiku regardless of the parent's model. Any subagent without a `model` override uses whatever model the invoking agent is running. + +{% /tab %} +{% tab label="VSCode" %} + +Subagents inherit the model currently active in the primary agent session — the model shown in the selector at the bottom of the chat. To bypass inheritance and pin a specific model for a subagent: + +- **Via Settings** — open **Settings → Models → Model per Mode**, find the subagent, and pick its model. +- **Via config file** — edit `kilo.jsonc`: + +```json +{ + "agent": { + "explore": { + "model": "anthropic/claude-haiku-4-5" + } + } +} +``` + +The Settings UI writes the same `agent..model` entry, so either method produces the same override. Subagents without an explicit model continue to inherit whatever the invoking agent is running. + +{% /tab %} +{% tab label="VSCode (Legacy)" %} + +In the legacy extension, each mode has **Sticky Models** — switching from one mode to another (e.g., Code → Architect) uses whatever model you last selected for that mode, not the model from the mode you came from. This means you can assign different models to different modes: + +- **Architect:** a reasoning-heavy model (Gemini Pro, Claude Opus) +- **Code:** a fast coding model (Claude Sonnet, GPT-4.1) +- **Debug:** a cost-efficient model (Gemini Flash, DeepSeek) + +The model selection is remembered per mode across sessions. + +{% /tab %} +{% /tabs %} + +For details on configuring subagent models, see [Custom Subagents](/docs/customize/custom-subagents). + ## Stay Current The AI model space moves fast. Bookmark [kilo.ai/models](https://kilo.ai/models) and check back when you're evaluating options. What's best today might not be best next month — and that's actually exciting. diff --git a/packages/kilo-docs/pages/code-with-ai/agents/using-agents.md b/packages/kilo-docs/pages/code-with-ai/agents/using-agents.md index c03728dfbb9..dde8d394e53 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/using-agents.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/using-agents.md @@ -58,18 +58,18 @@ Four ways to switch modes: Users often confuse `/newtask` and `/smol`. Here's the key difference: -| Command | Purpose | When to Use | -| ---------- | ----------------------------------------------------- | ----------------------------------------------------------------------- | -| `/newtask` | Creates a new task with context from the current task | When you want to start something new while carrying over context | -| `/smol` | Condenses your current context window | When your conversation is getting too long and you want to summarize it | +| Command | Purpose | When to Use | +|---|---|---| +| `/newtask` | Creates a new task with context from the current task | When you want to start something new while carrying over context | +| `/smol` | Condenses your current context window | When your conversation is getting too long and you want to summarize it | 3. **Toggle command/Keyboard shortcut:** Use the keyboard shortcut below, applicable to your operating system. Each press cycles through the available modes in sequence, wrapping back to the first mode after reaching the end. | Operating System | Shortcut | - | ---------------- | -------- | - | macOS | ⌘ + . | - | Windows | Ctrl + . | - | Linux | Ctrl + . | + |---|---| + | macOS | ⌘ + . | + | Windows | Ctrl + . | + | Linux | Ctrl + . | You can hold `shift` to move backwards through the list of modes, for example ⌘ + shift + on macOS. @@ -87,48 +87,48 @@ You can hold `shift` to move backwards through the list of modes, for example ### code (Default) -| Aspect | Details | -| -------------------- | ----------------------------------------------------------------------------------------------------------------- | -| **Description** | A skilled software engineer with expertise in programming languages, design patterns, and best practices | -| **Tool Access** | Full access to all tools: `read`, `edit`, `glob`, `grep`, `bash`, `task`, `webfetch`, plus tools from MCP servers | -| **Ideal For** | Writing code, implementing features, debugging, and general development | -| **Special Features** | No tool restrictions — full flexibility for all coding tasks | +| Aspect | Details | +|---|---| +| **Description** | A skilled software engineer with expertise in programming languages, design patterns, and best practices | +| **Tool Access** | Full access to all tools: `read`, `edit`, `glob`, `grep`, `bash`, `task`, `webfetch`, plus tools from MCP servers | +| **Ideal For** | Writing code, implementing features, debugging, and general development | +| **Special Features** | No tool restrictions — full flexibility for all coding tasks | ### ask -| Aspect | Details | -| -------------------- | ------------------------------------------------------------------------------------------------- | -| **Description** | A knowledgeable technical assistant focused on answering questions without changing your codebase | -| **Tool Access** | Read-only tools only (cannot edit files or run commands) | -| **Ideal For** | Code explanation, concept exploration, and technical learning | -| **Special Features** | Optimized for informative responses without modifying your project | +| Aspect | Details | +|---|---| +| **Description** | A knowledgeable technical assistant focused on answering questions without changing your codebase | +| **Tool Access** | Read-only tools only (cannot edit files or run commands) | +| **Ideal For** | Code explanation, concept exploration, and technical learning | +| **Special Features** | Optimized for informative responses without modifying your project | ### plan -| Aspect | Details | -| -------------------- | ---------------------------------------------------------------------------------------------------- | -| **Description** | An experienced technical leader and planner who helps design systems and create implementation plans | -| **Tool Access** | Read-only tools plus restricted file editing (plan files in `.kilo/plans/` only) | -| **Ideal For** | System design, high-level planning, and architecture discussions | -| **Special Features** | Similar to the legacy extension's "Architect" mode, with a planning-focused approach | +| Aspect | Details | +|---|---| +| **Description** | An experienced technical leader and planner who helps design systems and create implementation plans | +| **Tool Access** | Read-only tools plus restricted file editing (plan files in `.kilo/plans/` only) | +| **Ideal For** | System design, high-level planning, and architecture discussions | +| **Special Features** | Similar to the legacy extension's "Architect" mode, with a planning-focused approach | ### debug -| Aspect | Details | -| -------------------- | ----------------------------------------------------------------------------------- | -| **Description** | An expert problem solver specializing in systematic troubleshooting and diagnostics | -| **Tool Access** | Full access to all tools | -| **Ideal For** | Tracking down bugs, diagnosing errors, and resolving complex issues | +| Aspect | Details | +|---|---| +| **Description** | An expert problem solver specializing in systematic troubleshooting and diagnostics | +| **Tool Access** | Full access to all tools | +| **Ideal For** | Tracking down bugs, diagnosing errors, and resolving complex issues | | **Special Features** | Uses a methodical approach of analyzing, narrowing possibilities, and fixing issues | ### orchestrator (Deprecated) -| Aspect | Details | -| -------------------- | -------------------------------------------------------------------------------------------------------------------- | -| **Description** | A strategic workflow orchestrator who coordinates complex tasks by delegating them to appropriate specialized agents | -| **Tool Access** | Limited access to create new tasks and coordinate workflows | -| **Ideal For** | Breaking down complex projects into manageable subtasks assigned to specialized agents | -| **Special Features** | Delegates work to other agents; also has access to the **explore** subagent for codebase exploration | +| Aspect | Details | +|---|---| +| **Description** | A strategic workflow orchestrator who coordinates complex tasks by delegating them to appropriate specialized agents | +| **Tool Access** | Limited access to create new tasks and coordinate workflows | +| **Ideal For** | Breaking down complex projects into manageable subtasks assigned to specialized agents | +| **Special Features** | Delegates work to other agents; also has access to the **explore** subagent for codebase exploration | {% callout type="warning" %} Orchestrator is deprecated and will be removed in a future release. Agents with full tool access (Code, Plan, Debug) now support subagents natively — there's no need for a dedicated orchestrator. See [Orchestrator Mode (Deprecated)](/docs/code-with-ai/agents/orchestrator-mode) for migration details. @@ -143,48 +143,48 @@ The VSCode extension and CLI do not include a built-in Review agent. Code review ### code (Default) -| Aspect | Details | -| -------------------- | ----------------------------------------------------------------------------------------------------------------- | -| **Description** | A skilled software engineer with expertise in programming languages, design patterns, and best practices | -| **Tool Access** | Full access to all tools: `read`, `edit`, `glob`, `grep`, `bash`, `task`, `webfetch`, plus tools from MCP servers | -| **Ideal For** | Writing code, implementing features, debugging, and general development | -| **Special Features** | No tool restrictions — full flexibility for all coding tasks | +| Aspect | Details | +|---|---| +| **Description** | A skilled software engineer with expertise in programming languages, design patterns, and best practices | +| **Tool Access** | Full access to all tools: `read`, `edit`, `glob`, `grep`, `bash`, `task`, `webfetch`, plus tools from MCP servers | +| **Ideal For** | Writing code, implementing features, debugging, and general development | +| **Special Features** | No tool restrictions — full flexibility for all coding tasks | ### ask -| Aspect | Details | -| -------------------- | ------------------------------------------------------------------------------------------------- | -| **Description** | A knowledgeable technical assistant focused on answering questions without changing your codebase | -| **Tool Access** | Read-only tools only (cannot edit files or run commands) | -| **Ideal For** | Code explanation, concept exploration, and technical learning | -| **Special Features** | Optimized for informative responses without modifying your project | +| Aspect | Details | +|---|---| +| **Description** | A knowledgeable technical assistant focused on answering questions without changing your codebase | +| **Tool Access** | Read-only tools only (cannot edit files or run commands) | +| **Ideal For** | Code explanation, concept exploration, and technical learning | +| **Special Features** | Optimized for informative responses without modifying your project | ### plan -| Aspect | Details | -| -------------------- | ---------------------------------------------------------------------------------------------------- | -| **Description** | An experienced technical leader and planner who helps design systems and create implementation plans | -| **Tool Access** | Read-only tools plus restricted file editing (plan files in `.kilo/plans/` only) | -| **Ideal For** | System design, high-level planning, and architecture discussions | -| **Special Features** | Similar to the legacy extension's "Architect" mode, with a planning-focused approach | +| Aspect | Details | +|---|---| +| **Description** | An experienced technical leader and planner who helps design systems and create implementation plans | +| **Tool Access** | Read-only tools plus restricted file editing (plan files in `.kilo/plans/` only) | +| **Ideal For** | System design, high-level planning, and architecture discussions | +| **Special Features** | Similar to the legacy extension's "Architect" mode, with a planning-focused approach | ### debug -| Aspect | Details | -| -------------------- | ----------------------------------------------------------------------------------- | -| **Description** | An expert problem solver specializing in systematic troubleshooting and diagnostics | -| **Tool Access** | Full access to all tools | -| **Ideal For** | Tracking down bugs, diagnosing errors, and resolving complex issues | +| Aspect | Details | +|---|---| +| **Description** | An expert problem solver specializing in systematic troubleshooting and diagnostics | +| **Tool Access** | Full access to all tools | +| **Ideal For** | Tracking down bugs, diagnosing errors, and resolving complex issues | | **Special Features** | Uses a methodical approach of analyzing, narrowing possibilities, and fixing issues | ### orchestrator (Deprecated) -| Aspect | Details | -| -------------------- | -------------------------------------------------------------------------------------------------------------------- | -| **Description** | A strategic workflow orchestrator who coordinates complex tasks by delegating them to appropriate specialized agents | -| **Tool Access** | Limited access to create new tasks and coordinate workflows | -| **Ideal For** | Breaking down complex projects into manageable subtasks assigned to specialized agents | -| **Special Features** | Delegates work to other agents; also has access to the **explore** subagent for codebase exploration | +| Aspect | Details | +|---|---| +| **Description** | A strategic workflow orchestrator who coordinates complex tasks by delegating them to appropriate specialized agents | +| **Tool Access** | Limited access to create new tasks and coordinate workflows | +| **Ideal For** | Breaking down complex projects into manageable subtasks assigned to specialized agents | +| **Special Features** | Delegates work to other agents; also has access to the **explore** subagent for codebase exploration | {% callout type="warning" %} Orchestrator is deprecated and will be removed in a future release. Agents with full tool access (Code, Plan, Debug) now support subagents natively — there's no need for a dedicated orchestrator. See [Orchestrator Mode (Deprecated)](/docs/code-with-ai/agents/orchestrator-mode) for migration details. @@ -199,38 +199,38 @@ The VSCode extension and CLI do not include a built-in Review agent. Code review ### Code Mode (Default) -| Aspect | Details | -| -------------------- | -------------------------------------------------------------------------------------------------------- | -| **Description** | A skilled software engineer with expertise in programming languages, design patterns, and best practices | -| **Tool Access** | Full access to all tool groups: `read`, `edit`, `browser`, `command`, `mcp` | -| **Ideal For** | Writing code, implementing features, debugging, and general development | -| **Special Features** | No tool restrictions—full flexibility for all coding tasks | +| Aspect | Details | +|---|---| +| **Description** | A skilled software engineer with expertise in programming languages, design patterns, and best practices | +| **Tool Access** | Full access to all tool groups: `read`, `edit`, `browser`, `command`, `mcp` | +| **Ideal For** | Writing code, implementing features, debugging, and general development | +| **Special Features** | No tool restrictions—full flexibility for all coding tasks | ### Ask Mode -| Aspect | Details | -| -------------------- | ------------------------------------------------------------------------------------------------- | -| **Description** | A knowledgeable technical assistant focused on answering questions without changing your codebase | -| **Tool Access** | Limited access: `read`, `browser`, `mcp` only (cannot edit files or run commands) | -| **Ideal For** | Code explanation, concept exploration, and technical learning | -| **Special Features** | Optimized for informative responses without modifying your project | +| Aspect | Details | +|---|---| +| **Description** | A knowledgeable technical assistant focused on answering questions without changing your codebase | +| **Tool Access** | Limited access: `read`, `browser`, `mcp` only (cannot edit files or run commands) | +| **Ideal For** | Code explanation, concept exploration, and technical learning | +| **Special Features** | Optimized for informative responses without modifying your project | ### Architect Mode -| Aspect | Details | -| -------------------- | ---------------------------------------------------------------------------------------------------- | -| **Description** | An experienced technical leader and planner who helps design systems and create implementation plans | -| **Tool Access** | Access to `read`, `browser`, `mcp`, and restricted `edit` (markdown files only) | -| **Ideal For** | System design, high-level planning, and architecture discussions | -| **Special Features** | Follows a structured approach from information gathering to detailed planning | +| Aspect | Details | +|---|---| +| **Description** | An experienced technical leader and planner who helps design systems and create implementation plans | +| **Tool Access** | Access to `read`, `browser`, `mcp`, and restricted `edit` (markdown files only) | +| **Ideal For** | System design, high-level planning, and architecture discussions | +| **Special Features** | Follows a structured approach from information gathering to detailed planning | ### Debug Mode -| Aspect | Details | -| -------------------- | ----------------------------------------------------------------------------------- | -| **Description** | An expert problem solver specializing in systematic troubleshooting and diagnostics | -| **Tool Access** | Full access to all tool groups: `read`, `edit`, `browser`, `command`, `mcp` | -| **Ideal For** | Tracking down bugs, diagnosing errors, and resolving complex issues | +| Aspect | Details | +|---|---| +| **Description** | An expert problem solver specializing in systematic troubleshooting and diagnostics | +| **Tool Access** | Full access to all tool groups: `read`, `edit`, `browser`, `command`, `mcp` | +| **Ideal For** | Tracking down bugs, diagnosing errors, and resolving complex issues | | **Special Features** | Uses a methodical approach of analyzing, narrowing possibilities, and fixing issues | {% callout type="tip" %} @@ -239,21 +239,21 @@ The VSCode extension and CLI do not include a built-in Review agent. Code review ### Orchestrator Mode -| Aspect | Details | -| -------------------- | ------------------------------------------------------------------------------------------------------------------- | -| **Description** | A strategic workflow orchestrator who coordinates complex tasks by delegating them to appropriate specialized modes | -| **Tool Access** | Limited access to create new tasks and coordinate workflows | -| **Ideal For** | Breaking down complex projects into manageable subtasks assigned to specialized modes | -| **Special Features** | Uses the new_task tool to delegate work to other modes | +| Aspect | Details | +|---|---| +| **Description** | A strategic workflow orchestrator who coordinates complex tasks by delegating them to appropriate specialized modes | +| **Tool Access** | Limited access to create new tasks and coordinate workflows | +| **Ideal For** | Breaking down complex projects into manageable subtasks assigned to specialized modes | +| **Special Features** | Uses the new_task tool to delegate work to other modes | ### Review Mode -| Aspect | Details | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | -| **Description** | An expert code reviewer specializing in analyzing changes to provide structured feedback on quality, security, and best practices | -| **Tool Access** | Access to `read`, `browser`, `mcp`, and when permitted, `edit` | -| **Ideal For** | Catching issues early, enforcing code standards, accelerating PR turnaround | -| **Special Features** | Code review before committing, surfacing feedback across performance, security, style, and test coverage | +| Aspect | Details | +|---|---| +| **Description** | An expert code reviewer specializing in analyzing changes to provide structured feedback on quality, security, and best practices | +| **Tool Access** | Access to `read`, `browser`, `mcp`, and when permitted, `edit` | +| **Ideal For** | Catching issues early, enforcing code standards, accelerating PR turnaround | +| **Special Features** | Code review before committing, surfacing feedback across performance, security, style, and test coverage | {% /tab %} {% /tabs %} diff --git a/packages/kilo-docs/pages/code-with-ai/features/autocomplete/index.md b/packages/kilo-docs/pages/code-with-ai/features/autocomplete/index.md index bc485fcdda9..45b5072fb05 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/autocomplete/index.md +++ b/packages/kilo-docs/pages/code-with-ai/features/autocomplete/index.md @@ -12,7 +12,12 @@ Kilo Code's autocomplete feature provides intelligent code suggestions and compl ## How Autocomplete Works -The extension uses **Fill-in-the-Middle (FIM)** completion powered by Codestral (`mistralai/codestral-2508`) via the **Kilo Gateway**. It analyzes the code before and after your cursor to generate contextually accurate inline suggestions. +The extension uses **Fill-in-the-Middle (FIM)** completion routed through the **Kilo Gateway**. It analyzes the code before and after your cursor to generate contextually accurate inline suggestions. + +You can choose between two FIM models: + +- **Codestral** (`mistralai/codestral-2508`) by Mistral AI — the default, billed through your Kilo account. +- **Mercury Edit** (`inception/mercury-edit`) by Inception — temporarily available via **BYOK** (Bring Your Own Key) only; Kilo Gateway support is coming soon. ## Triggering Options @@ -30,9 +35,14 @@ This keybinding requires `kilo-code.new.autocomplete.enableSmartInlineTaskKeybin ## Provider and Model -Autocomplete currently uses **Codestral** (`mistralai/codestral-2508`) routed through the **Kilo Gateway**. Codestral is optimized for Fill-in-the-Middle (FIM) completions, and there is no option to select a different model at this time. Support for additional FIM models is planned for future releases. +Autocomplete requests are routed through the **Kilo Gateway**. You can pick the FIM model under **Settings → Models → Autocomplete model**: -Requests are billed through your Kilo account. To use your own Mistral API key instead, see [Setting Up Mistral for Free Autocomplete](/docs/code-with-ai/features/autocomplete/mistral-setup). +- **Codestral** (`mistralai/codestral-2508`) — the default. Billed through your Kilo account, or free when you add your own Mistral Codestral key via BYOK. See [Setting Up Mistral for Free Autocomplete](/docs/code-with-ai/features/autocomplete/mistral-setup). +- **Mercury Edit** (`inception/mercury-edit`) — a fast diffusion-based FIM model by Inception. Temporarily requires an **Inception BYOK key** until Kilo Gateway support lands. Add one from the [BYOK page](https://app.kilo.ai/byok) in the Kilo platform. See [Bring Your Own Key (BYOK)](/docs/getting-started/byok) for setup details. + +{% callout type="note" %} +Mercury Edit is only available through BYOK for now — Kilo Gateway support is coming soon. If you select Mercury Edit without a valid Inception BYOK key configured, autocomplete requests will fail — switch back to Codestral or add an Inception key to continue. +{% /callout %} ## Status Bar diff --git a/packages/kilo-docs/pages/code-with-ai/features/browser-use.md b/packages/kilo-docs/pages/code-with-ai/features/browser-use.md index 40d29c18728..2168eb48e9b 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/browser-use.md +++ b/packages/kilo-docs/pages/code-with-ai/features/browser-use.md @@ -86,16 +86,16 @@ Key characteristics: ### Available Browser Tools -| Tool | Description | When to Use | -| -------------------- | ----------------------------------- | ------------------------------------- | -| `browser_navigate` | Navigates to a URL | Opening a web page | -| `browser_click` | Clicks an element on the page | Interacting with buttons, links, etc. | -| `browser_type` | Types text into an input element | Filling forms, search boxes | -| `browser_screenshot` | Captures a screenshot of the page | Inspecting visual state | -| `browser_scroll` | Scrolls the page or a specific area | Viewing content above or below | -| `browser_hover` | Hovers over an element | Revealing tooltips or menus | -| `browser_select` | Selects an option from a dropdown | Choosing from select elements | -| `browser_drag` | Drags an element to a target | Drag-and-drop interactions | +| Tool | Description | When to Use | +|---|---|---| +| `browser_navigate` | Navigates to a URL | Opening a web page | +| `browser_click` | Clicks an element on the page | Interacting with buttons, links, etc. | +| `browser_type` | Types text into an input element | Filling forms, search boxes | +| `browser_screenshot` | Captures a screenshot of the page | Inspecting visual state | +| `browser_scroll` | Scrolls the page or a specific area | Viewing content above or below | +| `browser_hover` | Hovers over an element | Revealing tooltips or menus | +| `browser_select` | Selects an option from a dropdown | Choosing from select elements | +| `browser_drag` | Drags an element to a target | Drag-and-drop interactions | {% /tab %} {% tab label="VSCode (Legacy)" %} @@ -111,14 +111,14 @@ Key characteristics: ### Available Browser Actions -| Action | Description | When to Use | -| ------------- | ------------------------------ | ------------------------------------- | -| `launch` | Opens a browser at a URL | Starting a new browser session | -| `click` | Clicks at specific coordinates | Interacting with buttons, links, etc. | -| `type` | Types text into active element | Filling forms, search boxes | -| `scroll_down` | Scrolls down by one page | Viewing content below the fold | -| `scroll_up` | Scrolls up by one page | Returning to previous content | -| `close` | Closes the browser | Ending a browser session | +| Action | Description | When to Use | +|---|---|---| +| `launch` | Opens a browser at a URL | Starting a new browser session | +| `click` | Clicks at specific coordinates | Interacting with buttons, links, etc. | +| `type` | Types text into active element | Filling forms, search boxes | +| `scroll_down` | Scrolls down by one page | Viewing content below the fold | +| `scroll_up` | Scrolls up by one page | Returning to previous content | +| `close` | Closes the browser | Ending a browser session | {% /tab %} {% /tabs %} diff --git a/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md b/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md new file mode 100644 index 00000000000..a6d22e2c3cd --- /dev/null +++ b/packages/kilo-docs/pages/code-with-ai/features/file-encoding.md @@ -0,0 +1,51 @@ +--- +title: "File Encoding" +description: "How Kilo handles text file encodings when reading and editing files" +--- + +# File Encoding + +Kilo automatically detects the text encoding of each file it reads and preserves that encoding when writing changes back. You can work with source files in any supported encoding without worrying about Kilo corrupting them or showing the model garbled text. + +## Supported Encodings + +- UTF-8, with or without BOM +- UTF-16 LE and UTF-16 BE, **with a BOM** +- Shift_JIS, EUC-JP, GB2312, Big5, EUC-KR +- Windows-1251, KOI8-R +- The ISO-8859 family +- Other common legacy Latin and CJK encodings detected by [jschardet](https://github.com/aadsm/jschardet) and decoded by [iconv-lite](https://github.com/ashtuchkin/iconv-lite) + +New files Kilo creates are always UTF-8 without a BOM. Encoding detection only runs when Kilo reads or overwrites an existing file. + +## Not Supported + +- **UTF-16 without a BOM.** The byte pattern is ambiguous and cannot be distinguished reliably from other encodings. Save the file with a BOM or convert it to UTF-8. +- **UTF-32.** Extremely rare in practice; convert to UTF-8 if you need Kilo to work with it. + +{% callout type="info" %} +Encoding detection is statistical. Very short files, or files whose byte patterns happen to look like a different encoding, may occasionally be misidentified. If that happens, converting the file to UTF-8 is the most reliable workaround. +{% /callout %} + +## Reporting Issues + +If Kilo displays a file as garbled text, or writes it back in a different encoding than it was saved in, please open an issue at [github.com/Kilo-Org/kilocode/issues](https://github.com/Kilo-Org/kilocode/issues) and include all of the following: + +- **A file that reproduces the issue.** Attach the actual file to the issue — do not paste its contents into the issue body, since the web form will re-encode the text. +- **The exact name of the encoding** the file is saved in, for example `Shift_JIS`, `windows-1251`, or `UTF-16 LE with BOM`. +- **A SHA-256 hash of the attached file** so we can confirm it wasn't corrupted when uploaded. + + On macOS or Linux: + + ```bash + shasum -a 256 path/to/file + ``` + + On Windows: + + ```powershell + Get-FileHash path\to\file -Algorithm SHA256 + ``` + +- **The model and provider** you were using when the issue occurred, for example `claude-sonnet-4.5` via Kilo Gateway. +- **The exact Kilo version** you are running. For the CLI, run `kilo --version`. For the VS Code extension, open the Extensions view and check the version next to "Kilo Code". diff --git a/packages/kilo-docs/pages/code-with-ai/features/task-todo-list.md b/packages/kilo-docs/pages/code-with-ai/features/task-todo-list.md index 249692c8d50..19ff14dc2da 100644 --- a/packages/kilo-docs/pages/code-with-ai/features/task-todo-list.md +++ b/packages/kilo-docs/pages/code-with-ai/features/task-todo-list.md @@ -9,7 +9,7 @@ description: "Track and manage tasks with AI-generated todo lists" **Why it matters**: Complex workflows have lots of moving parts. Without structure, it's easy to miss steps, duplicate work, or forget what comes next. -{% image src="/docs/img/task-todo-list/task-todo-list-1.png" alt="Task Todo List overview showing interactive checklist in Kilo Code" width="500" /%} +{% image src="/docs/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-docs-overview-chromium-linux.png" alt="Task Todo List overview showing interactive checklist in Kilo Code" width="420" /%} ## How to trigger todo lists @@ -53,7 +53,7 @@ Kilo updates the list when: **1. Task Header Summary** Quick progress overview with your next important item -{% image src="/docs/img/task-todo-list/task-header.png" alt="Task header summary showing todo list progress" width="500" /%} +{% image src="/docs/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png" alt="Task header summary showing todo list progress" width="420" /%} Click the task header summary to expand the full list inline and jump to the current item. diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md index a49c0d72daa..87779277f90 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli-reference.md @@ -587,7 +587,7 @@ Options: --path directory path to generate the agent file [string] --description what the agent should do [string] --mode agent mode [string] [choices: "all", "primary", "subagent"] - --tools comma-separated list of tools to enable (default: all). Available: "bash, read, write, edit, list, glob, grep, webfetch, task, todowrite" [string] + --tools comma-separated list of tools to enable (default: all). Available: "bash, read, write, edit, glob, grep, webfetch, task, todowrite" [string] -m, --model model to use in the format of provider/model [string] ``` @@ -687,6 +687,8 @@ Options: --sanitize redact sensitive transcript and file data [boolean] ``` +Use `--sanitize` before sharing an exported session outside your team. Unsanitized exports can contain full prompts, assistant text, reasoning, tool inputs and outputs, file paths, file contents, snapshots, diffs, attachments, working directories, and other local context. Sanitized exports replace sensitive transcript and file data with stable `[redacted:...]` placeholders while preserving enough structure for debugging import/export issues. + ## kilo import ``` diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md index ed6ff494c9c..d3915a24163 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -67,73 +67,73 @@ For detailed help on every command and subcommand, see the [CLI Command Referenc ### Global Options -| Flag | Description | -| ----------------- | ----------------------------------- | -| `--help`, `-h` | Show help | -| `--version`, `-v` | Show version number | -| `--print-logs` | Print logs to stderr | -| `--log-level` | Log level: DEBUG, INFO, WARN, ERROR | +| Flag | Description | +|---|---| +| `--help`, `-h` | Show help | +| `--version`, `-v` | Show version number | +| `--print-logs` | Print logs to stderr | +| `--log-level` | Log level: DEBUG, INFO, WARN, ERROR | ### Interactive Slash Commands #### Session Commands -| Command | Aliases | Description | -| ------------- | ---------------------- | ------------------------- | -| `/sessions` | `/resume`, `/continue` | Switch session | -| `/new` | `/clear` | New session | -| `/share` | - | Share session | -| `/unshare` | - | Unshare session | -| `/rename` | - | Rename session | -| `/timeline` | - | Jump to message | -| `/fork` | - | Fork from message | -| `/compact` | `/summarize` | Compact/summarize session | -| `/undo` | - | Undo previous message | -| `/redo` | - | Redo message | -| `/copy` | - | Copy session transcript | -| `/export` | - | Export session transcript | -| `/timestamps` | `/toggle-timestamps` | Show/hide timestamps | -| `/thinking` | `/toggle-thinking` | Show/hide thinking blocks | +| Command | Aliases | Description | +|---|---|---| +| `/sessions` | `/resume`, `/continue` | Switch session | +| `/new` | `/clear` | New session | +| `/share` | - | Share session | +| `/unshare` | - | Unshare session | +| `/rename` | - | Rename session | +| `/timeline` | - | Jump to message | +| `/fork` | - | Fork from message | +| `/compact` | `/summarize` | Compact/summarize session | +| `/undo` | - | Undo previous message | +| `/redo` | - | Redo message | +| `/copy` | - | Copy session transcript | +| `/export` | - | Export session transcript | +| `/timestamps` | `/toggle-timestamps` | Show/hide timestamps | +| `/thinking` | `/toggle-thinking` | Show/hide thinking blocks | #### Agent & Model Commands -| Command | Description | -| --------- | ------------ | +| Command | Description | +|---|---| | `/models` | Switch model | | `/agents` | Switch agent | -| `/mcps` | Toggle MCPs | +| `/mcps` | Toggle MCPs | #### Provider Commands -| Command | Description | -| ---------- | ------------------------------------------------------------------------- | +| Command | Description | +|---|---| | `/connect` | Connect/add a provider - entry point for new users to add API credentials | #### System Commands -| Command | Aliases | Description | -| --------- | ------------- | -------------------- | -| `/status` | - | View status | -| `/themes` | - | Switch theme | -| `/help` | - | Show help | -| `/editor` | - | Open external editor | -| `/exit` | `/quit`, `/q` | Exit the app | +| Command | Aliases | Description | +|---|---|---| +| `/status` | - | View status | +| `/themes` | - | Switch theme | +| `/help` | - | Show help | +| `/editor` | - | Open external editor | +| `/exit` | `/quit`, `/q` | Exit the app | #### Kilo Gateway Commands (when connected) -| Command | Aliases | Description | -| ---------- | ------------------------ | ----------------------------------------- | -| `/profile` | `/me`, `/whoami` | View your Kilo Gateway profile | -| `/teams` | `/team`, `/org`, `/orgs` | Switch between Kilo Gateway teams | -| `/remote` | - | Toggle remote mode for Cloud Agent access | +| Command | Aliases | Description | +|---|---|---| +| `/profile` | `/me`, `/whoami` | View your Kilo Gateway profile | +| `/teams` | `/team`, `/org`, `/orgs` | Switch between Kilo Gateway teams | +| `/remote` | - | Toggle remote mode for Cloud Agent access | #### Built-in Commands -| Command | Description | -| --------------------------- | -------------------------------------------- | -| `/init` | Create/update AGENTS.md file for the project | -| `/local-review` | Review code changes | -| `/local-review-uncommitted` | Review uncommitted changes | +| Command | Description | +|---|---| +| `/init` | Create/update AGENTS.md file for the project | +| `/local-review` | Review code changes | +| `/local-review-uncommitted` | Review uncommitted changes | ## Local Code Reviews @@ -141,9 +141,9 @@ Review your code locally before pushing — catch issues early without waiting f ### Commands -| Command | Description | -| --------------------------- | ---------------------------------------------- | -| `/local-review` | Review current branch changes vs base branch | +| Command | Description | +|---|---| +| `/local-review` | Review current branch changes vs base branch | | `/local-review-uncommitted` | Review uncommitted changes (staged + unstaged) | ## Config Reference @@ -241,7 +241,7 @@ You can use `~` or `$HOME` at the start of a pattern to reference your home dire ### External Directories -Use `external_directory` to allow tool calls that touch paths outside the working directory where Kilo was started. This applies to any tool that takes a path as input (for example `read`, `edit`, `list`, `glob`, `grep`, and many bash commands). +Use `external_directory` to allow tool calls that touch paths outside the working directory where Kilo was started. This applies to any tool that takes a path as input (for example `read`, `edit`, `glob`, `grep`, and many bash commands). ```json { @@ -278,10 +278,10 @@ The Kilo CLI is a fork of [OpenCode](https://opencode.ai) and supports the same ### Config File Location (Kilo CLI 1.0) -| Scope | Path | -| ----------- | ------------------------------------------------------------------------------------------------- | -| **Global** | `~/.config/kilo/opencode.json` or `opencode.jsonc` (Windows: config dir may vary; same filenames) | -| **Project** | `./opencode.json` or `./.opencode/` in project root | +| Scope | Path | +|---|---| +| **Global** | `~/.config/kilo/opencode.json` or `opencode.jsonc` (Windows: config dir may vary; same filenames) | +| **Project** | `./opencode.json` or `./.opencode/` in project root | Project-level configuration takes precedence over global settings. @@ -308,13 +308,56 @@ Common configuration options include: - **`mcp`** - MCP server configuration - **`permission`** - Tool permission settings (`allow` or `ask`) - **`instructions`** - Paths to instruction files (e.g., `["CONTRIBUTING.md", ".cursor/rules/*.md"]`) -- **`formatter`** - Code formatter configuration +- **`formatter`** - Code formatter configuration (`true`, `false`, or formatter-specific entries) +- **`lsp`** - Language server configuration (`true`, `false`, or server-specific entries) - **`disabled_providers`** / **`enabled_providers`** - Control which providers are available {% callout type="tip" %} **Using a model that's not in the built-in list?** You can register any model by adding it under `provider..models` in your config file. See [Custom Models](/docs/code-with-ai/agents/custom-models) for full details and examples. {% /callout %} +### Formatter and LSP Toggles + +Set `formatter` or `lsp` to `true` to use built-in defaults, or `false` to disable the feature completely: + +```jsonc +{ + "formatter": true, + "lsp": false, +} +``` + +Both keys also accept object configuration for specific tools or language servers. Custom LSP server entries must include an `extensions` array unless the entry disables a built-in server: + +```jsonc +{ + "lsp": { + "my-language-server": { + "command": ["my-lsp", "--stdio"], + "extensions": [".foo"], + }, + }, +} +``` + +### TUI Keybindings on Windows + +The TUI gives `Ctrl+Z` to input undo on Windows because native Windows terminals do not support POSIX terminal suspend. On Windows, `input_undo` defaults to `ctrl+z,ctrl+-,super+z` and `terminal_suspend` is disabled. On macOS and Linux, `terminal_suspend` defaults to `ctrl+z`. + +### OpenTelemetry Export + +Kilo telemetry is enabled by default and can be disabled with `experimental.openTelemetry = false`: + +```jsonc +{ + "experimental": { + "openTelemetry": false, + }, +} +``` + +If `OTEL_EXPORTER_OTLP_ENDPOINT` is set, the CLI exports OpenTelemetry traces and logs to that OTLP HTTP endpoint. You can also pass `OTEL_EXPORTER_OTLP_HEADERS` as comma-separated `key=value` pairs and `OTEL_RESOURCE_ATTRIBUTES` as comma-separated resource attributes. Request spans include `http.method`, `http.path`, route params such as `session.id` and `message.id`, and internal params under the `opencode.*` namespace. + ### Environment Variables Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variables: diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/slack.md b/packages/kilo-docs/pages/code-with-ai/platforms/slack.md index de4d0b6a1b7..401b7751202 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/slack.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/slack.md @@ -20,10 +20,10 @@ Kilo for Slack brings the power of Kilo Code directly into your Slack workspace. ## Supported Platforms -| Platform | Integration Type | Details | -| -------- | ---------------- | ------------------------------------------------------------------- | -| GitHub | GitHub App | [GitHub Setup Guide](/docs/automate/integrations#connecting-github) | -| GitLab | OAuth or PAT | [GitLab Setup Guide](/docs/automate/integrations#connecting-gitlab) | +| Platform | Integration Type | Details | +|---|---|---| +| GitHub | GitHub App | [GitHub Setup Guide](/docs/automate/integrations#connecting-github) | +| GitLab | OAuth or PAT | [GitLab Setup Guide](/docs/automate/integrations#connecting-gitlab) | --- diff --git a/packages/kilo-docs/pages/collaborate/adoption-dashboard/for-team-leads.md b/packages/kilo-docs/pages/collaborate/adoption-dashboard/for-team-leads.md index 3263bd1f4a2..5bba0acd3f9 100644 --- a/packages/kilo-docs/pages/collaborate/adoption-dashboard/for-team-leads.md +++ b/packages/kilo-docs/pages/collaborate/adoption-dashboard/for-team-leads.md @@ -32,12 +32,12 @@ Use these panels to diagnose specific issues and identify targeted actions. Switch between time filters to understand different patterns: -| Filter | Best For | -| -------------- | ------------------------------------------------ | -| **Past Week** | Recent changes, sprint-level trends | +| Filter | Best For | +|---|---| +| **Past Week** | Recent changes, sprint-level trends | | **Past Month** | Adoption initiative tracking, onboarding results | -| **Past Year** | Long-term trends, seasonal patterns | -| **All** | Historical baseline, major milestones | +| **Past Year** | Long-term trends, seasonal patterns | +| **All** | Historical baseline, major milestones | --- @@ -99,12 +99,12 @@ Low Frequency suggests AI hasn't become a daily habit. Use the score tiers as milestones: -| Current Tier | Reasonable Next Goal | -| --------------- | ---------------------------- | -| 0–20 (Minimal) | Reach 30–40 within 4–6 weeks | -| 21–50 (Early) | Reach 55–65 within 4–6 weeks | +| Current Tier | Reasonable Next Goal | +|---|---| +| 0–20 (Minimal) | Reach 30–40 within 4–6 weeks | +| 21–50 (Early) | Reach 55–65 within 4–6 weeks | | 51–75 (Growing) | Reach 75–80 within 6–8 weeks | -| 76–90 (Strong) | Maintain and optimize | +| 76–90 (Strong) | Maintain and optimize | **Tip:** Focus on one dimension at a time rather than trying to improve everything at once. @@ -224,14 +224,14 @@ Additional views for comparing multiple teams within an organization are planned ## Quick Reference: Dashboard Actions -| What You Want to Know | Where to Look | -| ---------------------------- | ------------------------------------------- | -| Overall adoption level | Main score display | -| Which dimension needs work | Trend indicators (look for negative trends) | -| Specific improvement actions | Click dimension → detail panel | -| Historical patterns | Timeline chart with time filter | -| Your personal usage | Toggle "Only my usage" | -| Week-over-week change | Metric cards at bottom | +| What You Want to Know | Where to Look | +|---|---| +| Overall adoption level | Main score display | +| Which dimension needs work | Trend indicators (look for negative trends) | +| Specific improvement actions | Click dimension → detail panel | +| Historical patterns | Timeline chart with time filter | +| Your personal usage | Toggle "Only my usage" | +| Week-over-week change | Metric cards at bottom | ## Next Steps diff --git a/packages/kilo-docs/pages/collaborate/adoption-dashboard/improving-your-score.md b/packages/kilo-docs/pages/collaborate/adoption-dashboard/improving-your-score.md index 803a37d58f6..126a8adc156 100644 --- a/packages/kilo-docs/pages/collaborate/adoption-dashboard/improving-your-score.md +++ b/packages/kilo-docs/pages/collaborate/adoption-dashboard/improving-your-score.md @@ -100,12 +100,12 @@ Most teams start with Code mode and stop there. But Kilo's other modes unlock ad **Action:** Introduce your team to specialized modes: -| Mode | Use Case | -| ---------------- | -------------------------------------------------------- | +| Mode | Use Case | +|---|---| | **Orchestrator** | Delegate and execute subtasks over long-horizon projects | -| **Architect** | Design and plan before implementation | -| **Debug** | Systematic error diagnosis | -| **Ask** | Quick questions and explanations | +| **Architect** | Design and plan before implementation | +| **Debug** | Systematic error diagnosis | +| **Ask** | Quick questions and explanations | This increases efficacy and improves trust in AI-facilitated tasking. @@ -138,23 +138,23 @@ Other ways to spread usage: ### Patterns That Drive Adoption -| Pattern | Why It Works | -| -------------------------------- | -------------------------------------------------- | -| **Pair AI with existing tools** | Developers don't have to learn new workflows | -| **Start with quick wins** | Autocomplete and commit messages build confidence | -| **Champion-led adoption** | Enthusiastic team members model effective usage | -| **Weekly check-ins on AI usage** | Keeps AI top-of-mind without being prescriptive | -| **Celebrate retained code** | Recognize when AI contributions ship to production | +| Pattern | Why It Works | +|---|---| +| **Pair AI with existing tools** | Developers don't have to learn new workflows | +| **Start with quick wins** | Autocomplete and commit messages build confidence | +| **Champion-led adoption** | Enthusiastic team members model effective usage | +| **Weekly check-ins on AI usage** | Keeps AI top-of-mind without being prescriptive | +| **Celebrate retained code** | Recognize when AI contributions ship to production | ### Anti-Patterns to Avoid -| Anti-Pattern | Why It Fails | -| ----------------------------------- | --------------------------------------------- | -| **Mandating specific usage levels** | Creates resentment without changing habits | -| **Focusing only on power users** | Neglects the majority who need onboarding | -| **Ignoring context quality** | Leads to poor suggestions and abandoned usage | -| **Measuring without acting** | Scores drop when no one addresses gaps | -| **All-or-nothing adoption** | Teams need gradual, sustainable change | +| Anti-Pattern | Why It Fails | +|---|---| +| **Mandating specific usage levels** | Creates resentment without changing habits | +| **Focusing only on power users** | Neglects the majority who need onboarding | +| **Ignoring context quality** | Leads to poor suggestions and abandoned usage | +| **Measuring without acting** | Scores drop when no one addresses gaps | +| **All-or-nothing adoption** | Teams need gradual, sustainable change | --- diff --git a/packages/kilo-docs/pages/collaborate/adoption-dashboard/overview.md b/packages/kilo-docs/pages/collaborate/adoption-dashboard/overview.md index c9c8a0ee036..7168f3c524c 100644 --- a/packages/kilo-docs/pages/collaborate/adoption-dashboard/overview.md +++ b/packages/kilo-docs/pages/collaborate/adoption-dashboard/overview.md @@ -72,23 +72,23 @@ Each card displays the percentage change (e.g., "+2.3%" or "-1.5%") with a direc The AI Adoption Score is composed of three weighted dimensions: -| Dimension | Weight | Question It Answers | -| ------------- | ------ | ------------------------------------------------ | -| **Frequency** | 40% | How often do developers use AI? | -| **Depth** | 40% | How integrated is AI into actual development? | -| **Coverage** | 20% | How broadly is AI being adopted across the team? | +| Dimension | Weight | Question It Answers | +|---|---|---| +| **Frequency** | 40% | How often do developers use AI? | +| **Depth** | 40% | How integrated is AI into actual development? | +| **Coverage** | 20% | How broadly is AI being adopted across the team? | Click on any dimension card to view detailed analysis and improvement suggestions specific to that dimension. ## Quick Reference: Score Tiers -| Score Range | Tier | Description | -| ----------- | ------------------------ | ---------------------------------------- | -| 0–20 | Minimal adoption | AI usage is sporadic or experimental | -| 21–50 | Early adoption | Some developers are using AI regularly | -| 51–75 | Growing adoption | AI is becoming part of team workflows | -| 76–90 | Strong adoption | AI is deeply integrated into development | -| 91–100 | AI-first engineering org | AI is central to how the team ships code | +| Score Range | Tier | Description | +|---|---|---| +| 0–20 | Minimal adoption | AI usage is sporadic or experimental | +| 21–50 | Early adoption | Some developers are using AI regularly | +| 51–75 | Growing adoption | AI is becoming part of team workflows | +| 76–90 | Strong adoption | AI is deeply integrated into development | +| 91–100 | AI-first engineering org | AI is central to how the team ships code | ## Next Steps diff --git a/packages/kilo-docs/pages/collaborate/adoption-dashboard/understanding-your-score.md b/packages/kilo-docs/pages/collaborate/adoption-dashboard/understanding-your-score.md index dded0c6189f..8c55b341c4f 100644 --- a/packages/kilo-docs/pages/collaborate/adoption-dashboard/understanding-your-score.md +++ b/packages/kilo-docs/pages/collaborate/adoption-dashboard/understanding-your-score.md @@ -67,13 +67,13 @@ This dimension captures reach and rollout—how many team members are using AI a Your score falls into one of five tiers: -| Score Range | Tier | What It Means | -| ----------- | ------------------------ | ----------------------------------------------------------------------------------------------------------- | -| **0–20** | Minimal adoption | AI usage is sporadic or experimental. Most developers aren't using AI tools regularly. | -| **21–50** | Early adoption | Some developers have incorporated AI into their workflow, but it's not yet team-wide. | -| **51–75** | Growing adoption | AI is becoming a standard part of how the team works. Most developers use it, though depth varies. | -| **76–90** | Strong adoption | AI is deeply integrated into development workflows. Teams at this level trust and depend on AI suggestions. | -| **91–100** | AI-first engineering org | AI is central to how the team ships code. Usage is high, broad, and deeply integrated. | +| Score Range | Tier | What It Means | +|---|---|---| +| **0–20** | Minimal adoption | AI usage is sporadic or experimental. Most developers aren't using AI tools regularly. | +| **21–50** | Early adoption | Some developers have incorporated AI into their workflow, but it's not yet team-wide. | +| **51–75** | Growing adoption | AI is becoming a standard part of how the team works. Most developers use it, though depth varies. | +| **76–90** | Strong adoption | AI is deeply integrated into development workflows. Teams at this level trust and depend on AI suggestions. | +| **91–100** | AI-first engineering org | AI is central to how the team ships code. Usage is high, broad, and deeply integrated. | ## How Scores Are Calculated diff --git a/packages/kilo-docs/pages/collaborate/enterprise/audit-logs.md b/packages/kilo-docs/pages/collaborate/enterprise/audit-logs.md index 0f1f757e03a..055a9f312c6 100644 --- a/packages/kilo-docs/pages/collaborate/enterprise/audit-logs.md +++ b/packages/kilo-docs/pages/collaborate/enterprise/audit-logs.md @@ -20,11 +20,11 @@ Use filters to narrow down results by action, user, or date range. ## Filters -| Filter | Description | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Actions** | Choose one or more events to view. Options include:
- `user login` / `logout`
- `user invite`, `accept invite`, `revoke invite`
- `settings change`
- `purchase credits`
- `member remove`, `member change role`
- `sso set domain`, `sso remove domain` | -| **Actor Email** | Filter by the user who performed the action. | -| **Start / End Date** | Specify a date and time range to view logs within that period. | +| Filter | Description | +|---|---| +| **Actions** | Choose one or more events to view. Options include:
- `user login` / `logout`
- `user invite`, `accept invite`, `revoke invite`
- `settings change`
- `purchase credits`
- `member remove`, `member change role`
- `sso set domain`, `sso remove domain` | +| **Actor Email** | Filter by the user who performed the action. | +| **Start / End Date** | Specify a date and time range to view logs within that period. | Multiple filters can be used together for precise auditing. @@ -32,11 +32,11 @@ Multiple filters can be used together for precise auditing. Each event includes: -| Field | Description | -| ----------- | ------------------------------------------------------------------------------- | -| **Time** | When the action occurred (shown in your local timezone). | -| **Action** | The event type (e.g. `user.login`, `settings.change`). | -| **Actor** | The user who performed the action. | +| Field | Description | +|---|---| +| **Time** | When the action occurred (shown in your local timezone). | +| **Action** | The event type (e.g. `user.login`, `settings.change`). | +| **Actor** | The user who performed the action. | | **Details** | Context or additional data related to the event (e.g. models added or removed). | ## Logged Events diff --git a/packages/kilo-docs/pages/collaborate/enterprise/migration.md b/packages/kilo-docs/pages/collaborate/enterprise/migration.md index 3719ad05e44..da37ca12209 100644 --- a/packages/kilo-docs/pages/collaborate/enterprise/migration.md +++ b/packages/kilo-docs/pages/collaborate/enterprise/migration.md @@ -78,14 +78,14 @@ Switch to **Kilo Teams** or **Kilo Enterprise** from other AI coding tools and e ### Cursor Feature Mapping -| Cursor Feature | Kilo Equivalent | -| ---------------------- | -------------------------------------------------------------- | -| AI Chat | Chat interface with multiple modes | -| Code Generation | Code mode with advanced tools | -| Code Editing | Fast edits and surgical modifications | -| Codebase Understanding | Codebase indexing and search | -| Team Management | Comprehensive team dashboard (Enterprise adds SSO, audit logs) | -| Usage Analytics | Detailed usage and cost analytics | +| Cursor Feature | Kilo Equivalent | +|---|---| +| AI Chat | Chat interface with multiple modes | +| Code Generation | Code mode with advanced tools | +| Code Editing | Fast edits and surgical modifications | +| Codebase Understanding | Codebase indexing and search | +| Team Management | Comprehensive team dashboard (Enterprise adds SSO, audit logs) | +| Usage Analytics | Detailed usage and cost analytics | ## Migrating from GitHub Copilot @@ -131,13 +131,13 @@ Switch to **Kilo Teams** or **Kilo Enterprise** from other AI coding tools and e ### GitHub Copilot Feature Comparison -| GitHub Copilot | Kilo | Advantage | -| ---------------- | -------------------------------- | ----------------------------- | -| Code suggestions | AI-powered code generation | ✅ More model choices | -| Chat interface | Multi-mode chat system | ✅ Specialized modes | -| Team admin | Comprehensive team management | ✅ Enterprise adds audit logs | -| Usage insights | Detailed usage and cost tracking | ✅ Transparent pricing | -| Model selection | 18+ AI providers and models | ✅ No vendor lock-in | +| GitHub Copilot | Kilo | Advantage | +|---|---|---| +| Code suggestions | AI-powered code generation | ✅ More model choices | +| Chat interface | Multi-mode chat system | ✅ Specialized modes | +| Team admin | Comprehensive team management | ✅ Enterprise adds audit logs | +| Usage insights | Detailed usage and cost tracking | ✅ Transparent pricing | +| Model selection | 18+ AI providers and models | ✅ No vendor lock-in | ## Migrating from Other AI Coding Tools diff --git a/packages/kilo-docs/pages/collaborate/enterprise/model-access-controls.md b/packages/kilo-docs/pages/collaborate/enterprise/model-access-controls.md index fdf65a71dbe..427dcaaf95f 100644 --- a/packages/kilo-docs/pages/collaborate/enterprise/model-access-controls.md +++ b/packages/kilo-docs/pages/collaborate/enterprise/model-access-controls.md @@ -15,10 +15,10 @@ This means newly added models and providers are automatically available to your ## How It Works -| Scenario | Behavior | -| ---------------------- | ------------------------------------------------------------------------------------- | -| No blocks configured | All models and providers are available (default) | -| Provider blocked | All current and future models from that provider are unavailable | +| Scenario | Behavior | +|---|---| +| No blocks configured | All models and providers are available (default) | +| Provider blocked | All current and future models from that provider are unavailable | | Specific model blocked | Only that model is unavailable; other models from the same provider remain accessible | ## Managing Model Access @@ -53,13 +53,13 @@ A status bar appears at the bottom of the page whenever you have unsaved changes Use filters to find the models or providers you want to block: -| Filter | Tab | Description | -| ------------------- | ------------------ | ----------------------------------------------------- | -| **Search** | Models & Providers | Filter by name, ID, or provider slug | -| **Enabled only** | Models & Providers | Show only currently allowed items | -| **Trains on data** | Providers | Filter by whether the provider trains on user prompts | -| **Retains prompts** | Providers | Filter by whether the provider retains user prompts | -| **Location** | Providers | Filter by provider headquarters or datacenter country | +| Filter | Tab | Description | +|---|---|---| +| **Search** | Models & Providers | Filter by name, ID, or provider slug | +| **Enabled only** | Models & Providers | Show only currently allowed items | +| **Trains on data** | Providers | Filter by whether the provider trains on user prompts | +| **Retains prompts** | Providers | Filter by whether the provider retains user prompts | +| **Location** | Providers | Filter by provider headquarters or datacenter country | ## Example Use Cases diff --git a/packages/kilo-docs/pages/collaborate/teams/custom-modes-org.md b/packages/kilo-docs/pages/collaborate/teams/custom-modes-org.md index 56211e329d6..63f6a78810c 100644 --- a/packages/kilo-docs/pages/collaborate/teams/custom-modes-org.md +++ b/packages/kilo-docs/pages/collaborate/teams/custom-modes-org.md @@ -18,15 +18,15 @@ For example, Admins and Owners can extend these by creating **Custom Modes** wit 3. Optionally select a **template** (e.g. _User Story Creator_, _Project Research_, _DevOps_). 4. Fill in the following fields: -| Field | Description | -| ---------------------------------- | ---------------------------------------------------------------------------------------------------- | -| **Mode Name** | Display name for the new mode (e.g. _Security Reviewer_). | -| **Mode Slug** | A short identifier used internally (e.g. `security-reviewer`). | -| **Role Definition** | Describe Kilo's role and personality for this mode. Shapes how it reasons and responds. | -| **Short Description** | A brief summary shown in the mode selector. | -| **When to Use (optional)** | Guidance for when this mode should be used. Helps the Orchestrator choose the right mode for a task. | -| **Custom Instructions (optional)** | Add behavioral guidelines specific to this mode. | -| **Available Tools** | Select which tools this mode can access (Read, Edit, Browser, Commands, MCP). | +| Field | Description | +|---|---| +| **Mode Name** | Display name for the new mode (e.g. _Security Reviewer_). | +| **Mode Slug** | A short identifier used internally (e.g. `security-reviewer`). | +| **Role Definition** | Describe Kilo's role and personality for this mode. Shapes how it reasons and responds. | +| **Short Description** | A brief summary shown in the mode selector. | +| **When to Use (optional)** | Guidance for when this mode should be used. Helps the Orchestrator choose the right mode for a task. | +| **Custom Instructions (optional)** | Add behavioral guidelines specific to this mode. | +| **Available Tools** | Select which tools this mode can access (Read, Edit, Browser, Commands, MCP). | 5. Click **Create Mode** to save. diff --git a/packages/kilo-docs/pages/contributing/architecture/agent-observability.md b/packages/kilo-docs/pages/contributing/architecture/agent-observability.md index a92af1a98d4..c99994d9fb8 100644 --- a/packages/kilo-docs/pages/contributing/architecture/agent-observability.md +++ b/packages/kilo-docs/pages/contributing/architecture/agent-observability.md @@ -73,11 +73,11 @@ Common dashboards which offer filtering based on provider, model, and tool: Implement [multi-window, multi-burn-rate alerting](https://sre.google/workbook/alerting-on-slos/) against error budgets: -| Window | Burn Rate | Action | Use Case | -| ------ | --------- | ------ | ------------------ | -| 5 min | 14.4x | Page | Major Outage | -| 30 min | 6x | Page | Incident | -| 6 hr | 1x | Ticket | Change in behavior | +| Window | Burn Rate | Action | Use Case | +|---|---|---|---| +| 5 min | 14.4x | Page | Major Outage | +| 30 min | 6x | Page | Incident | +| 6 hr | 1x | Ticket | Change in behavior | Paging should **only occur on Recommended Models when using the Kilo Gateway**. All other alerts should be tickets, and some may be configured to be ignored. diff --git a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md index 0821d610f95..4ecc42b1de0 100644 --- a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md +++ b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md @@ -11,12 +11,12 @@ Kilo Auto is a model routing system that automatically selects the optimal AI mo Three tiers are user-facing, and one is internal: -| Tier ID | Audience | Pricing | -| -------------------- | ------------------------------ | ------- | -| `kilo-auto/frontier` | Best paid models | Paid | -| `kilo-auto/balanced` | Strong performance, lower cost | Paid | -| `kilo-auto/free` | Best available free models | Free | -| `kilo-auto/small` | Internal — background tasks | Varies | +| Tier ID | Audience | Pricing | +|---|---|---| +| `kilo-auto/frontier` | Best paid models | Paid | +| `kilo-auto/balanced` | Strong performance, lower cost | Paid | +| `kilo-auto/free` | Best available free models | Free | +| `kilo-auto/small` | Internal — background tasks | Varies | ## Problem @@ -52,7 +52,7 @@ For the current mode-to-model mappings, see the [Auto Model user docs](/docs/cod **Who it's for**: Cost-conscious developers who want better results than free models at a fraction of frontier cost. -**What it does**: Uses GPT 5.3 Codex — a cost-effective model with strong reasoning and coding capabilities — for every mode. Unlike Frontier, Balanced does not vary its underlying model by mode. +**What it does**: Routes to a cost-effective model based on the API interface used by the client. Requests using the Completions API (default) route to `qwen/qwen3.6-plus`; Responses API requests route to `openai/gpt-5.5`; Messages API requests route to `anthropic/claude-sonnet-4.6`. Unlike Frontier, Balanced does not vary its underlying model by mode. **Pricing**: Paid, but significantly cheaper than Frontier. @@ -62,7 +62,7 @@ For the current mode-to-model mappings, see the [Auto Model user docs](/docs/cod **Who it's for**: Users who want to try Kilo without a credit card, students, hobbyists, and anyone exploring AI-assisted coding. -**What it does**: Splits requests across the best available free models, weighted by a deterministic per-session hash so a given session sticks with one model. As free model availability changes due to promotional periods, the split and the underlying models are updated transparently server-side. Users always get the best free option without having to track which models are currently available. +**What it does**: Routes each session to one of the best available free models, selected deterministically based on the session (or user/IP) so a given session sticks with one model. The full candidate pool is determined server-side from curated preferred free models, and updated transparently as availability changes due to promotional periods. Users always get the best free option without having to track which models are currently available. **Pricing**: Free. No credits required. @@ -84,11 +84,11 @@ For the current mode-to-model mappings, see the [Auto Model user docs](/docs/cod The three user-facing tiers appear in the model selector: -| Display Name | Description shown to user | -| -------------- | ---------------------------------------------------- | +| Display Name | Description shown to user | +|---|---| | Auto: Frontier | Best paid models, automatically matched to your task | -| Auto: Balanced | Strong performance at lower cost | -| Auto: Free | Best free models, no credits required | +| Auto: Balanced | Strong performance at lower cost | +| Auto: Free | Best free models, no credits required | Auto: Small does not appear in the model picker. It is filtered out by the UI (see `KILO_AUTO_SMALL_IDS` in the VS Code extension). @@ -140,16 +140,16 @@ The client-side chain works as follows: ### Key files -| File | Role | -| ----------------------------------------------- | ------------------------------------------------------------------------------------- | -| `packages/kilo-gateway/src/api/constants.ts` | Default model constants (`DEFAULT_MODEL`, `DEFAULT_FREE_MODEL`) | -| `packages/kilo-gateway/src/api/models.ts` | Fetches models from Kilo API, parses `opencode.variants` | -| `packages/opencode/src/provider/model-cache.ts` | Caches Kilo Gateway models with 5-min TTL | -| `packages/opencode/src/provider/provider.ts` | Preserves variants for kilo provider; `getSmallModel()` prioritizes `kilo-auto/small` | -| `packages/opencode/src/provider/transform.ts` | Passes through server-defined variants for Kilo Gateway models | -| `packages/opencode/src/session/prompt.ts` | Resolves variant from agent config, attaches to user messages | -| `packages/opencode/src/session/llm.ts` | Merges variant options into LLM call parameters | -| `packages/opencode/src/config/config.ts` | Agent config schema includes `variant` field | +| File | Role | +|---|---| +| `packages/kilo-gateway/src/api/constants.ts` | Default model constants (`DEFAULT_MODEL`, `DEFAULT_FREE_MODEL`) | +| `packages/kilo-gateway/src/api/models.ts` | Fetches models from Kilo API, parses `opencode.variants` | +| `packages/opencode/src/provider/model-cache.ts` | Caches Kilo Gateway models with 5-min TTL | +| `packages/opencode/src/provider/provider.ts` | Preserves variants for kilo provider; `getSmallModel()` prioritizes `kilo-auto/small` | +| `packages/opencode/src/provider/transform.ts` | Passes through server-defined variants for Kilo Gateway models | +| `packages/opencode/src/session/prompt.ts` | Resolves variant from agent config, attaches to user messages | +| `packages/opencode/src/session/llm.ts` | Merges variant options into LLM call parameters | +| `packages/opencode/src/config/config.ts` | Agent config schema includes `variant` field | ## Requirements @@ -160,12 +160,12 @@ The client-side chain works as follows: ## Risks -| Risk | User impact | Mitigation | -| ------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Free model disappears mid-session | User's next message fails | Fallback chain: primary → secondary → tertiary free model. Graceful error only if all options exhausted. | -| Model quality variance across free/balanced tiers | Inconsistent experience compared to Frontier | Set clear expectations in UI. Curate model lists, don't just pick the cheapest. | -| Cross-family model switching breaks context | Thinking blocks from Model A incompatible with Model B | Strip thinking blocks when the underlying model family changes between turns. Frontier stays within one family so this primarily affects Free tier (which may switch models). | -| Users don't understand the tier differences | Wrong tier selected, poor experience | Clear descriptions in the model picker. Good defaults (Balanced for paid, Free for unpaid) so most users never need to actively choose. | +| Risk | User impact | Mitigation | +|---|---|---| +| Free model disappears mid-session | User's next message fails | Fallback chain: primary → secondary → tertiary free model. Graceful error only if all options exhausted. | +| Model quality variance across free/balanced tiers | Inconsistent experience compared to Frontier | Set clear expectations in UI. Curate model lists, don't just pick the cheapest. | +| Cross-family model switching breaks context | Thinking blocks from Model A incompatible with Model B | Strip thinking blocks when the underlying model family changes between turns. Frontier stays within one family so this primarily affects Free tier (which may switch models). | +| Users don't understand the tier differences | Wrong tier selected, poor experience | Clear descriptions in the model picker. Good defaults (Balanced for paid, Free for unpaid) so most users never need to actively choose. | ## Data and compliance diff --git a/packages/kilo-docs/pages/contributing/architecture/benchmarking.md b/packages/kilo-docs/pages/contributing/architecture/benchmarking.md index 5d637385ca1..a0548e6eb17 100644 --- a/packages/kilo-docs/pages/contributing/architecture/benchmarking.md +++ b/packages/kilo-docs/pages/contributing/architecture/benchmarking.md @@ -131,16 +131,16 @@ opik harbor run -d terminal-bench@head -a kilo -m anthropic/claude-opus-4 Opik adds value beyond what the tbench.ai dashboard provides: -| Capability | tbench.ai Dashboard | Opik | -| ----------------------------- | ------------------- | ---- | -| Task-level pass/fail | Yes | Yes | -| Aggregate leaderboard | Yes | No | -| Asciinema replay | Yes | No | -| Step-level trace view | No | Yes | -| Step-level LLM judge | No | Yes | -| Cost attribution per step | No | Yes | -| Side-by-side trace comparison | No | Yes | -| Root cause analysis | No | Yes | +| Capability | tbench.ai Dashboard | Opik | +|---|---|---| +| Task-level pass/fail | Yes | Yes | +| Aggregate leaderboard | Yes | No | +| Asciinema replay | Yes | No | +| Step-level trace view | No | Yes | +| Step-level LLM judge | No | Yes | +| Cost attribution per step | No | Yes | +| Side-by-side trace comparison | No | Yes | +| Root cause analysis | No | Yes | The two dashboards are complementary: tbench.ai for high-level leaderboard comparisons, Opik for drilling into why a specific run succeeded or failed. @@ -148,12 +148,12 @@ The two dashboards are complementary: tbench.ai for high-level leaderboard compa Harbor's registry provides access to established benchmark datasets. The choice of dataset can vary depending on what you are evaluating: -| Dataset | Focus | Use Case | -| ------------------ | -------------------------------- | -------------------------------------------------- | -| Terminal-Bench 2.0 | CLI/terminal tasks (89 tasks) | General agent capability on hard, realistic tasks | -| SWE-bench | Real GitHub issues in real repos | Software engineering task completion | -| LiveCodeBench | Competitive programming problems | Code generation quality | -| Custom task sets | Whatever you define | Targeted evaluation, marketing, regression testing | +| Dataset | Focus | Use Case | +|---|---|---| +| Terminal-Bench 2.0 | CLI/terminal tasks (89 tasks) | General agent capability on hard, realistic tasks | +| SWE-bench | Real GitHub issues in real repos | Software engineering task completion | +| LiveCodeBench | Competitive programming problems | Code generation quality | +| Custom task sets | Whatever you define | Targeted evaluation, marketing, regression testing | #### Creating Custom Task Sets @@ -277,10 +277,10 @@ opik harbor run -d kilo-refactoring@1.0 -a kilo -m anthropic/claude-opus-4 Harbor provides task-level judging (did the agent solve the task?). Opik adds step-level evaluation: -| Level | Tool | What It Tells You | -| -------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Task-level** | Harbor | Pass/fail, score, total time, total cost | -| **Step-level** | Opik | At step N, the agent chose tool X when it should have used tool Y. The reasoning was flawed because of Z. This step cost $0.03 and took 4 seconds. | +| Level | Tool | What It Tells You | +|---|---|---| +| **Task-level** | Harbor | Pass/fail, score, total time, total cost | +| **Step-level** | Opik | At step N, the agent chose tool X when it should have used tool Y. The reasoning was flawed because of Z. This step cost $0.03 and took 4 seconds. | Step-level evaluation is where root cause debugging happens. When a benchmark score drops between versions, you can trace back to the exact decision point that caused the regression. @@ -288,13 +288,13 @@ Step-level evaluation is where root cause debugging happens. When a benchmark sc This benchmarking system is complementary to, but separate from, the [Agent Observability](/docs/contributing/architecture/agent-observability) system: -| Concern | Benchmarking | Production Observability | -| --------------- | ------------------------------------- | ------------------------------------- | -| **Purpose** | Offline evaluation of agent quality | Real-time monitoring of user sessions | -| **Data source** | Controlled benchmark tasks | Real user interactions | -| **Tools** | Harbor, Opik, tbench.ai | PostHog, custom metrics | -| **When** | Before release, on-demand | Continuously in production | -| **Output** | Leaderboard scores, trace comparisons | Alerts, dashboards, SLO tracking | +| Concern | Benchmarking | Production Observability | +|---|---|---| +| **Purpose** | Offline evaluation of agent quality | Real-time monitoring of user sessions | +| **Data source** | Controlled benchmark tasks | Real user interactions | +| **Tools** | Harbor, Opik, tbench.ai | PostHog, custom metrics | +| **When** | Before release, on-demand | Continuously in production | +| **Output** | Leaderboard scores, trace comparisons | Alerts, dashboards, SLO tracking | ## References diff --git a/packages/kilo-docs/pages/contributing/architecture/features.md b/packages/kilo-docs/pages/contributing/architecture/features.md index f73bd0f4d88..2b187d17d45 100644 --- a/packages/kilo-docs/pages/contributing/architecture/features.md +++ b/packages/kilo-docs/pages/contributing/architecture/features.md @@ -7,17 +7,17 @@ description: "Overview of current and planned features in Kilo Code" These pages document the architecture and design of current or planned features, as well as any unique development patterns. -| Feature | Description | -| ---------------------------------------------------------------------------------------- | ---------------------------------------------------- | -| [Agent Observability](/docs/contributing/architecture/agent-observability) | Observability and monitoring for agentic systems | -| [Auto Model Tiers](/docs/contributing/architecture/auto-model-tiers) | Multi-tier auto model routing (Frontier, Free, Open) | -| [Benchmarking](/docs/contributing/architecture/benchmarking) | Benchmarking Kilo Code across models and agents | -| [Enterprise MCP Controls](/docs/contributing/architecture/enterprise-mcp-controls) | Admin controls for MCP server allowlists | -| [MCP OAuth Authorization](/docs/contributing/architecture/mcp-oauth-authorization) | OAuth 2.1-based authorization for MCP servers | -| [Onboarding Improvements](/docs/contributing/architecture/onboarding-improvements) | User onboarding and engagement features | -| [Organization Modes Library](/docs/contributing/architecture/organization-modes-library) | Shared modes for teams and enterprise | -| [Agentic Security Reviews](/docs/deploy-secure/security-reviews) | AI-powered security vulnerability analysis | -| [Track Repo URL](/docs/contributing/architecture/track-repo-url) | Usage tracking by repository/project | -| [Voice Transcription](/docs/contributing/architecture/voice-transcription) | Live voice input for chat | +| Feature | Description | +|---|---| +| [Agent Observability](/docs/contributing/architecture/agent-observability) | Observability and monitoring for agentic systems | +| [Auto Model Tiers](/docs/contributing/architecture/auto-model-tiers) | Multi-tier auto model routing (Frontier, Free, Open) | +| [Benchmarking](/docs/contributing/architecture/benchmarking) | Benchmarking Kilo Code across models and agents | +| [Enterprise MCP Controls](/docs/contributing/architecture/enterprise-mcp-controls) | Admin controls for MCP server allowlists | +| [MCP OAuth Authorization](/docs/contributing/architecture/mcp-oauth-authorization) | OAuth 2.1-based authorization for MCP servers | +| [Onboarding Improvements](/docs/contributing/architecture/onboarding-improvements) | User onboarding and engagement features | +| [Organization Modes Library](/docs/contributing/architecture/organization-modes-library) | Shared modes for teams and enterprise | +| [Agentic Security Reviews](/docs/deploy-secure/security-reviews) | AI-powered security vulnerability analysis | +| [Track Repo URL](/docs/contributing/architecture/track-repo-url) | Usage tracking by repository/project | +| [Voice Transcription](/docs/contributing/architecture/voice-transcription) | Live voice input for chat | To propose a new feature design, consider using the [Spec Template](/docs/contributing/architecture/feature-template). diff --git a/packages/kilo-docs/pages/contributing/architecture/index.md b/packages/kilo-docs/pages/contributing/architecture/index.md index 5f5b9aa5f26..bbc3e1fe482 100644 --- a/packages/kilo-docs/pages/contributing/architecture/index.md +++ b/packages/kilo-docs/pages/contributing/architecture/index.md @@ -62,16 +62,16 @@ The CLI can run in several modes: Key subsystems inside the CLI: -| Subsystem | Purpose | -| --------------- | ------------------------------------------------------------------------ | -| Agent Runtime | Orchestrates AI conversations, tool calls, and multi-step task execution | -| Tools Service | Built-in tools for file editing, shell execution, search, and more | -| MCP Servers | Model Context Protocol support for extending with external tools | -| LSP Client | Language Server Protocol integration for code intelligence | -| Session Manager | Persistent session state, conversation history, and checkpoints | -| Provider Router | Connects to 500+ AI models via direct APIs or Kilo Gateway | -| HTTP Server | REST API + SSE streaming for client communication | -| Config System | Project and global configuration, modes, and permissions | +| Subsystem | Purpose | +|---|---| +| Agent Runtime | Orchestrates AI conversations, tool calls, and multi-step task execution | +| Tools Service | Built-in tools for file editing, shell execution, search, and more | +| MCP Servers | Model Context Protocol support for extending with external tools | +| LSP Client | Language Server Protocol integration for code intelligence | +| Session Manager | Persistent session state, conversation history, and checkpoints | +| Provider Router | Connects to 500+ AI models via direct APIs or Kilo Gateway | +| HTTP Server | REST API + SSE streaming for client communication | +| Config System | Project and global configuration, modes, and permissions | ## Client Layer @@ -144,23 +144,23 @@ Key concepts: Agents operate in a hierarchy: -| Agent | Role | -| -------- | ------------------------------------------------------------------------------------------- | -| Mayor | Persistent conversational coordinator — decomposes tasks and delegates to worker agents | -| Polecat | Worker agent — clones repo worktrees, writes code, commits, pushes, and creates PRs | +| Agent | Role | +|---|---| +| Mayor | Persistent conversational coordinator — decomposes tasks and delegates to worker agents | +| Polecat | Worker agent — clones repo worktrees, writes code, commits, pushes, and creates PRs | | Refinery | Code review agent — reviews polecat branches, runs quality gates, merges or requests rework | -| Triage | Ephemeral agent that resolves ambiguous situations detected by automated patrol checks | +| Triage | Ephemeral agent that resolves ambiguous situations detected by automated patrol checks | A reconciler loop running every 5 seconds drives all state transitions: dispatching agents, transitioning beads, polling PR status, managing convoys, and recovering from failures. ### Supporting Services -| Service | Purpose | -| -------------------- | ------------------------------------------------------------------------------------ | +| Service | Purpose | +|---|---| | Webhook Agent Ingest | Named webhook endpoints that capture HTTP requests and queue delivery to Cloud Agent | -| AI Attribution | Tracks line-level AI-generated code attribution when users accept or reject edits | -| Session Ingest | Ingests and stores CLI session data for analytics | -| Observability | Telemetry pipelines for monitoring cloud services | +| AI Attribution | Tracks line-level AI-generated code attribution when users accept or reject edits | +| Session Ingest | Ingests and stores CLI session data for analytics | +| Observability | Telemetry pipelines for monitoring cloud services | ## Key Concepts @@ -210,22 +210,35 @@ const client = new KiloClient({ baseUrl: "http://localhost:3000" }) const session = await client.session.create({ ... }) ``` -### Namespace Module Pattern +### Module Export Pattern -The CLI uses a namespace module pattern for organizing related functionality: +The CLI uses flat ESM exports inside each module, then re-exports the module as a namespace from an index file when callers need grouped access. Avoid adding new `export namespace` declarations; top-level exports are easier to tree-shake and work better with Node's type-stripping runtime. ```typescript -export namespace Session { - export const create = fn(CreateSchema, async (input) => { - // ... - }) +// packages/opencode/src/session/session.ts +export const create = fn(CreateSchema, async (input) => { + // ... +}) - export const list = fn(ListSchema, async (input) => { - // ... - }) -} +export const list = fn(ListSchema, async (input) => { + // ... +}) + +// packages/opencode/src/session/index.ts +export * as Session from "./session" ``` +Prefer importing the specific export when possible. Use the namespace re-export (`Session.create`, `Session.list`) when a caller benefits from grouped module access or when preserving the existing public shape. + +### CLI Server API + +The CLI server is Hono-based and publishes an OpenAPI-compatible HTTP + SSE API consumed by `@kilocode/sdk`. Some route groups are being migrated behind the experimental Effect `HttpApi` bridge while preserving the generated SDK shape. + +- Keep the SDK output stable when moving routes between Hono and Effect `HttpApi`. +- Use `KILO_EXPERIMENTAL_HTTPAPI` only for migration testing; public clients should not depend on the bridge detail. +- Regenerate `packages/sdk/js/` after server endpoint changes. +- Keep request handling observable with route spans and stable attributes where possible. + ### Tool Implementation Tools follow a consistent pattern with Zod schema validation: @@ -255,10 +268,10 @@ The project uses: ## Repositories -| Repository | Contents | -| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -| [Kilo-Org/kilocode](https://github.com/Kilo-Org/kilocode) | CLI engine, VS Code extension, SDK, gateway client, telemetry, docs, UI components | -| Cloud (private) | Web dashboard, Cloud Agent, Kilo Bot, KiloClaw, Gas Town, code review, auto triage, billing, and supporting Cloudflare Workers | +| Repository | Contents | +|---|---| +| [Kilo-Org/kilocode](https://github.com/Kilo-Org/kilocode) | CLI engine, VS Code extension, SDK, gateway client, telemetry, docs, UI components | +| Cloud (private) | Web dashboard, Cloud Agent, Kilo Bot, KiloClaw, Gas Town, code review, auto triage, billing, and supporting Cloudflare Workers | ## Further Reading diff --git a/packages/kilo-docs/pages/customize/agents-md.md b/packages/kilo-docs/pages/customize/agents-md.md index a3d19b9b65a..7eccef968da 100644 --- a/packages/kilo-docs/pages/customize/agents-md.md +++ b/packages/kilo-docs/pages/customize/agents-md.md @@ -143,39 +143,39 @@ When you start a task in Kilo Code: In the new platform, AGENTS.md is loaded alongside other instruction sources. The CLI also supports `.claude/` and `.agents/` directories for compatibility with other tools. -| Source | Scope | Location | Priority | -| ------------------------------------------------ | --------- | ------------------------------------------ | ---------------- | -| **Agent prompt** | Per-agent | `agent..prompt` in config | 1 (Highest) | -| **[Instructions](/docs/customize/custom-rules)** | Project | `instructions` key in project `kilo.jsonc` | 2 | -| **AGENTS.md** | Project | `AGENTS.md` at project root | 3 | -| **[Instructions](/docs/customize/custom-rules)** | Global | `instructions` key in global `kilo.jsonc` | 4 | -| **[Skills](/docs/customize/skills)** | Both | `.kilo/skills/`, config `skills` key | Loaded on demand | +| Source | Scope | Location | Priority | +|---|---|---|---| +| **Agent prompt** | Per-agent | `agent..prompt` in config | 1 (Highest) | +| **[Instructions](/docs/customize/custom-rules)** | Project | `instructions` key in project `kilo.jsonc` | 2 | +| **AGENTS.md** | Project | `AGENTS.md` at project root | 3 | +| **[Instructions](/docs/customize/custom-rules)** | Global | `instructions` key in global `kilo.jsonc` | 4 | +| **[Skills](/docs/customize/skills)** | Both | `.kilo/skills/`, config `skills` key | Loaded on demand | {% /tab %} {% tab label="CLI" %} In the new platform, AGENTS.md is loaded alongside other instruction sources. The CLI also supports `.claude/` and `.agents/` directories for compatibility with other tools. -| Source | Scope | Location | Priority | -| ------------------------------------------------ | --------- | ------------------------------------------ | ---------------- | -| **Agent prompt** | Per-agent | `agent..prompt` in config | 1 (Highest) | -| **[Instructions](/docs/customize/custom-rules)** | Project | `instructions` key in project `kilo.jsonc` | 2 | -| **AGENTS.md** | Project | `AGENTS.md` at project root | 3 | -| **[Instructions](/docs/customize/custom-rules)** | Global | `instructions` key in global `kilo.jsonc` | 4 | -| **[Skills](/docs/customize/skills)** | Both | `.kilo/skills/`, config `skills` key | Loaded on demand | +| Source | Scope | Location | Priority | +|---|---|---|---| +| **Agent prompt** | Per-agent | `agent..prompt` in config | 1 (Highest) | +| **[Instructions](/docs/customize/custom-rules)** | Project | `instructions` key in project `kilo.jsonc` | 2 | +| **AGENTS.md** | Project | `AGENTS.md` at project root | 3 | +| **[Instructions](/docs/customize/custom-rules)** | Global | `instructions` key in global `kilo.jsonc` | 4 | +| **[Skills](/docs/customize/skills)** | Both | `.kilo/skills/`, config `skills` key | Loaded on demand | {% /tab %} {% tab label="VSCode (Legacy)" %} AGENTS.md works alongside Kilo Code's other configuration systems: -| Feature | Scope | Location | Purpose | Priority | -| -------------------------------------------------------------- | ------- | ------------------------- | ----------------------------------------- | ----------- | -| **[Mode-specific Custom Rules](/docs/customize/custom-rules)** | Project | `.kilocode/rules-{mode}/` | Mode-specific rules and constraints | 1 (Highest) | -| **[Custom Rules](/docs/customize/custom-rules)** | Project | `.kilocode/rules/` | Kilo Code-specific rules and constraints | 2 | -| **[AGENTS.md](/docs/customize/agents-md)** | Project | `AGENTS.md` | Universal standard for any AI coding tool | 3 | -| **[Global Custom Rules](/docs/customize/custom-rules)** | Global | `~/.kilocode/rules/` | Global Kilo Code rules | 4 | -| **[Custom Instructions](/docs/customize/custom-instructions)** | Global | IDE settings | Personal preferences across all projects | 5 (Lowest) | +| Feature | Scope | Location | Purpose | Priority | +|---|---|---|---|---| +| **[Mode-specific Custom Rules](/docs/customize/custom-rules)** | Project | `.kilocode/rules-{mode}/` | Mode-specific rules and constraints | 1 (Highest) | +| **[Custom Rules](/docs/customize/custom-rules)** | Project | `.kilocode/rules/` | Kilo Code-specific rules and constraints | 2 | +| **[AGENTS.md](/docs/customize/agents-md)** | Project | `AGENTS.md` | Universal standard for any AI coding tool | 3 | +| **[Global Custom Rules](/docs/customize/custom-rules)** | Global | `~/.kilocode/rules/` | Global Kilo Code rules | 4 | +| **[Custom Instructions](/docs/customize/custom-instructions)** | Global | IDE settings | Personal preferences across all projects | 5 (Lowest) | {% /tab %} {% /tabs %} diff --git a/packages/kilo-docs/pages/customize/context/codebase-indexing.md b/packages/kilo-docs/pages/customize/context/codebase-indexing.md index 3f38b01a76d..163afeaa00c 100644 --- a/packages/kilo-docs/pages/customize/context/codebase-indexing.md +++ b/packages/kilo-docs/pages/customize/context/codebase-indexing.md @@ -17,7 +17,7 @@ When enabled, the indexing system: 1. **Parses your code** using Tree-sitter to identify semantic blocks (functions, classes, methods) 2. **Creates embeddings** of each code block using AI models 3. **Stores vectors** in a Qdrant database for fast similarity search -4. **Provides the [`codebase_search`](/docs/automate/tools/codebase-search) tool** to Kilo Code for intelligent code discovery +4. **Provides the [`semantic_search`](/docs/automate/tools/semantic-search) tool** to Kilo Code for intelligent code discovery This enables natural language queries like "user authentication logic" or "database connection handling" to find relevant code across your entire project. @@ -202,7 +202,7 @@ If your local embedding server is based on llama.cpp (including Ollama), indexin ## Using the Search Feature -Once indexed, Kilo Code can use the [`codebase_search`](/docs/automate/tools/codebase-search) tool to find relevant code: +Once indexed, Kilo Code can use the [`semantic_search`](/docs/automate/tools/semantic-search) tool to find relevant code: **Example Queries:** diff --git a/packages/kilo-docs/pages/customize/context/context-condensing.md b/packages/kilo-docs/pages/customize/context/context-condensing.md index d9666889c31..9993b4bcca3 100644 --- a/packages/kilo-docs/pages/customize/context/context-condensing.md +++ b/packages/kilo-docs/pages/customize/context/context-condensing.md @@ -22,15 +22,15 @@ Every AI model has a maximum context window — a limit on how much text it can ## The Solution: Auto-Compaction -Kilo Code uses a **Compaction** system to manage context automatically. When your conversation approaches the token limit, compaction kicks in and produces a structured summary that captures: +Kilo Code uses a **Compaction** system to manage context automatically. When your conversation approaches the token limit, compaction kicks in and produces an anchored summary that captures: - The overall goal of the session -- Instructions given along the way -- Key discoveries made -- What has been accomplished so far +- Constraints and preferences you gave along the way +- Progress, key decisions, and next steps +- Critical context needed to continue - Relevant files and directories -This summary replaces the earlier conversation history, freeing up context window space while maintaining continuity in your work. +This summary replaces older conversation history while Kilo keeps the most recent turns verbatim when they fit. If a session has already been compacted, Kilo updates the previous summary instead of starting over, preserving still-relevant details and removing stale ones. ## How Compaction Triggers @@ -56,10 +56,12 @@ You can trigger compaction at any time: ## Defaults -| Setting | Default | Effect | -| --------------------- | -------------------------------------- | -------------------------------------------------------------------------------------- | -| `compaction.auto` | `true` | Automatically compact when the usable window is reached | -| `compaction.prune` | `true` | Clear old tool outputs beyond the 40K recency window | +| Setting | Default | Effect | +|---|---|---| +| `compaction.auto` | `true` | Automatically compact when the usable window is reached | +| `compaction.prune` | `true` | Clear old tool outputs beyond the 40K recency window | +| `compaction.tail_turns` | `2` | Keep the most recent user turns and their responses verbatim when possible | +| `compaction.preserve_recent_tokens` | 25% of usable context, clamped between 2,000 and 8,000 tokens | Token budget for the verbatim recent tail | | `compaction.reserved` | `min(20,000, model_max_output_tokens)` | Token headroom kept free for the next turn — also defines the compaction trigger point | ## Configuration @@ -71,16 +73,20 @@ Compaction is configured in your `kilo.jsonc` file: "compaction": { "auto": true, // Enable or disable automatic compaction "prune": true, // Enable pruning of old tool outputs beyond the recency window + "tail_turns": 2, // Recent user turns to keep verbatim during compaction + "preserve_recent_tokens": 8000, // Maximum token budget for the recent tail "reserved": 20000, // Token buffer kept free; smaller = later trigger, larger = earlier trigger }, } ``` -| Option | Type | Default | Description | -| --------------------- | ------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `compaction.auto` | boolean | `true` | Enable or disable automatic compaction when the usable window is reached | -| `compaction.prune` | boolean | `true` | Enable pruning of old tool outputs outside the 40K token recency window | -| `compaction.reserved` | number | `min(20000, model_max_output)` | Token headroom reserved for the next turn. Applies only to models that advertise a separate input limit; models with a single context window use their full output cap as the reserve instead. | +| Option | Type | Default | Description | +|---|---|---|---| +| `compaction.auto` | boolean | `true` | Enable or disable automatic compaction when the usable window is reached | +| `compaction.prune` | boolean | `true` | Enable pruning of old tool outputs outside the 40K token recency window | +| `compaction.tail_turns` | number | `2` | Number of recent user turns, including following assistant and tool responses, to keep verbatim during compaction | +| `compaction.preserve_recent_tokens` | number | 25% of usable context, clamped between 2,000 and 8,000 tokens | Maximum token budget for recent turns kept verbatim after compaction | +| `compaction.reserved` | number | `min(20000, model_max_output)` | Token headroom reserved for the next turn. Applies only to models that advertise a separate input limit; models with a single context window use their full output cap as the reserve instead. | ### Use a different model for compaction @@ -100,10 +106,10 @@ If no compaction agent is set, the current session's model is used. ### Environment overrides -| Variable | Effect | -| ------------------------------------ | ------------------------------------------------- | -| `KILO_DISABLE_AUTOCOMPACT=1` | Forces `compaction.auto = false` | -| `KILO_DISABLE_PRUNE=1` | Forces `compaction.prune = false` | +| Variable | Effect | +|---|---| +| `KILO_DISABLE_AUTOCOMPACT=1` | Forces `compaction.auto = false` | +| `KILO_DISABLE_PRUNE=1` | Forces `compaction.prune = false` | | `KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX` | Overrides the 32,000 default output-token ceiling | {% /tab %} @@ -111,15 +117,15 @@ If no compaction agent is set, the current session's model is used. ## The Solution: Auto-Compaction -Kilo CLI uses a **Compaction** system to manage context automatically. When your conversation approaches the token limit, compaction kicks in and produces a structured summary that captures: +Kilo CLI uses a **Compaction** system to manage context automatically. When your conversation approaches the token limit, compaction kicks in and produces an anchored summary that captures: - The overall goal of the session -- Instructions given along the way -- Key discoveries made -- What has been accomplished so far +- Constraints and preferences you gave along the way +- Progress, key decisions, and next steps +- Critical context needed to continue - Relevant files and directories -This summary replaces the earlier conversation history, freeing up context window space while maintaining continuity in your work. +This summary replaces older conversation history while Kilo keeps the most recent turns verbatim when they fit. If a session has already been compacted, Kilo updates the previous summary instead of starting over, preserving still-relevant details and removing stale ones. ## How Compaction Triggers @@ -144,10 +150,12 @@ You can trigger compaction at any time: ## Defaults -| Setting | Default | Effect | -| --------------------- | -------------------------------------- | -------------------------------------------------------------------------------------- | -| `compaction.auto` | `true` | Automatically compact when the usable window is reached | -| `compaction.prune` | `true` | Clear old tool outputs beyond the 40K recency window | +| Setting | Default | Effect | +|---|---|---| +| `compaction.auto` | `true` | Automatically compact when the usable window is reached | +| `compaction.prune` | `true` | Clear old tool outputs beyond the 40K recency window | +| `compaction.tail_turns` | `2` | Keep the most recent user turns and their responses verbatim when possible | +| `compaction.preserve_recent_tokens` | 25% of usable context, clamped between 2,000 and 8,000 tokens | Token budget for the verbatim recent tail | | `compaction.reserved` | `min(20,000, model_max_output_tokens)` | Token headroom kept free for the next turn — also defines the compaction trigger point | ## Configuration @@ -159,16 +167,20 @@ Compaction is configured in your `kilo.jsonc` file: "compaction": { "auto": true, // Enable or disable automatic compaction "prune": true, // Enable pruning of old tool outputs beyond the recency window + "tail_turns": 2, // Recent user turns to keep verbatim during compaction + "preserve_recent_tokens": 8000, // Maximum token budget for the recent tail "reserved": 20000, // Token buffer kept free; smaller = later trigger, larger = earlier trigger }, } ``` -| Option | Type | Default | Description | -| --------------------- | ------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `compaction.auto` | boolean | `true` | Enable or disable automatic compaction when the usable window is reached | -| `compaction.prune` | boolean | `true` | Enable pruning of old tool outputs outside the 40K token recency window | -| `compaction.reserved` | number | `min(20000, model_max_output)` | Token headroom reserved for the next turn. Applies only to models that advertise a separate input limit; models with a single context window use their full output cap as the reserve instead. | +| Option | Type | Default | Description | +|---|---|---|---| +| `compaction.auto` | boolean | `true` | Enable or disable automatic compaction when the usable window is reached | +| `compaction.prune` | boolean | `true` | Enable pruning of old tool outputs outside the 40K token recency window | +| `compaction.tail_turns` | number | `2` | Number of recent user turns, including following assistant and tool responses, to keep verbatim during compaction | +| `compaction.preserve_recent_tokens` | number | 25% of usable context, clamped between 2,000 and 8,000 tokens | Maximum token budget for recent turns kept verbatim after compaction | +| `compaction.reserved` | number | `min(20000, model_max_output)` | Token headroom reserved for the next turn. Applies only to models that advertise a separate input limit; models with a single context window use their full output cap as the reserve instead. | ### Use a different model for compaction @@ -188,10 +200,10 @@ If no compaction agent is set, the current session's model is used. ### Environment overrides -| Variable | Effect | -| ------------------------------------ | ------------------------------------------------- | -| `KILO_DISABLE_AUTOCOMPACT=1` | Forces `compaction.auto = false` | -| `KILO_DISABLE_PRUNE=1` | Forces `compaction.prune = false` | +| Variable | Effect | +|---|---| +| `KILO_DISABLE_AUTOCOMPACT=1` | Forces `compaction.auto = false` | +| `KILO_DISABLE_PRUNE=1` | Forces `compaction.prune = false` | | `KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX` | Overrides the 32,000 default output-token ceiling | {% /tab %} diff --git a/packages/kilo-docs/pages/customize/context/large-projects.md b/packages/kilo-docs/pages/customize/context/large-projects.md index 101fb1621b6..cfa129e40b1 100644 --- a/packages/kilo-docs/pages/customize/context/large-projects.md +++ b/packages/kilo-docs/pages/customize/context/large-projects.md @@ -31,7 +31,7 @@ The context window includes: 5. **Prioritize Recent History:** Kilo Code automatically truncates older messages in the conversation history to stay within the context window. Be mindful of this, and re-include important context if needed. -6. **Use Prompt Caching (if available):** Some API providers like Anthropic, OpenAI, OpenRouter and Requesty support "prompt caching". This caches your prompts for use in future tasks and helps reduce the cost and latency of requests. +6. **Use Prompt Caching (if available):** Some API providers like Anthropic, OpenAI, OpenRouter, Requesty, and Vertex AI Claude support "prompt caching". This caches your prompts for use in future tasks and helps reduce the cost and latency of requests. ## Example: Refactoring a Large File diff --git a/packages/kilo-docs/pages/customize/custom-modes.md b/packages/kilo-docs/pages/customize/custom-modes.md index f5a2089503e..928a4feda93 100644 --- a/packages/kilo-docs/pages/customize/custom-modes.md +++ b/packages/kilo-docs/pages/customize/custom-modes.md @@ -25,20 +25,20 @@ In the VSCode extension and CLI, custom behavioral profiles are called **agents* ## What's Included in a Custom Agent? -| Property | Description | -| --------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| **name** (filename) | The agent's identifier, derived from the `.md` filename (e.g., `docs-writer.md` creates an agent named `docs-writer`) | -| **description** | A short summary displayed in the agent picker and used by the orchestrator for delegation | -| **model** | Pin a specific model in `provider/model` format (e.g., `anthropic/claude-sonnet-4-20250514`) | -| **prompt** (markdown body) | The system prompt text — the markdown body of the file, injected into the agent's system prompt | -| **mode** | Role classification: `primary` (user-selectable), `subagent` (only invoked by other agents), or `all` (both) | -| **permission** | Per-agent permission overrides controlling which tools the agent can use (e.g., deny `edit`, `bash`) | -| **color** | Hex color (`#FF5733`) or theme keyword (`primary`, `accent`, `warning`, etc.) for the agent picker UI | -| **steps** | Maximum agentic iterations before forcing a text-only response | -| **temperature** / **top_p** | Sampling parameters for the agent's model | -| **variant** | Default model variant | -| **hidden** | If `true`, the agent is hidden from the UI (only meaningful for subagents) | -| **disable** | If `true`, removes the agent entirely | +| Property | Description | +|---|---| +| **name** (filename) | The agent's identifier, derived from the `.md` filename (e.g., `docs-writer.md` creates an agent named `docs-writer`) | +| **description** | A short summary displayed in the agent picker and used by the orchestrator for delegation | +| **model** | Pin a specific model in `provider/model` format (e.g., `anthropic/claude-sonnet-4-20250514`) | +| **prompt** (markdown body) | The system prompt text — the markdown body of the file, injected into the agent's system prompt | +| **mode** | Role classification: `primary` (user-selectable), `subagent` (only invoked by other agents), or `all` (both) | +| **permission** | Per-agent permission overrides controlling which tools the agent can use (e.g., deny `edit`, `bash`) | +| **color** | Hex color (`#FF5733`) or theme keyword (`primary`, `accent`, `warning`, etc.) for the agent picker UI | +| **steps** | Maximum agentic iterations before forcing a text-only response | +| **temperature** / **top_p** | Sampling parameters for the agent's model | +| **variant** | Default model variant | +| **hidden** | If `true`, the agent is hidden from the UI (only meaningful for subagents) | +| **disable** | If `true`, removes the agent entirely | ## Methods for Creating and Configuring Agents @@ -132,11 +132,11 @@ Define agents under the `agent` key in your project's `kilo.jsonc`: Controls where the agent appears: -| Value | Behavior | -| ---------- | -------------------------------------------------------------------------------------- | -| `primary` | Shown in the agent picker — the user can select it directly | -| `subagent` | Only invokable by other agents via the `task` tool | -| `all` | Available both as a top-level pick and as a subagent (default for user-defined agents) | +| Value | Behavior | +|---|---| +| `primary` | Shown in the agent picker — the user can select it directly | +| `subagent` | Only invokable by other agents via the `task` tool | +| `all` | Available both as a top-level pick and as a subagent (default for user-defined agents) | ### `permission` @@ -151,7 +151,7 @@ permission: read: allow ``` -Known permission types include: `read`, `edit`, `bash`, `glob`, `grep`, `list`, `task`, `webfetch`, `websearch`, `codesearch`, `todowrite`, `todoread`, and more. +Known permission types include: `read`, `edit`, `bash`, `glob`, `grep`, `task`, `webfetch`, `websearch`, `codesearch`, `todowrite`, `todoread`, and more. ### `model` @@ -232,27 +232,43 @@ If you have existing `.kilocodemodes` or `custom_modes.yaml` files from the VSCo Default legacy mode slugs (`code`, `build`, `architect`, `ask`, `debug`, `orchestrator`) are skipped during migration since they map to built-in agents (`build` → `code`, `architect` → `plan`). +### Legacy File Locations + +The current VSCode extension reads the legacy `custom_modes.yaml` file from its own global storage directory. Helpful for inspecting or fixing the file before the one-time migration runs: + +| OS | Path | +|---|---| +| macOS | `~/Library/Application Support/Code/User/globalStorage/kilocode.kilo-code/settings/custom_modes.yaml` | +| Linux | `~/.config/Code/User/globalStorage/kilocode.kilo-code/settings/custom_modes.yaml` | +| Windows | `%APPDATA%\Code\User\globalStorage\kilocode.kilo-code\settings\custom_modes.yaml` | + +Project-level `.kilocodemodes` and workspace-scoped files are handled by the CLI backend that the extension delegates to — see the [CLI tab](#cli) for the full load-order table. After the extension migrates on startup, the legacy file is no longer consulted; remove new modes through the extension UI instead of editing `custom_modes.yaml` directly. + {% /tab %} {% tab label="CLI" %} In the CLI, custom behavioral profiles are called **agents** instead of modes. Agents are defined as Markdown files with YAML frontmatter or as entries in the `agent` key of your config file. +{% callout type="warning" %} +**Legacy `custom_modes.yaml` is not loaded from `~/.config/kilo/`.** If you're migrating from the legacy VSCode extension, global custom modes are read from `~/.kilocode/cli/global/settings/custom_modes.yaml` (not from the CLI's XDG config directory). The recommended approach is to convert legacy modes to agent `.md` files and place them in `~/.config/kilo/agent/` instead — see [Markdown files](#3-markdown-files-with-yaml-frontmatter) and [Migration](#migration-from-vscode-extension-modes) below. +{% /callout %} + ## What's Included in a Custom Agent? -| Property | Description | -| --------------------------- | --------------------------------------------------------------------------------------------------------------------- | -| **name** (filename) | The agent's identifier, derived from the `.md` filename (e.g., `docs-writer.md` creates an agent named `docs-writer`) | -| **description** | A short summary displayed in the agent picker and used by the orchestrator for delegation | -| **model** | Pin a specific model in `provider/model` format (e.g., `anthropic/claude-sonnet-4-20250514`) | -| **prompt** (markdown body) | The system prompt text — the markdown body of the file, injected into the agent's system prompt | -| **mode** | Role classification: `primary` (user-selectable), `subagent` (only invoked by other agents), or `all` (both) | -| **permission** | Per-agent permission overrides controlling which tools the agent can use (e.g., deny `edit`, `bash`) | -| **color** | Hex color (`#FF5733`) or theme keyword (`primary`, `accent`, `warning`, etc.) for the agent picker UI | -| **steps** | Maximum agentic iterations before forcing a text-only response | -| **temperature** / **top_p** | Sampling parameters for the agent's model | -| **variant** | Default model variant | -| **hidden** | If `true`, the agent is hidden from the UI (only meaningful for subagents) | -| **disable** | If `true`, removes the agent entirely | +| Property | Description | +|---|---| +| **name** (filename) | The agent's identifier, derived from the `.md` filename (e.g., `docs-writer.md` creates an agent named `docs-writer`) | +| **description** | A short summary displayed in the agent picker and used by the orchestrator for delegation | +| **model** | Pin a specific model in `provider/model` format (e.g., `anthropic/claude-sonnet-4-20250514`) | +| **prompt** (markdown body) | The system prompt text — the markdown body of the file, injected into the agent's system prompt | +| **mode** | Role classification: `primary` (user-selectable), `subagent` (only invoked by other agents), or `all` (both) | +| **permission** | Per-agent permission overrides controlling which tools the agent can use (e.g., deny `edit`, `bash`) | +| **color** | Hex color (`#FF5733`) or theme keyword (`primary`, `accent`, `warning`, etc.) for the agent picker UI | +| **steps** | Maximum agentic iterations before forcing a text-only response | +| **temperature** / **top_p** | Sampling parameters for the agent's model | +| **variant** | Default model variant | +| **hidden** | If `true`, the agent is hidden from the UI (only meaningful for subagents) | +| **disable** | If `true`, removes the agent entirely | ## Methods for Creating and Configuring Agents @@ -352,11 +368,11 @@ Define agents under the `agent` key in your project's `kilo.jsonc`: Controls where the agent appears: -| Value | Behavior | -| ---------- | -------------------------------------------------------------------------------------- | -| `primary` | Shown in the agent picker — the user can select it directly | -| `subagent` | Only invokable by other agents via the `task` tool | -| `all` | Available both as a top-level pick and as a subagent (default for user-defined agents) | +| Value | Behavior | +|---|---| +| `primary` | Shown in the agent picker — the user can select it directly | +| `subagent` | Only invokable by other agents via the `task` tool | +| `all` | Available both as a top-level pick and as a subagent (default for user-defined agents) | ### `permission` @@ -371,7 +387,7 @@ permission: read: allow ``` -Known permission types include: `read`, `edit`, `bash`, `glob`, `grep`, `list`, `task`, `webfetch`, `websearch`, `codesearch`, `todowrite`, `todoread`, and more. +Known permission types include: `read`, `edit`, `bash`, `glob`, `grep`, `task`, `webfetch`, `websearch`, `codesearch`, `todowrite`, `todoread`, and more. ### `model` @@ -452,6 +468,21 @@ If you have existing `.kilocodemodes` or `custom_modes.yaml` files from the VSCo Default legacy mode slugs (`code`, `build`, `architect`, `ask`, `debug`, `orchestrator`) are skipped during migration since they map to built-in agents (`build` → `code`, `architect` → `plan`). +### Legacy File Lookup Paths + +The CLI reads legacy mode files from the following locations (in load order). When the same slug appears in multiple sources, the **last loaded source wins**: + +| Load Order | Path | Format | Scope | +|---|---|---|---| +| 1 | VSCode extension global storage `/settings/custom_modes.yaml` | YAML | Global | +| 2 | `~/.kilocode/cli/global/settings/custom_modes.yaml` | YAML | Global | +| 3 | `~/.kilocodemodes` | YAML | Global | +| 4 | `/.kilocodemodes` | YAML | Project (wins on conflict) | + +{% callout type="info" %} +`~/.config/kilo/` is the XDG config directory for the new agent format — legacy `custom_modes.yaml` placed there will **not** be loaded. Use `~/.config/kilo/agent/*.md` or `~/.config/kilo/kilo.jsonc` for new agent definitions instead. +{% /callout %} + {% /tab %} {% tab label="VSCode (Legacy)" %} @@ -471,15 +502,15 @@ _Kilo Code's interface for creating and managing custom modes._ Custom modes are defined by several key properties. Understanding these concepts will help you tailor Kilo's behavior effectively. -| UI Field / YAML Property | Conceptual Description | -| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Slug** (`slug`) | A unique internal identifier for the mode. Used by Kilo Code to reference the mode, especially for associating mode-specific instruction files. | -| **Name** (`name`) | The display name for the mode as it appears in the Kilo Code user interface. Should be human-readable and descriptive. | -| **Description** (`description`) | A short, user-friendly summary of the mode's purpose displayed in the mode selector UI. Keep this concise and focused on what the mode does for the user. | -| **Role Definition** (`roleDefinition`) | Defines the core identity and expertise of the mode. This text is placed at the beginning of the system prompt and defines Kilo's personality and behavior when this mode is active. | -| **Available Tools** (`groups`) | Defines the allowed toolsets and file access permissions for the mode. Corresponds to selecting which general categories of tools the mode can use. | -| **When to Use** (`whenToUse`) | _(Optional)_ Provides guidance for Kilo's automated decision-making, particularly for mode selection and task orchestration. Used by the Orchestrator mode for task coordination. | -| **Custom Instructions** (`customInstructions`) | _(Optional)_ Specific behavioral guidelines or rules for the mode. Added near the end of the system prompt to further refine Kilo's behavior. | +| UI Field / YAML Property | Conceptual Description | +|---|---| +| **Slug** (`slug`) | A unique internal identifier for the mode. Used by Kilo Code to reference the mode, especially for associating mode-specific instruction files. | +| **Name** (`name`) | The display name for the mode as it appears in the Kilo Code user interface. Should be human-readable and descriptive. | +| **Description** (`description`) | A short, user-friendly summary of the mode's purpose displayed in the mode selector UI. Keep this concise and focused on what the mode does for the user. | +| **Role Definition** (`roleDefinition`) | Defines the core identity and expertise of the mode. This text is placed at the beginning of the system prompt and defines Kilo's personality and behavior when this mode is active. | +| **Available Tools** (`groups`) | Defines the allowed toolsets and file access permissions for the mode. Corresponds to selecting which general categories of tools the mode can use. | +| **When to Use** (`whenToUse`) | _(Optional)_ Provides guidance for Kilo's automated decision-making, particularly for mode selection and task orchestration. Used by the Orchestrator mode for task coordination. | +| **Custom Instructions** (`customInstructions`) | _(Optional)_ Specific behavioral guidelines or rules for the mode. Added near the end of the system prompt to further refine Kilo's behavior. | {% callout type="tip" %} **Power Steering for Better Mode Adherence** @@ -553,14 +584,14 @@ Focus on clarity, completeness, and consistent formatting. **YAML frontmatter fields:** -| Field | Description | -| ------------- | ----------------------------------------------------------------------------- | -| `model` | Override the default model for this agent | -| `description` | Short description shown in the agent selector | -| `mode` | `"primary"` (user-selectable), `"subagent"` (invoked by AI only), or `"all"` | -| `permission` | Tool permission overrides (same format as the global `permission` config key) | -| `temperature` | Model temperature override | -| `top_p` | Model top_p override | +| Field | Description | +|---|---| +| `model` | Override the default model for this agent | +| `description` | Short description shown in the agent selector | +| `mode` | `"primary"` (user-selectable), `"subagent"` (invoked by AI only), or `"all"` | +| `permission` | Tool permission overrides (same format as the global `permission` config key) | +| `temperature` | Model temperature override | +| `top_p` | Model top_p override | The filename (without `.md`) becomes the agent's slug and display name. @@ -943,13 +974,13 @@ Kilo will generate the pattern. Remember to adapt it for YAML (usually single ba ### Common Pattern Examples -| Pattern (YAML-like) | JSON fileRegex Value | Matches | Doesn't Match | -| -------------------------------- | ----------------------------------- | ----------------------------------------- | ---------------------------------- | -| `\.md$` | `"\\.md$"` | `readme.md`, `docs/guide.md` | `script.js`, `readme.md.bak` | -| `^src/.*` | `"^src/.*"` | `src/app.js`, `src/components/button.tsx` | `lib/utils.js`, `test/src/mock.js` | -| `\.(css\|scss)$` | `"\\.(css\|scss)$"` | `styles.css`, `theme.scss` | `styles.less`, `styles.css.map` | -| `docs/.*\.md$` | `"docs/.*\\.md$"` | `docs/guide.md`, `docs/api/reference.md` | `guide.md`, `src/docs/notes.md` | -| `^(?!.*(test\|spec))\.(js\|ts)$` | `"^(?!.*(test\|spec))\\.(js\|ts)$"` | `app.js`, `utils.ts` | `app.test.js`, `utils.spec.js` | +| Pattern (YAML-like) | JSON fileRegex Value | Matches | Doesn't Match | +|---|---|---|---| +| `\.md$` | `"\\.md$"` | `readme.md`, `docs/guide.md` | `script.js`, `readme.md.bak` | +| `^src/.*` | `"^src/.*"` | `src/app.js`, `src/components/button.tsx` | `lib/utils.js`, `test/src/mock.js` | +| `\.(css\|scss)$` | `"\\.(css\|scss)$"` | `styles.css`, `theme.scss` | `styles.less`, `styles.css.map` | +| `docs/.*\.md$` | `"docs/.*\\.md$"` | `docs/guide.md`, `docs/api/reference.md` | `guide.md`, `src/docs/notes.md` | +| `^(?!.*(test\|spec))\.(js\|ts)$` | `"^(?!.*(test\|spec))\\.(js\|ts)$"` | `app.js`, `utils.ts` | `app.test.js`, `utils.spec.js` | ### Key Regex Building Blocks diff --git a/packages/kilo-docs/pages/customize/custom-subagents.md b/packages/kilo-docs/pages/customize/custom-subagents.md index ed4b3340a40..c1164c6701b 100644 --- a/packages/kilo-docs/pages/customize/custom-subagents.md +++ b/packages/kilo-docs/pages/customize/custom-subagents.md @@ -27,20 +27,20 @@ Key characteristics of subagents: Kilo Code includes two built-in subagents: -| Name | Description | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **general** | General-purpose agent for researching complex questions and executing multi-step tasks. Has full tool access (except todo). | +| Name | Description | +|---|---| +| **general** | General-purpose agent for researching complex questions and executing multi-step tasks. Has full tool access (except todo). | | **explore** | Fast, read-only agent for codebase exploration. Cannot modify files. Use for finding files by patterns, searching code, or answering questions about the codebase. | ## Agent Modes Every agent has a **mode** that determines how it can be used: -| Mode | Description | -| ---------- | ------------------------------------------------------------------------------------------- | -| `primary` | User-facing agents you interact with directly. Switch between them with **Tab**. | -| `subagent` | Only invocable via the Task tool or `@` mentions. Not available as a primary agent. | -| `all` | Can function as both a primary agent and a subagent. This is the default for custom agents. | +| Mode | Description | +|---|---| +| `primary` | User-facing agents you interact with directly. Switch between them with **Tab**. | +| `subagent` | Only invocable via the Task tool or `@` mentions. Not available as a primary agent. | +| `all` | Can function as both a primary agent and a subagent. This is the default for custom agents. | ## Configuring Custom Subagents @@ -149,19 +149,19 @@ kilo agent create \ The following options are available when configuring a subagent: -| Option | Type | Description | -| ------------- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `description` | `string` | What the agent does and when to use it. Shown to primary agents to help them decide which subagent to invoke. | -| `mode` | `"subagent" \| "primary" \| "all"` | How the agent can be used. Defaults to `all` for custom agents. | -| `model` | `string` | Override the model for this agent (format: `provider/model-id`). If not set, subagents inherit the model of the invoking primary agent. | -| `prompt` | `string` | Custom system prompt. In JSON, can use `{file:./path}` syntax. In markdown, the body is the prompt. | -| `temperature` | `number` | Controls response randomness (0.0-1.0). Lower = more deterministic. | -| `top_p` | `number` | Alternative to temperature for controlling response diversity (0.0-1.0). | -| `permission` | `object` | Controls tool access. See [Permissions](#permissions) below. | -| `hidden` | `boolean` | If `true`, hides the subagent from the `@` autocomplete menu. It can still be invoked by agents via the Task tool. Only applies to `mode: subagent`. | -| `steps` | `number` | Maximum agentic iterations before forcing a text-only response. Useful for cost control. | -| `color` | `string` | Visual color in the UI. Accepts hex (`#FF5733`) or theme names (`primary`, `accent`, `error`, etc.). | -| `disable` | `boolean` | Set to `true` to disable the agent entirely. | +| Option | Type | Description | +|---|---|---| +| `description` | `string` | What the agent does and when to use it. Shown to primary agents to help them decide which subagent to invoke. | +| `mode` | `"subagent" \| "primary" \| "all"` | How the agent can be used. Defaults to `all` for custom agents. | +| `model` | `string` | Override the model for this agent (format: `provider/model-id`). If not set, subagents inherit the model of the invoking primary agent. | +| `prompt` | `string` | Custom system prompt. In JSON, can use `{file:./path}` syntax. In markdown, the body is the prompt. | +| `temperature` | `number` | Controls response randomness (0.0-1.0). Lower = more deterministic. | +| `top_p` | `number` | Alternative to temperature for controlling response diversity (0.0-1.0). | +| `permission` | `object` | Controls tool access. See [Permissions](#permissions) below. | +| `hidden` | `boolean` | If `true`, hides the subagent from the `@` autocomplete menu. It can still be invoked by agents via the Task tool. Only applies to `mode: subagent`. | +| `steps` | `number` | Maximum agentic iterations before forcing a text-only response. Useful for cost control. | +| `color` | `string` | Visual color in the UI. Accepts hex (`#FF5733`) or theme names (`primary`, `accent`, `error`, etc.). | +| `disable` | `boolean` | Set to `true` to disable the agent entirely. | Any additional options not listed above are passed through to the model provider, allowing you to use provider-specific parameters like `reasoningEffort` for OpenAI models. diff --git a/packages/kilo-docs/pages/customize/skills.md b/packages/kilo-docs/pages/customize/skills.md index 3f7c85dc107..99dbe4dabe1 100644 --- a/packages/kilo-docs/pages/customize/skills.md +++ b/packages/kilo-docs/pages/customize/skills.md @@ -326,13 +326,13 @@ You can include examples, guidelines, code snippets, etc. Per the [Agent Skills specification](https://agentskills.io/specification): -| Field | Required | Description | -| --------------- | -------- | ----------------------------------------------------------------------------------------------------- | -| `name` | Yes | Max 64 characters. Lowercase letters, numbers, and hyphens only. Must not start or end with a hyphen. | -| `description` | Yes | Max 1024 characters. Describes what the skill does and when to use it. | -| `license` | No | License name or reference to a bundled license file | -| `compatibility` | No | Environment requirements (intended product, system packages, network access, etc.) | -| `metadata` | No | Arbitrary key-value mapping for additional metadata | +| Field | Required | Description | +|---|---|---| +| `name` | Yes | Max 64 characters. Lowercase letters, numbers, and hyphens only. Must not start or end with a hyphen. | +| `description` | Yes | Max 1024 characters. Describes what the skill does and when to use it. | +| `license` | No | License name or reference to a bundled license file | +| `compatibility` | No | Environment requirements (intended product, system packages, network access, etc.) | +| `metadata` | No | Arbitrary key-value mapping for additional metadata | ### Example with Optional Fields @@ -573,11 +573,11 @@ There's currently no dedicated UI indicator showing "Skill X was activated." The ### Common Errors -| Error | Cause | Solution | -| ------------------------------- | -------------------------------------------- | ------------------------------------------------ | -| "missing required 'name' field" | No `name` in frontmatter | Add `name: your-skill-name` | -| "name doesn't match directory" | Mismatch between frontmatter and folder name | Make `name` match exactly | -| Skill not appearing | Wrong directory structure | Verify path follows `skills/skill-name/SKILL.md` | +| Error | Cause | Solution | +|---|---|---| +| "missing required 'name' field" | No `name` in frontmatter | Add `name: your-skill-name` | +| "name doesn't match directory" | Mismatch between frontmatter and folder name | Make `name` match exactly | +| Skill not appearing | Wrong directory structure | Verify path follows `skills/skill-name/SKILL.md` | ## Contributing to the Marketplace diff --git a/packages/kilo-docs/pages/customize/workflows.md b/packages/kilo-docs/pages/customize/workflows.md index 50ea192b3b6..80e5f538232 100644 --- a/packages/kilo-docs/pages/customize/workflows.md +++ b/packages/kilo-docs/pages/customize/workflows.md @@ -8,7 +8,7 @@ platform: new Workflows (also called **slash commands** in the new extension) automate repetitive tasks by defining step-by-step instructions for Kilo Code to execute. -{% image src="/docs/img/slash-commands/workflows.png" alt="Workflows tab in Kilo Code" width="600" caption="Workflows tab in Kilo Code" /%} +{% image src="/docs/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-workflows-chromium-linux.png" alt="Workflows tab in Kilo Code" width="420" caption="Workflows tab in Kilo Code" /%} ## Creating Workflows @@ -41,12 +41,12 @@ agent: code You are helping submit a pull request... ``` -| Field | Description | -| ------------- | --------------------------------------------- | -| `description` | Shown in the command picker | -| `agent` | Which agent to use when invoking this command | -| `model` | Model override for this command | -| `subtask` | When `true`, runs as a sub-agent session | +| Field | Description | +|---|---| +| `description` | Shown in the command picker | +| `agent` | Which agent to use when invoking this command | +| `model` | Model override for this command | +| `subtask` | When `true`, runs as a sub-agent session | ### Workflow Capabilities diff --git a/packages/kilo-docs/pages/deploy-secure/managed-indexing.md b/packages/kilo-docs/pages/deploy-secure/managed-indexing.md index a1906b86f88..1b799c40af2 100644 --- a/packages/kilo-docs/pages/deploy-secure/managed-indexing.md +++ b/packages/kilo-docs/pages/deploy-secure/managed-indexing.md @@ -56,11 +56,11 @@ Codebase Indexing is rolling out across our users. It will automatically engage ### Configuration Options -| Field | Type | Required | Description | -| -------------------------------- | ------- | -------- | ------------------------------------------------------------------------------------------- | -| `project.id` | string | No | Custom name for your project. Defaults to the name from your Git origin remote. | -| `project.baseBranch` | string | No | Specifies your base branch if it isn't `main`, `master`, `dev`, or `develop`. | -| `project.managedIndexingEnabled` | boolean | No | Set to `false` to disable indexing for individual project repositories. Defaults to `true`. | +| Field | Type | Required | Description | +|---|---|---|---| +| `project.id` | string | No | Custom name for your project. Defaults to the name from your Git origin remote. | +| `project.baseBranch` | string | No | Specifies your base branch if it isn't `main`, `master`, `dev`, or `develop`. | +| `project.managedIndexingEnabled` | boolean | No | Set to `false` to disable indexing for individual project repositories. Defaults to `true`. | Organization-wide indexing is enabled for any organization that has a credit balance. If you want to disable indexing for a specific repository, set `managedIndexingEnabled` to `false` in the config file. diff --git a/packages/kilo-docs/pages/deploy-secure/security-reviews.md b/packages/kilo-docs/pages/deploy-secure/security-reviews.md index 8f5232943d8..c3a77571325 100644 --- a/packages/kilo-docs/pages/deploy-secure/security-reviews.md +++ b/packages/kilo-docs/pages/deploy-secure/security-reviews.md @@ -52,11 +52,11 @@ The Security Agent processes each vulnerability alert through four stages. You control how much analysis the agent performs via three modes: -| Mode | What happens | -| ----------- | --------------------------------------------------------------------- | -| **Auto** | Triage first, then deep analysis only when triage recommends it | -| **Shallow** | Triage only — no deep analysis | -| **Deep** | Full codebase analysis for every finding, regardless of triage result | +| Mode | What happens | +|---|---| +| **Auto** | Triage first, then deep analysis only when triage recommends it | +| **Shallow** | Triage only — no deep analysis | +| **Deep** | Full codebase analysis for every finding, regardless of triage result | **Auto** is the default. It gives you the best balance between thoroughness and credit usage — deep analysis only runs where triage says it's needed. @@ -110,24 +110,24 @@ Every finding has a **primary status** and an **outcome label**. The status trac **Primary status:** -| Status | Meaning | -| --------- | --------------------------------------------------- | -| Open | Active vulnerability that needs attention | -| Fixed | Resolved — detected from the Dependabot alert state | -| Dismissed | Closed by a user or by auto-dismiss | +| Status | Meaning | +|---|---| +| Open | Active vulnerability that needs attention | +| Fixed | Resolved — detected from the Dependabot alert state | +| Dismissed | Closed by a user or by auto-dismiss | **Outcome labels:** -| Outcome | Meaning | -| --------------- | ------------------------------------------ | -| Not Analyzed | No analysis has run yet | -| Analyzing | Analysis is currently in progress | -| Analysis Failed | Something went wrong during analysis | -| Exploitable | Deep analysis confirmed it's exploitable | +| Outcome | Meaning | +|---|---| +| Not Analyzed | No analysis has run yet | +| Analyzing | Analysis is currently in progress | +| Analysis Failed | Something went wrong during analysis | +| Exploitable | Deep analysis confirmed it's exploitable | | Not Exploitable | Deep analysis confirmed it's not reachable | -| Safe to Dismiss | Triage recommends dismissing this finding | -| Needs Review | Triage recommends manual review | -| Triage Complete | Triage is done, no deep analysis needed | +| Safe to Dismiss | Triage recommends dismissing this finding | +| Needs Review | Triage recommends manual review | +| Triage Complete | Triage is done, no deep analysis needed | --- @@ -158,11 +158,11 @@ All settings are on the Security Agent configuration page. **SLA deadlines** set how many days your team has to remediate findings at each severity level: | Severity | Default | -| -------- | ------- | +|---|---| | Critical | 15 days | -| High | 30 days | -| Medium | 45 days | -| Low | 90 days | +| High | 30 days | +| Medium | 45 days | +| Low | 90 days | You can adjust these per your organization's policies and reset to defaults at any time. diff --git a/packages/kilo-docs/pages/gateway/api-reference.md b/packages/kilo-docs/pages/gateway/api-reference.md index 499fa94aedb..30b1a4fc02f 100644 --- a/packages/kilo-docs/pages/gateway/api-reference.md +++ b/packages/kilo-docs/pages/gateway/api-reference.md @@ -324,16 +324,16 @@ No authentication required. ## Error codes -| HTTP Status | Description | -| ----------- | ------------------------------------------------------- | -| 400 | Bad request -- invalid parameters or model ID | -| 401 | Unauthorized -- invalid or missing API key | -| 402 | Insufficient balance -- add credits to continue | -| 403 | Forbidden -- model not allowed by organization policy | -| 429 | Rate limited -- too many requests | -| 500 | Internal server error | -| 502 | Provider error -- upstream provider returned an error | -| 503 | Service unavailable -- provider temporarily unavailable | +| HTTP Status | Description | +|---|---| +| 400 | Bad request -- invalid parameters or model ID | +| 401 | Unauthorized -- invalid or missing API key | +| 402 | Insufficient balance -- add credits to continue | +| 403 | Forbidden -- model not allowed by organization policy | +| 429 | Rate limited -- too many requests | +| 500 | Internal server error | +| 502 | Provider error -- upstream provider returned an error | +| 503 | Service unavailable -- provider temporarily unavailable | ### Error response format diff --git a/packages/kilo-docs/pages/gateway/authentication.md b/packages/kilo-docs/pages/gateway/authentication.md index 2c5862ca936..91315a6016d 100644 --- a/packages/kilo-docs/pages/gateway/authentication.md +++ b/packages/kilo-docs/pages/gateway/authentication.md @@ -78,22 +78,22 @@ BYOK lets you use your own provider API keys with the Kilo AI Gateway. When a BY ### Supported BYOK providers -| Provider | BYOK Key ID | -| -------------------- | ----------------- | -| Anthropic | `anthropic` | -| AWS Bedrock | `bedrock` | -| Google AI Studio | `google` | -| Inception | `inception` | -| OpenAI | `openai` | -| MiniMax | `minimax` | -| Mistral | `mistral` | -| xAI | `xai` | -| Z.AI | `zai` | +| Provider | BYOK Key ID | +|---|---| +| Anthropic | `anthropic` | +| AWS Bedrock | `bedrock` | +| Google AI Studio | `google` | +| Inception | `inception` | +| OpenAI | `openai` | +| MiniMax | `minimax` | +| Mistral | `mistral` | +| xAI | `xai` | +| Z.AI | `zai` | | BytePlus Coding Plan | `byteplus-coding` | -| Codestral (FIM) | `codestral` | -| Kimi Code | `kimi-coding` | -| Neuralwatt | `neuralwatt` | -| Z.AI Coding Plan | `zai-coding` | +| Codestral (FIM) | `codestral` | +| Kimi Code | `kimi-coding` | +| Neuralwatt | `neuralwatt` | +| Z.AI Coding Plan | `zai-coding` | ### How BYOK works @@ -109,11 +109,11 @@ BYOK keys can be configured at the personal level or at the organization level. The gateway accepts the following headers: -| Header | Required | Description | -| --------------------------- | ----------------------- | -------------------------------------------- | -| `Authorization` | Yes (unless free model) | `Bearer ` | -| `Content-Type` | Yes | `application/json` | -| `X-KiloCode-OrganizationId` | No | Organization context for org-scoped requests | -| `X-KiloCode-TaskId` | No | Task identifier for prompt cache keying | -| `X-KiloCode-Version` | No | Client version string | -| `x-kilocode-mode` | No | Mode hint for `kilo-auto` model routing | +| Header | Required | Description | +|---|---|---| +| `Authorization` | Yes (unless free model) | `Bearer ` | +| `Content-Type` | Yes | `application/json` | +| `X-KiloCode-OrganizationId` | No | Organization context for org-scoped requests | +| `X-KiloCode-TaskId` | No | Task identifier for prompt cache keying | +| `X-KiloCode-Version` | No | Client version string | +| `x-kilocode-mode` | No | Mode hint for `kilo-auto` model routing | diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index 2eb81138698..a34767b01f5 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -39,32 +39,32 @@ This returns model information including pricing, context window, and supported ### Popular models -| Model ID | Provider | Description | -| ------------------------------- | --------- | ----------------------------------------------- | -| `anthropic/claude-opus-4.7` | Anthropic | Most capable Claude model for complex reasoning | -| `anthropic/claude-sonnet-4.6` | Anthropic | Balanced performance and cost | -| `anthropic/claude-haiku-4.5` | Anthropic | Fast and cost-effective | -| `openai/gpt-5.4` | OpenAI | Latest GPT model | -| `openai/gpt-5.4-mini` | OpenAI | Fast and efficient | -| `google/gemini-3.1-pro-preview` | Google | Advanced reasoning | -| `google/gemini-2.5-flash` | Google | Fast and efficient | -| `x-ai/grok-4` | xAI | Most capable Grok model | -| `x-ai/grok-code-fast-1` | xAI | Optimized for code tasks | -| `deepseek/deepseek-v3.2` | DeepSeek | Strong coding and reasoning model | -| `moonshotai/kimi-k2.5` | Moonshot | Strong coding and multilingual model | -| `minimax/minimax-m2.7` | MiniMax | High-performance MoE model | +| Model ID | Provider | Description | +|---|---|---| +| `anthropic/claude-opus-4.7` | Anthropic | Most capable Claude model for complex reasoning | +| `anthropic/claude-sonnet-4.6` | Anthropic | Balanced performance and cost | +| `anthropic/claude-haiku-4.5` | Anthropic | Fast and cost-effective | +| `openai/gpt-5.4` | OpenAI | Latest GPT model | +| `openai/gpt-5.4-mini` | OpenAI | Fast and efficient | +| `google/gemini-3.1-pro-preview` | Google | Advanced reasoning | +| `google/gemini-2.5-flash` | Google | Fast and efficient | +| `x-ai/grok-4` | xAI | Most capable Grok model | +| `x-ai/grok-code-fast-1` | xAI | Optimized for code tasks | +| `deepseek/deepseek-v3.2` | DeepSeek | Strong coding and reasoning model | +| `moonshotai/kimi-k2.5` | Moonshot | Strong coding and multilingual model | +| `minimax/minimax-m2.7` | MiniMax | High-performance MoE model | ### Free models Several models are available at no cost, subject to rate limits: -| Model ID | Description | -| ---------------------------------------- | ------------------------------ | -| `bytedance-seed/dola-seed-2.0-pro:free` | ByteDance Dola Seed 2.0 Pro | -| `x-ai/grok-code-fast-1:optimized:free` | xAI Grok Code Fast 1 Optimized | -| `nvidia/nemotron-3-super-120b-a12b:free` | NVIDIA Nemotron 3 Super 120B | -| `arcee-ai/trinity-large-thinking:free` | Arcee Trinity Large | -| `openrouter/free` | Best available free model | +| Model ID | Description | +|---|---| +| `bytedance-seed/dola-seed-2.0-pro:free` | ByteDance Dola Seed 2.0 Pro | +| `x-ai/grok-code-fast-1:optimized:free` | xAI Grok Code Fast 1 Optimized | +| `nvidia/nemotron-3-super-120b-a12b:free` | NVIDIA Nemotron 3 Super 120B | +| `arcee-ai/trinity-large-thinking:free` | Arcee Trinity Large | +| `openrouter/free` | Best available free model | Free models are available to both authenticated and anonymous users. Anonymous users are rate-limited to 200 requests per hour per IP address. @@ -84,36 +84,33 @@ The mappings below reflect the current routing. The underlying models behind eac Highest performance and capability for any task. Frontier requests are sent with medium reasoning effort and medium verbosity. -| Mode | Resolved Model | -| -------------------------------------------------------------- | ----------------------------- | -| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `anthropic/claude-opus-4.7` | -| `build`, `explore`, `code` | `anthropic/claude-sonnet-4.6` | -| Default (no / unknown mode) | `anthropic/claude-sonnet-4.6` | +| Mode | Resolved Model | +|---|---| +| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `anthropic/claude-opus-4.7` | +| `build`, `explore`, `code` | `anthropic/claude-sonnet-4.6` | +| Default (no / unknown mode) | `anthropic/claude-sonnet-4.6` | ### `kilo-auto/balanced` -Great balance of price and capability. Balanced routes to the same model regardless of mode, with low reasoning effort. +Great balance of price and capability. The resolved model depends on the API interface used by the client. -| Mode | Resolved Model | -| --------- | ---------------------- | -| All modes | `openai/gpt-5.3-codex` | +| API interface | Resolved Model | Reasoning effort | +|---|---|---| +| Completions (default) | `qwen/qwen3.6-plus` | enabled | +| Responses API | `openai/gpt-5.5` | low | +| Messages API | `anthropic/claude-sonnet-4.6` | low | ### `kilo-auto/free` -Free with limited capability. No credits required. Requests are split across the available free models; the mapping updates server-side as free model availability shifts. - -| Routing | Resolved Model | -| ------- | ----------------------------- | -| 80% | `minimax/minimax-m2.5:free` | -| 20% | `stepfun/step-3.5-flash:free` | +Free with limited capability. No credits required. The resolved model is selected dynamically per session from a curated set of available free models; the mapping updates server-side as free model availability shifts. ### `kilo-auto/small` Automatically routes to a small, fast model for lightweight background tasks (session titles, commit messages, summaries). -| Condition | Resolved Model | -| ------------------------- | -------------------------------- | -| Account has paid balance | `google/gemma-4-31b-it` | +| Condition | Resolved Model | +|---|---| +| Account has paid balance | `google/gemma-4-31b-it` | | No balance / free account | `google/gemma-4-26b-a4b-it:free` | ### Example usage diff --git a/packages/kilo-docs/pages/gateway/sdks-and-frameworks.md b/packages/kilo-docs/pages/gateway/sdks-and-frameworks.md index e4702151328..485c9d2c26b 100644 --- a/packages/kilo-docs/pages/gateway/sdks-and-frameworks.md +++ b/packages/kilo-docs/pages/gateway/sdks-and-frameworks.md @@ -285,12 +285,12 @@ puts result['choices'][0]['message']['content'] The Kilo AI Gateway works with any framework that supports OpenAI-compatible APIs: -| Framework | Integration | -| --------------------------------------------------------------------- | ----------------------------------------- | -| [Vercel AI SDK](https://ai-sdk.dev) | Use `createOpenAI` with Kilo base URL | -| [LangChain](https://langchain.com) | Use `ChatOpenAI` with custom base URL | -| [LlamaIndex](https://www.llamaindex.ai) | Use OpenAI-compatible configuration | -| [Haystack](https://haystack.deepset.ai) | Use OpenAI generator with custom URL | +| Framework | Integration | +|---|---| +| [Vercel AI SDK](https://ai-sdk.dev) | Use `createOpenAI` with Kilo base URL | +| [LangChain](https://langchain.com) | Use `ChatOpenAI` with custom base URL | +| [LlamaIndex](https://www.llamaindex.ai) | Use OpenAI-compatible configuration | +| [Haystack](https://haystack.deepset.ai) | Use OpenAI generator with custom URL | | [Semantic Kernel](https://learn.microsoft.com/en-us/semantic-kernel/) | Use OpenAI connector with custom endpoint | ### LangChain example diff --git a/packages/kilo-docs/pages/gateway/usage-and-billing.md b/packages/kilo-docs/pages/gateway/usage-and-billing.md index 9aa82e1b063..1ddb9e78ccc 100644 --- a/packages/kilo-docs/pages/gateway/usage-and-billing.md +++ b/packages/kilo-docs/pages/gateway/usage-and-billing.md @@ -94,8 +94,8 @@ Set a maximum daily spend per organization member. When a member reaches their d All free model requests (both anonymous and authenticated) are rate-limited by IP address: -| Scope | Limit | -| ------------------ | --------------------- | +| Scope | Limit | +|---|---| | Free models per IP | 200 requests per hour | When rate-limited, you receive HTTP 429: @@ -117,17 +117,17 @@ Paid model requests are not rate-limited by the gateway itself, but may be rate- Usage data is tracked per request and includes: -| Field | Description | -| --------------------- | ------------------------------------------ | -| `model` | Model ID used | -| `provider` | Inference provider that served the request | -| `input_tokens` | Number of input/prompt tokens | -| `output_tokens` | Number of output/completion tokens | -| `cache_write_tokens` | Tokens written to cache | -| `cache_hit_tokens` | Tokens served from cache | -| `cost_microdollars` | Cost in microdollars (1 USD = 1,000,000) | -| `time_to_first_token` | Latency to first token (streaming only) | -| `is_byok` | Whether a BYOK key was used | +| Field | Description | +|---|---| +| `model` | Model ID used | +| `provider` | Inference provider that served the request | +| `input_tokens` | Number of input/prompt tokens | +| `output_tokens` | Number of output/completion tokens | +| `cache_write_tokens` | Tokens written to cache | +| `cache_hit_tokens` | Tokens served from cache | +| `cost_microdollars` | Cost in microdollars (1 USD = 1,000,000) | +| `time_to_first_token` | Latency to first token (streaming only) | +| `is_byok` | Whether a BYOK key was used | ## Token counting diff --git a/packages/kilo-docs/pages/getting-started/byok.md b/packages/kilo-docs/pages/getting-started/byok.md index 24ed48bf863..3fd344f85d3 100644 --- a/packages/kilo-docs/pages/getting-started/byok.md +++ b/packages/kilo-docs/pages/getting-started/byok.md @@ -47,11 +47,11 @@ AWS Bedrock requires credentials in a different format than other providers. Ins } ``` -| Field | Description | -| ----------------- | ------------------------------------------------------------------------ | -| `accessKeyId` | Your AWS access key ID | -| `secretAccessKey` | Your AWS secret access key | -| `region` | The AWS region where Bedrock is enabled (e.g., `us-east-1`, `eu-west-1`) | +| Field | Description | +|---|---| +| `accessKeyId` | Your AWS access key ID | +| `secretAccessKey` | Your AWS secret access key | +| `region` | The AWS region where Bedrock is enabled (e.g., `us-east-1`, `eu-west-1`) | Your IAM user or role must have the following permissions: diff --git a/packages/kilo-docs/pages/getting-started/migrating.md b/packages/kilo-docs/pages/getting-started/migrating.md index cebf184c045..218f8ea3ef7 100644 --- a/packages/kilo-docs/pages/getting-started/migrating.md +++ b/packages/kilo-docs/pages/getting-started/migrating.md @@ -38,13 +38,13 @@ Choose your current tool: ### What's Different in Kilo Code -| Cursor | Kilo Code | Key Difference | -| ------------------------------------------- | ----------------------------------------- | ------------------------------------------- | -| `.cursor/rules/*.mdc` with YAML frontmatter | `.kilocode/rules/*.md` plain Markdown | No YAML metadata required | -| `alwaysApply: true/false` metadata | File location determines scope | Scope controlled by directory structure | -| `globs: ["*.ts"]` for file patterns | Mode-specific directories or custom modes | File patterns handled via custom modes | -| `description` for AI activation | Clear file names and organization | Relies on explicit file organization | -| Global rules in UI settings | `~/.kilocode/rules/*.md` files | Global rules stored as files in home folder | +| Cursor | Kilo Code | Key Difference | +|---|---|---| +| `.cursor/rules/*.mdc` with YAML frontmatter | `.kilocode/rules/*.md` plain Markdown | No YAML metadata required | +| `alwaysApply: true/false` metadata | File location determines scope | Scope controlled by directory structure | +| `globs: ["*.ts"]` for file patterns | Mode-specific directories or custom modes | File patterns handled via custom modes | +| `description` for AI activation | Clear file names and organization | Relies on explicit file organization | +| Global rules in UI settings | `~/.kilocode/rules/*.md` files | Global rules stored as files in home folder | ### Migration Steps @@ -160,14 +160,14 @@ Cursor supports nested `.cursor/rules/` directories. Kilo Code uses flat structu ### What's Different in Kilo Code -| Windsurf | Kilo Code | Key Difference | -| -------------------------------------------------------------- | ------------------------------ | ------------------------------------------- | -| `.windsurf/rules/*.md` | `.kilocode/rules/*.md` | Same Markdown format | -| GUI configuration for activation modes | File location determines scope | Scope controlled by directory structure | -| "Always On" mode (GUI) | Place in `.kilocode/rules/` | Rules stored as files, not GUI settings | -| "Glob" mode (GUI) | Mode-specific directories | File patterns handled via mode directories | -| 12,000 character limit per rule | No hard limit | No character limit on rule files | -| Global rules in `~/.codeium/windsurf/memories/global_rules.md` | `~/.kilocode/rules/*.md` | Global rules in home folder, multiple files | +| Windsurf | Kilo Code | Key Difference | +|---|---|---| +| `.windsurf/rules/*.md` | `.kilocode/rules/*.md` | Same Markdown format | +| GUI configuration for activation modes | File location determines scope | Scope controlled by directory structure | +| "Always On" mode (GUI) | Place in `.kilocode/rules/` | Rules stored as files, not GUI settings | +| "Glob" mode (GUI) | Mode-specific directories | File patterns handled via mode directories | +| 12,000 character limit per rule | No hard limit | No character limit on rule files | +| Global rules in `~/.codeium/windsurf/memories/global_rules.md` | `~/.kilocode/rules/*.md` | Global rules in home folder, multiple files | ### Migration Steps @@ -220,12 +220,12 @@ If you had rules approaching the 12,000 character limit, split them: Windsurf configures activation through the GUI. In Kilo Code, file organization replaces GUI configuration: -| Windsurf GUI Mode | Kilo Code Equivalent | -| ------------------------ | ----------------------------------------------------------- | -| **Always On** | Place in `.kilocode/rules/` (default) | -| **Glob** (file patterns) | Mode-specific directory or custom mode | -| **Model Decision** | Clear file names by concern (e.g., `testing-guidelines.md`) | -| **Manual** | Organize with descriptive names | +| Windsurf GUI Mode | Kilo Code Equivalent | +|---|---| +| **Always On** | Place in `.kilocode/rules/` (default) | +| **Glob** (file patterns) | Mode-specific directory or custom mode | +| **Model Decision** | Clear file names by concern (e.g., `testing-guidelines.md`) | +| **Manual** | Organize with descriptive names | **Example - Converting a Glob rule:** diff --git a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md index 9a76c5d9b9b..8e4bee7cefb 100644 --- a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md +++ b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md @@ -24,11 +24,11 @@ The UI reads and writes to the same `kilo.jsonc` config files used by the CLI, s Each tool permission can be set to one of three values: -| Value | Behavior | -| --------- | --------------------------------------------------------- | -| `"allow"` | The tool runs automatically without prompting | -| `"ask"` | Kilo pauses and asks for approval before running the tool | -| `"deny"` | The tool is blocked entirely | +| Value | Behavior | +|---|---| +| `"allow"` | The tool runs automatically without prompting | +| `"ask"` | Kilo pauses and asks for approval before running the tool | +| `"deny"` | The tool is blocked entirely | When no rule matches a permission check, the default action is `ask`. @@ -36,30 +36,29 @@ When no rule matches a permission check, the default action is `ask`. The Auto Approve tab lists the following tool-specific permissions. Some tools are grouped together in the UI and share a single permission level: -| Permission | Controls | -| -------------------------- | ------------------------------------------------------ | -| `external_directory` | Accessing files outside the project directory | -| `bash` | Executing shell commands | -| `read` | Reading file contents | -| `edit` | Editing existing files | -| `glob` | File pattern matching / searching by name | -| `grep` | Searching file contents by regex | -| `list` | Listing directory contents | -| `task` | Launching sub-agents | -| `skill` | Loading specialized skills | -| `lsp` | Language server protocol operations | -| `todoread` / `todowrite` | Reading and updating the todo list | -| `websearch` / `codesearch` | Performing web or code searches | -| `webfetch` | Fetching content from URLs | -| `doom_loop` | Allowing the agent to continue after repeated failures | +| Permission | Controls | +|---|---| +| `external_directory` | Accessing files outside the project directory | +| `bash` | Executing shell commands | +| `read` | Reading file contents | +| `edit` | Editing existing files | +| `glob` | File pattern matching / searching by name | +| `grep` | Searching file contents by regex | +| `task` | Launching sub-agents | +| `skill` | Loading specialized skills | +| `lsp` | Language server protocol operations | +| `todoread` / `todowrite` | Reading and updating the todo list | +| `websearch` / `codesearch` | Performing web or code searches | +| `webfetch` | Fetching content from URLs | +| `doom_loop` | Allowing the agent to continue after repeated failures | ## Runtime Permission Requests When a tool is set to `"ask"`, Kilo pauses and displays a permission prompt with two options: -| Option | Behavior | -| -------- | ------------------------------ | -| **Run** | Allow this specific invocation | +| Option | Behavior | +|---|---| +| **Run** | Allow this specific invocation | | **Deny** | Block this specific invocation | Expand **Manage Auto-Approve Rules** to add commands or patterns to your allowed or denied lists. These rules are then appended to the bottom of the approval rules in settings and the config file. @@ -89,11 +88,11 @@ The CLI uses a granular, per-tool permission system configured in `kilo.jsonc`. Each tool permission can be set to one of three values: -| Value | Behavior | -| --------- | --------------------------------------------------------- | -| `"allow"` | The tool runs automatically without prompting | -| `"ask"` | Kilo pauses and asks for approval before running the tool | -| `"deny"` | The tool is blocked entirely | +| Value | Behavior | +|---|---| +| `"allow"` | The tool runs automatically without prompting | +| `"ask"` | Kilo pauses and asks for approval before running the tool | +| `"deny"` | The tool is blocked entirely | When no rule matches a permission check, the default action is `ask`. @@ -101,22 +100,21 @@ When no rule matches a permission check, the default action is `ask`. Permissions are configured under the `permission` key in `kilo.jsonc`. The following tool-specific permission levels are available: -| Permission | Controls | -| -------------------------- | ------------------------------------------------------ | -| `external_directory` | Accessing files outside the project directory | -| `bash` | Executing shell commands | -| `read` | Reading file contents | -| `edit` | Editing existing files | -| `glob` | File pattern matching / searching by name | -| `grep` | Searching file contents by regex | -| `list` | Listing directory contents | -| `task` | Launching sub-agents | -| `skill` | Loading specialized skills | -| `lsp` | Language server protocol operations | -| `todoread` / `todowrite` | Reading and updating the todo list | -| `websearch` / `codesearch` | Performing web or code searches | -| `webfetch` | Fetching content from URLs | -| `doom_loop` | Allowing the agent to continue after repeated failures | +| Permission | Controls | +|---|---| +| `external_directory` | Accessing files outside the project directory | +| `bash` | Executing shell commands | +| `read` | Reading file contents | +| `edit` | Editing existing files | +| `glob` | File pattern matching / searching by name | +| `grep` | Searching file contents by regex | +| `task` | Launching sub-agents | +| `skill` | Loading specialized skills | +| `lsp` | Language server protocol operations | +| `todoread` / `todowrite` | Reading and updating the todo list | +| `websearch` / `codesearch` | Performing web or code searches | +| `webfetch` | Fetching content from URLs | +| `doom_loop` | Allowing the agent to continue after repeated failures | ## Glob-Pattern Rules @@ -200,11 +198,11 @@ In this example, the `code` agent can run `git` commands automatically and asks When a tool is set to `"ask"`, Kilo pauses and displays a permission prompt. You have three options: -| Option | Behavior | -| ---------------- | -------------------------------------------------------- | -| **Allow once** | Allow this specific invocation only | +| Option | Behavior | +|---|---| +| **Allow once** | Allow this specific invocation only | | **Allow always** | Allow this tool (or pattern) for the rest of the session | -| **Reject** | Block this specific invocation | +| **Reject** | Block this specific invocation | ## Defaults @@ -277,18 +275,18 @@ Click the toolbar to expand it and configure individual permissions: ### Available Permissions -| Permission | What it does | Risk level | -| ------------------------------ | ------------------------------------------------ | ----------- | -| **Read files and directories** | Lets Kilo Code access files without asking | Medium | -| **Edit files** | Lets Kilo Code modify files without asking | **High** | -| **Execute approved commands** | Runs whitelisted terminal commands automatically | **High** | -| **Use the browser** | Allows headless browser interaction | Medium | -| **Use MCP servers** | Lets Kilo Code use configured MCP services | Medium-High | -| **Switch modes** | Changes between Kilo Code modes automatically | Low | -| **Create & complete subtasks** | Manages subtasks without confirmation | Low | -| **Retry failed requests** | Automatically retries failed API requests | Low | -| **Answer follow-up questions** | Selects default answer for follow-up questions | Low | -| **Update todo list** | Automatically updates task progress | Low | +| Permission | What it does | Risk level | +|---|---|---| +| **Read files and directories** | Lets Kilo Code access files without asking | Medium | +| **Edit files** | Lets Kilo Code modify files without asking | **High** | +| **Execute approved commands** | Runs whitelisted terminal commands automatically | **High** | +| **Use the browser** | Allows headless browser interaction | Medium | +| **Use MCP servers** | Lets Kilo Code use configured MCP services | Medium-High | +| **Switch modes** | Changes between Kilo Code modes automatically | Low | +| **Create & complete subtasks** | Manages subtasks without confirmation | Low | +| **Retry failed requests** | Automatically retries failed API requests | Low | +| **Answer follow-up questions** | Selects default answer for follow-up questions | Low | +| **Update todo list** | Automatically updates task progress | Low | ## Master Toggle for Quick Control diff --git a/packages/kilo-docs/pages/getting-started/settings/auto-cleanup.md b/packages/kilo-docs/pages/getting-started/settings/auto-cleanup.md index c011f2c937e..7ad5085fc09 100644 --- a/packages/kilo-docs/pages/getting-started/settings/auto-cleanup.md +++ b/packages/kilo-docs/pages/getting-started/settings/auto-cleanup.md @@ -36,12 +36,12 @@ Auto-Cleanup uses an intelligent classification system to determine how long eac Every task is automatically classified into one of these categories: -| Task Type | Description | Default Retention | -| -------------- | ----------------------------------------- | ---------------------------------------- | -| **Favorited** | Tasks you've marked as favorites | Never deleted (or 90 days if configured) | -| **Completed** | Tasks that successfully finished | 30 days | -| **Incomplete** | Tasks that were started but not completed | 7 days | -| **Regular** | Default classification for other tasks | 30 days | +| Task Type | Description | Default Retention | +|---|---|---| +| **Favorited** | Tasks you've marked as favorites | Never deleted (or 90 days if configured) | +| **Completed** | Tasks that successfully finished | 30 days | +| **Incomplete** | Tasks that were started but not completed | 7 days | +| **Regular** | Default classification for other tasks | 30 days | #### Understanding Task Completion diff --git a/packages/kilo-docs/pages/getting-started/settings/index.md b/packages/kilo-docs/pages/getting-started/settings/index.md index 1d78324d158..f72cbd0e0aa 100644 --- a/packages/kilo-docs/pages/getting-started/settings/index.md +++ b/packages/kilo-docs/pages/getting-started/settings/index.md @@ -47,7 +47,12 @@ If you check config files into version control, make sure they do not contain AP ### Export and Import -Config files are plain-text and portable — copy them between machines and you're done. +You can export and import settings from the **About Kilo Code** tab in the Settings UI: + +- **Export**: Saves your global config as a `kilo-settings.json` file. Review it before sharing, because config values are exported as-is. +- **Import**: Loads a previously exported JSON file into the settings draft. Changes are not applied immediately — you can review them and click Save or Discard, just like any manual edit. + +Config files are also plain-text and portable — you can copy `~/.config/kilo/kilo.jsonc` between machines directly. {% /tab %} {% tab label="CLI" %} @@ -166,6 +171,7 @@ Available experimental toggles include: - **LSP integration** — expose language server diagnostics to the agent - **Paste summary** — summarize large clipboard pastes before including them - **Batch tool** — allow the agent to batch multiple tool calls in one step +- **OpenTelemetry** — enable Kilo telemetry and optional OTLP export when configured Advanced options not exposed in the UI can be configured via the `experimental` key in `kilo.jsonc`: @@ -174,6 +180,7 @@ Advanced options not exposed in the UI can be configured via the `experimental` "experimental": { "codebase_search": true, "batch_tool": false, + "openTelemetry": true, "disable_paste_summary": false, "mcp_timeout": 30000 } @@ -185,7 +192,9 @@ Refer to the auto-generated `$schema` in your `kilo.jsonc` for the full list of {% /tab %} {% tab label="CLI" %} -The CLI does not currently expose the same experimental feature toggles as the **VSCode (Legacy)** version. Configuration of model behavior, file editing strategies, and other advanced options is handled directly in the JSONC config files. Refer to the auto-generated `$schema` in your `kilo.jsonc` for the full list of available options. +The CLI does not currently expose the same experimental feature toggles as the **VSCode (Legacy)** version. Configuration of model behavior, file editing strategies, telemetry, and other advanced options is handled directly in the JSONC config files. Refer to the auto-generated `$schema` in your `kilo.jsonc` for the full list of available options. + +Telemetry is enabled by default. Set `experimental.openTelemetry` to `false` in `kilo.jsonc` to opt out. If `OTEL_EXPORTER_OTLP_ENDPOINT` is set in the environment, the CLI also exports OpenTelemetry traces and logs to that OTLP HTTP endpoint. {% /tab %} {% tab label="VSCode (Legacy)" %} diff --git a/packages/kilo-docs/pages/getting-started/settings/system-notifications.md b/packages/kilo-docs/pages/getting-started/settings/system-notifications.md index 477f88043d6..fe840def461 100644 --- a/packages/kilo-docs/pages/getting-started/settings/system-notifications.md +++ b/packages/kilo-docs/pages/getting-started/settings/system-notifications.md @@ -24,11 +24,11 @@ Kilo Code uses system notifications to inform you about: Kilo Code's system notifications work on all major operating systems with different underlying technologies: -| Operating System | Technology | Requirements | -| ---------------- | ------------------------------- | -------------------------------------------- | -| **macOS** | AppleScript + terminal-notifier | Built-in support, optional enhanced features | -| **Windows** | PowerShell + Windows Runtime | PowerShell execution policy configuration | -| **Linux** | notify-send | libnotify package installation | +| Operating System | Technology | Requirements | +|---|---|---| +| **macOS** | AppleScript + terminal-notifier | Built-in support, optional enhanced features | +| **Windows** | PowerShell + Windows Runtime | PowerShell execution policy configuration | +| **Linux** | notify-send | libnotify package installation | ## Platform-Specific Setup @@ -81,12 +81,12 @@ Windows notifications use the `Windows.UI.Notifications` API through PowerShell. #### Execution Policy Options -| Policy | Description | Security Level | Recommended | -| -------------- | ------------------------------------------ | -------------- | ----------------------- | -| `Restricted` | No scripts allowed (default) | Highest | ❌ Blocks notifications | -| `RemoteSigned` | Local scripts run, downloaded need signing | High | ✅ **Recommended** | -| `Unrestricted` | All scripts run with warnings | Medium | ⚠️ Use with caution | -| `AllSigned` | All scripts must be signed | Highest | ❌ Too restrictive | +| Policy | Description | Security Level | Recommended | +|---|---|---|---| +| `Restricted` | No scripts allowed (default) | Highest | ❌ Blocks notifications | +| `RemoteSigned` | Local scripts run, downloaded need signing | High | ✅ **Recommended** | +| `Unrestricted` | All scripts run with warnings | Medium | ⚠️ Use with caution | +| `AllSigned` | All scripts must be signed | Highest | ❌ Too restrictive | ### Linux Setup @@ -130,14 +130,14 @@ which notify-send System notifications work best with these desktop environments: -| Desktop Environment | Support Level | Notes | -| ------------------- | --------------- | ----------------------------------------- | -| **GNOME** | ✅ Full support | Native notification center | -| **KDE Plasma** | ✅ Full support | Native notification system | -| **XFCE** | ✅ Good support | Requires notification daemon | -| **Unity** | ✅ Full support | Ubuntu's notification system | -| **i3/Sway** | ⚠️ Limited | Requires manual notification daemon setup | -| **Headless** | ❌ No support | No display server available | +| Desktop Environment | Support Level | Notes | +|---|---|---| +| **GNOME** | ✅ Full support | Native notification center | +| **KDE Plasma** | ✅ Full support | Native notification system | +| **XFCE** | ✅ Good support | Requires notification daemon | +| **Unity** | ✅ Full support | Ubuntu's notification system | +| **i3/Sway** | ⚠️ Limited | Requires manual notification daemon setup | +| **Headless** | ❌ No support | No display server available | #### Notification Daemon Setup (Advanced) diff --git a/packages/kilo-docs/pages/getting-started/setup-authentication.md b/packages/kilo-docs/pages/getting-started/setup-authentication.md index 26de070b996..975d456e013 100644 --- a/packages/kilo-docs/pages/getting-started/setup-authentication.md +++ b/packages/kilo-docs/pages/getting-started/setup-authentication.md @@ -62,12 +62,12 @@ If you're using the [Kilo AI Gateway](/docs/gateway/) outside of the Kilo Code e If you prefer to use your own API key or existing subscription, Kilo Code supports **over 30 providers**. Here are some popular options to get started: -| Provider | Best For | API Key Required | -| -------------------------------------------------------------- | ----------------------------------- | ---------------- | -| [ChatGPT Plus/Pro](/docs/ai-providers/openai-chatgpt-plus-pro) | Use your existing subscription | No | -| [OpenRouter](/docs/ai-providers/openrouter) | Access multiple models with one key | Yes | -| [Anthropic](/docs/ai-providers/anthropic) | Direct access to Claude models | Yes | -| [OpenAI](/docs/ai-providers/openai) | Access to GPT models | Yes | +| Provider | Best For | API Key Required | +|---|---|---| +| [ChatGPT Plus/Pro](/docs/ai-providers/openai-chatgpt-plus-pro) | Use your existing subscription | No | +| [OpenRouter](/docs/ai-providers/openrouter) | Access multiple models with one key | Yes | +| [Anthropic](/docs/ai-providers/anthropic) | Direct access to Claude models | Yes | +| [OpenAI](/docs/ai-providers/openai) | Access to GPT models | Yes | {% callout type="info" title="Many More Providers Available" %} These are just a few examples! Kilo Code supports many more providers including Google Gemini, DeepSeek, Mistral, Ollama (for local models), AWS Bedrock, Google Vertex, and more. See the complete list at [AI Providers](/docs/ai-providers/). diff --git a/packages/kilo-docs/pages/kiloclaw/control-ui/changing-models.md b/packages/kilo-docs/pages/kiloclaw/control-ui/changing-models.md index 63c7fb77c62..442a00fcb04 100644 --- a/packages/kilo-docs/pages/kiloclaw/control-ui/changing-models.md +++ b/packages/kilo-docs/pages/kiloclaw/control-ui/changing-models.md @@ -7,13 +7,13 @@ description: "Browse and switch models from the Control UI chat" The Control UI Chat tab doubles as a command line for model management. KiloClaw exposes 335+ models through the `kilocode` provider and you can browse and switch between them without leaving the chat. -| Command | Description | -| ------------------------------------ | ------------------------------------------------------------------------------- | -| `/model status` | View the currently active model and provider | -| `/models kilocode` | Browse available models (paginated, 20 per page) | -| `/models kilocode ` | Jump to a specific page (e.g. `/models kilocode 2`) | +| Command | Description | +|---|---| +| `/model status` | View the currently active model and provider | +| `/models kilocode` | Browse available models (paginated, 20 per page) | +| `/models kilocode ` | Jump to a specific page (e.g. `/models kilocode 2`) | | `/model kilocode//` | Switch to a specific model (e.g. `/model kilocode/anthropic/claude-sonnet-4.6`) | -| `/models kilocode all` | List every available model at once | +| `/models kilocode all` | List every available model at once | Each `/models` response includes helper text at the bottom with shortcuts for switching, paging, and listing all models. diff --git a/packages/kilo-docs/pages/kiloclaw/control-ui/exec-approvals.md b/packages/kilo-docs/pages/kiloclaw/control-ui/exec-approvals.md index 08555b1f953..083ca923d1e 100644 --- a/packages/kilo-docs/pages/kiloclaw/control-ui/exec-approvals.md +++ b/packages/kilo-docs/pages/kiloclaw/control-ui/exec-approvals.md @@ -17,11 +17,11 @@ Approvals are enforced locally on the execution host and sit on top of tool poli ## Security Policies -| Policy | Behavior | -| ----------- | ---------------------------------------------- | -| `deny` | Block all host exec requests (default) | -| `allowlist` | Allow only commands matching the allowlist | -| `full` | Allow everything (equivalent to elevated mode) | +| Policy | Behavior | +|---|---| +| `deny` | Block all host exec requests (default) | +| `allowlist` | Allow only commands matching the allowlist | +| `full` | Allow everything (equivalent to elevated mode) | ## Allow Everything from Settings @@ -37,11 +37,11 @@ Enabling **Allow Everything** removes all exec safety checks. Only use this in t The `ask` setting controls when the user is prompted for approval: -| Setting | Behavior | -| --------- | ------------------------------------------------------- | -| `off` | Never prompt | +| Setting | Behavior | +|---|---| +| `off` | Never prompt | | `on-miss` | Prompt only when the allowlist does not match (default) | -| `always` | Prompt on every command | +| `always` | Prompt on every command | If a prompt is required but no UI is reachable, the `askFallback` setting decides the outcome (`deny` by default). diff --git a/packages/kilo-docs/pages/kiloclaw/dashboard.md b/packages/kilo-docs/pages/kiloclaw/dashboard.md index ae04957539f..0d6a388a5c7 100644 --- a/packages/kilo-docs/pages/kiloclaw/dashboard.md +++ b/packages/kilo-docs/pages/kiloclaw/dashboard.md @@ -13,12 +13,12 @@ This page covers everything you can do from the KiloClaw dashboard. For getting Your instance is always in one of these states as indicated by the status label at the top of your dashboard: -| Status | Label | Meaning | -| --------------- | --------------- | ------------------------------------------------------------- | -| **Running** | Machine Online | Your agent is online and reachable | -| **Stopped** | Machine Stopped | The machine is off, but all your files and data are preserved | -| **Provisioned** | Provisioned | Your instance has been created but never started | -| **Destroying** | Destroying | The instance is being permanently deleted | +| Status | Label | Meaning | +|---|---|---| +| **Running** | Machine Online | Your agent is online and reachable | +| **Stopped** | Machine Stopped | The machine is off, but all your files and data are preserved | +| **Provisioned** | Provisioned | Your instance has been created but never started | +| **Destroying** | Destroying | The instance is being permanently deleted | ## Instance Controls @@ -168,23 +168,23 @@ The dashboard shows recent KiloClaw platform updates. Each entry is tagged as a ## Instance Lifecycle -| Action | What Happens | Data Preserved? | -| ---------------------- | --------------------------------------------------------------------------- | --------------- | -| **Create & Provision** | Allocates storage in the best region available and saves your config. | N/A | -| **Start Machine** | Boots the machine and starts OpenClaw. | Yes | -| **Stop Instance** | Shuts down the machine. | Yes | -| **Restart OpenClaw** | Restarts the OpenClaw process. Machine stays up. | Yes | -| **Redeploy** | Stops, applies config, and restarts the machine (same version or upgraded). | Yes | -| **Destroy Instance** | Permanently deletes everything. | No | +| Action | What Happens | Data Preserved? | +|---|---|---| +| **Create & Provision** | Allocates storage in the best region available and saves your config. | N/A | +| **Start Machine** | Boots the machine and starts OpenClaw. | Yes | +| **Stop Instance** | Shuts down the machine. | Yes | +| **Restart OpenClaw** | Restarts the OpenClaw process. Machine stays up. | Yes | +| **Redeploy** | Stops, applies config, and restarts the machine (same version or upgraded). | Yes | +| **Destroy Instance** | Permanently deletes everything. | No | ## Machine Specs Each instance runs on a dedicated machine — there is no shared infrastructure between users. -| Spec | Value | -| ------- | -------------------- | -| CPU | 2 shared vCPUs | -| Memory | 3 GB RAM | +| Spec | Value | +|---|---| +| CPU | 2 shared vCPUs | +| Memory | 3 GB RAM | | Storage | 10 GB persistent SSD | Your storage is region-pinned — once your instance is created in a region (e.g., DFW), it always runs there. OpenClaw config lives at `/root/.openclaw` and the workspace at `/root/clawd`. diff --git a/packages/kilo-docs/pages/kiloclaw/development-tools/github.md b/packages/kilo-docs/pages/kiloclaw/development-tools/github.md index a79df17e7f5..28829902022 100644 --- a/packages/kilo-docs/pages/kiloclaw/development-tools/github.md +++ b/packages/kilo-docs/pages/kiloclaw/development-tools/github.md @@ -27,21 +27,21 @@ Once you have a GitHub account ready, continue to Step 2. KiloClaw uses a [fine grained Personal Access Token](https://github.com/settings/tokens?type=beta) to authenticate as your bot. When creating the token, use these settings: -| Setting | Recommended Value | -| --------------------- | ----------------------------------------- | -| **Token name** | `kiloclaw-bot` (or any descriptive name) | -| **Expiration** | 90 days (set a reminder to rotate) | +| Setting | Recommended Value | +|---|---| +| **Token name** | `kiloclaw-bot` (or any descriptive name) | +| **Expiration** | 90 days (set a reminder to rotate) | | **Repository access** | All repositories, or select specific ones | Grant the following permissions: -| Permission | Access Level | Purpose | -| ----------------- | ------------ | ------------------------------------------- | -| **Contents** | Read & Write | Clone repos, push commits | -| **Pull requests** | Read & Write | Open and manage pull requests | -| **Issues** | Read & Write | Create and comment on issues | -| **Metadata** | Read only | List repositories and basic repo info | -| **Workflows** | Read & Write | Trigger and manage GitHub Actions workflows | +| Permission | Access Level | Purpose | +|---|---|---| +| **Contents** | Read & Write | Clone repos, push commits | +| **Pull requests** | Read & Write | Open and manage pull requests | +| **Issues** | Read & Write | Create and comment on issues | +| **Metadata** | Read only | List repositories and basic repo info | +| **Workflows** | Read & Write | Trigger and manage GitHub Actions workflows | ### Step 3: Enter credentials in KiloClaw diff --git a/packages/kilo-docs/pages/kiloclaw/development-tools/google.md b/packages/kilo-docs/pages/kiloclaw/development-tools/google.md index cbc7505f511..e40abb65781 100644 --- a/packages/kilo-docs/pages/kiloclaw/development-tools/google.md +++ b/packages/kilo-docs/pages/kiloclaw/development-tools/google.md @@ -21,20 +21,20 @@ Once setup is complete, your KiloClaw machine will have the following configured - Real-time Gmail push notifications via Google Pub/Sub, so KiloClaw can react to incoming emails sent to the dedicated account without polling - Access to the full range of Google Workspace services: -| Service | What KiloClaw can do | -| --------------------- | ---------------------------- | -| **Gmail** | Read, draft, and send emails | -| **Google Calendar** | View and manage events | -| **Google Drive** | Access and organize files | -| **Google Docs** | Read and edit documents | -| **Google Sheets** | Read and edit spreadsheets | -| **Google Slides** | Read and edit presentations | -| **Google Tasks** | View and manage tasks | -| **People (Contacts)** | Access contact information | -| **Google Forms** | Read and manage forms | -| **Google Chat** | Send and read messages | -| **Google Classroom** | Access classroom resources | -| **Apps Script** | Manage Apps Script projects | +| Service | What KiloClaw can do | +|---|---| +| **Gmail** | Read, draft, and send emails | +| **Google Calendar** | View and manage events | +| **Google Drive** | Access and organize files | +| **Google Docs** | Read and edit documents | +| **Google Sheets** | Read and edit spreadsheets | +| **Google Slides** | Read and edit presentations | +| **Google Tasks** | View and manage tasks | +| **People (Contacts)** | Access contact information | +| **Google Forms** | Read and manage forms | +| **Google Chat** | Send and read messages | +| **Google Classroom** | Access classroom resources | +| **Apps Script** | Manage Apps Script projects | ## Prerequisites @@ -68,13 +68,13 @@ KiloClaw will automatically use the dedicated account's credentials to fulfill t KiloClaw's credentials are tied to its dedicated Google account — not your personal one. To let KiloClaw work with your personal Google data, you need to **share or delegate access from your personal account to the KiloClaw account**: -| Service | How to share access | -| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Google Calendar** | Share your calendar with the KiloClaw account's email address ([instructions](https://support.google.com/calendar/answer/37082)) | -| **Google Drive** | Share specific files or folders with the KiloClaw account's email address | -| **Gmail** (Option 1: Delegation) | Set up [Gmail delegation](https://support.google.com/mail/answer/138350) to grant KiloClaw read and write access to your inbox — it can read, draft, and send emails on your behalf | -| **Gmail** (Option 2: Forwarding) | Set up [email forwarding](https://support.google.com/mail/answer/10957) so KiloClaw receives its own copy of all incoming emails — it can read them but cannot make any changes to your original inbox | -| **Google Docs / Sheets / Slides** | Share individual documents with the KiloClaw account's email address | +| Service | How to share access | +|---|---| +| **Google Calendar** | Share your calendar with the KiloClaw account's email address ([instructions](https://support.google.com/calendar/answer/37082)) | +| **Google Drive** | Share specific files or folders with the KiloClaw account's email address | +| **Gmail** (Option 1: Delegation) | Set up [Gmail delegation](https://support.google.com/mail/answer/138350) to grant KiloClaw read and write access to your inbox — it can read, draft, and send emails on your behalf | +| **Gmail** (Option 2: Forwarding) | Set up [email forwarding](https://support.google.com/mail/answer/10957) so KiloClaw receives its own copy of all incoming emails — it can read them but cannot make any changes to your original inbox | +| **Google Docs / Sheets / Slides** | Share individual documents with the KiloClaw account's email address | Once access is shared, reference the delegation in your prompts so KiloClaw knows where to look: diff --git a/packages/kilo-docs/pages/kiloclaw/end-to-end.md b/packages/kilo-docs/pages/kiloclaw/end-to-end.md index 652dc07ab38..b095bce88ab 100644 --- a/packages/kilo-docs/pages/kiloclaw/end-to-end.md +++ b/packages/kilo-docs/pages/kiloclaw/end-to-end.md @@ -114,11 +114,11 @@ After sending that, redeploy from the dashboard to pick up the changes. After OAuth is set up, decide how much access to give your Claw to your personal accounts. -| Option | What it does | Best for | Configured from | -| ----------------------- | --------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------------------------- | -| Forward select emails | A Gmail filter forwards specific senders or labels to the bot account | Targeted use cases like newsletter digests | Your personal account | -| Forward all email | Forwards your full inbox to the bot | Simpler setups where noise is acceptable | Bot account (destination) | -| Full account delegation | Gives the bot direct read/write access to your personal account | Maximum capability | Your personal account — Gmail Settings → Add a delegate | +| Option | What it does | Best for | Configured from | +|---|---|---|---| +| Forward select emails | A Gmail filter forwards specific senders or labels to the bot account | Targeted use cases like newsletter digests | Your personal account | +| Forward all email | Forwards your full inbox to the bot | Simpler setups where noise is acceptable | Bot account (destination) | +| Full account delegation | Gives the bot direct read/write access to your personal account | Maximum capability | Your personal account — Gmail Settings → Add a delegate | {% callout type="info" %} Email forwarding is configured from the **destination** (bot) account. Account delegation is configured from the **source** (personal) account. diff --git a/packages/kilo-docs/pages/kiloclaw/pre-installed-software.md b/packages/kilo-docs/pages/kiloclaw/pre-installed-software.md index feea6ffed7c..fa51a07d3c6 100644 --- a/packages/kilo-docs/pages/kiloclaw/pre-installed-software.md +++ b/packages/kilo-docs/pages/kiloclaw/pre-installed-software.md @@ -23,74 +23,74 @@ Packages installed via `apt` do not persist across redeploys. If you need a pack The following packages are installed via `apt` on top of the base image: -| Package | Description | -| ----------------- | ----------------------------------------- | +| Package | Description | +|---|---| | `ca-certificates` | Root CA certificates for TLS verification | -| `curl` | HTTP client | -| `gnupg` | GPG encryption and signing | -| `git` | Version control | -| `unzip` | Archive extraction | -| `jq` | JSON processor | -| `ripgrep` | Fast recursive search (`rg`) | -| `rsync` | File synchronization | -| `zstd` | Zstandard compression | -| `build-essential` | GCC, make, and core build tools | -| `python3` | Python 3 interpreter (system default) | -| `ffmpeg` | Audio/video processing | -| `tmux` | Terminal multiplexer | +| `curl` | HTTP client | +| `gnupg` | GPG encryption and signing | +| `git` | Version control | +| `unzip` | Archive extraction | +| `jq` | JSON processor | +| `ripgrep` | Fast recursive search (`rg`) | +| `rsync` | File synchronization | +| `zstd` | Zstandard compression | +| `build-essential` | GCC, make, and core build tools | +| `python3` | Python 3 interpreter (system default) | +| `ffmpeg` | Audio/video processing | +| `tmux` | Terminal multiplexer | ## Browser -| Tool | Description | -| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Tool | Description | +|---|---| | Headless Chromium | Built-in browser for web browsing, screenshots, and CDP automation. Works with OpenClaw's browser tool out of the box. Requires the "full" tool profile. | ## Languages & Runtimes -| Language / Runtime | Version | Install Method | -| ------------------ | ---------------------------------- | -------------------------------- | -| Node.js | 22.13.1 | Binary tarball (primary runtime) | -| Go | 1.26.0 | Binary tarball | -| Bun | 1.2.4 | Install script | -| Python 3 | Unpinned (Debian Bookworm default) | `apt` | +| Language / Runtime | Version | Install Method | +|---|---|---| +| Node.js | 22.13.1 | Binary tarball (primary runtime) | +| Go | 1.26.0 | Binary tarball | +| Bun | 1.2.4 | Install script | +| Python 3 | Unpinned (Debian Bookworm default) | `apt` | ## Package Managers These package managers are available for installing libraries and dependencies: -| Manager | Included Via | -| ------- | -------------------- | -| `npm` | Bundled with Node.js | -| `pnpm` | Installed via `npm` | -| `bun` | Bundled with Bun | +| Manager | Included Via | +|---|---| +| `npm` | Bundled with Node.js | +| `pnpm` | Installed via `npm` | +| `bun` | Bundled with Bun | ## CLI Tools -| Tool | Version / Source | -| -------------------- | --------------------------- | -| GitHub CLI (`gh`) | Unpinned (GitHub apt repo) | +| Tool | Version / Source | +|---|---| +| GitHub CLI (`gh`) | Unpinned (GitHub apt repo) | | 1Password CLI (`op`) | 2.32.1 (1Password apt repo) | ## npm Global Packages The following packages are installed globally via `npm`: -| Package | Version | -| ----------------------- | -------- | +| Package | Version | +|---|---| | ClawHub CLI (`clawhub`) | Unpinned | -| mcporter | 0.7.3 | -| `@steipete/summarize` | 0.11.1 | +| mcporter | 0.7.3 | +| `@steipete/summarize` | 0.11.1 | ## OpenClaw Skills & Integrations -| Tool | Description | -| ------------ | --------------------------------------------------------------------- | +| Tool | Description | +|---|---| | gog (gogcli) | Google Workspace CLI — Gmail, Calendar, Drive, Contacts, Sheets, Docs | -| blogwatcher | Monitor blogs and RSS/Atom feeds for updates | -| xurl | Authenticated requests to the X (Twitter) API | -| gifgrep | Search GIF providers, download results, extract stills | -| summarize | Summarize or extract text/transcripts from URLs and files | -| goplaces | Location and places lookup | +| blogwatcher | Monitor blogs and RSS/Atom feeds for updates | +| xurl | Authenticated requests to the X (Twitter) API | +| gifgrep | Search GIF providers, download results, extract stills | +| summarize | Summarize or extract text/transcripts from URLs and files | +| goplaces | Location and places lookup | ## Installing Additional Tools diff --git a/packages/kilo-docs/pages/kiloclaw/triggers/index.md b/packages/kilo-docs/pages/kiloclaw/triggers/index.md index c2c468567a8..9b32395a5a0 100644 --- a/packages/kilo-docs/pages/kiloclaw/triggers/index.md +++ b/packages/kilo-docs/pages/kiloclaw/triggers/index.md @@ -11,10 +11,10 @@ All triggers are managed from the **Settings** page in the KiloClaw section of t ## Trigger Types -| Type | Description | -| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -| [**Webhooks**](/docs/kiloclaw/triggers/webhooks) | Receive HTTP requests from external services (GitHub, Stripe, monitoring tools, etc.) and deliver them as chat messages to your agent | -| [**Scheduled**](/docs/kiloclaw/triggers/scheduled) | Run tasks on a recurring schedule (e.g. every 15 minutes, daily at 9 AM, weekdays only) | +| Type | Description | +|---|---| +| [**Webhooks**](/docs/kiloclaw/triggers/webhooks) | Receive HTTP requests from external services (GitHub, Stripe, monitoring tools, etc.) and deliver them as chat messages to your agent | +| [**Scheduled**](/docs/kiloclaw/triggers/scheduled) | Run tasks on a recurring schedule (e.g. every 15 minutes, daily at 9 AM, weekdays only) | ## How Triggers Work diff --git a/packages/kilo-docs/pages/kiloclaw/triggers/scheduled.md b/packages/kilo-docs/pages/kiloclaw/triggers/scheduled.md index ef654dd71c8..bb0551cb125 100644 --- a/packages/kilo-docs/pages/kiloclaw/triggers/scheduled.md +++ b/packages/kilo-docs/pages/kiloclaw/triggers/scheduled.md @@ -47,12 +47,12 @@ Cron expressions use the standard five-field format: **Examples:** -| Expression | Meaning | -| -------------- | ----------------------------------- | -| `*/15 * * * *` | Every 15 minutes | -| `0 9 * * 1-5` | 9:00 AM on weekdays | -| `0 0 1 * *` | Midnight on the first of each month | -| `30 14 * * 3` | 2:30 PM every Wednesday | +| Expression | Meaning | +|---|---| +| `*/15 * * * *` | Every 15 minutes | +| `0 9 * * 1-5` | 9:00 AM on weekdays | +| `0 0 1 * *` | Midnight on the first of each month | +| `30 14 * * 3` | 2:30 PM every Wednesday | {% callout type="note" title="Minimum interval" %} The minimum interval between scheduled trigger runs is 10 minutes. Schedules more frequent than that are rejected. @@ -74,10 +74,10 @@ Run your scheduled task. Triggered at {{scheduledTime}}. **Available variables:** -| Variable | Description | -| ------------------- | ---------------------------------------- | +| Variable | Description | +|---|---| | `{{scheduledTime}}` | The time the schedule fired (ISO string) | -| `{{timestamp}}` | Capture timestamp (ISO string) | +| `{{timestamp}}` | Capture timestamp (ISO string) | {% callout type="note" title="Webhook variables are not available" %} Since scheduled triggers do not receive an HTTP request, variables like `{{body}}`, `{{bodyJson}}`, `{{headers}}`, `{{method}}`, `{{path}}`, and `{{query}}` are not populated. Use `{{scheduledTime}}` and `{{timestamp}}` instead. diff --git a/packages/kilo-docs/pages/kiloclaw/triggers/webhooks.md b/packages/kilo-docs/pages/kiloclaw/triggers/webhooks.md index 781273d15bd..93a135d33bd 100644 --- a/packages/kilo-docs/pages/kiloclaw/triggers/webhooks.md +++ b/packages/kilo-docs/pages/kiloclaw/triggers/webhooks.md @@ -41,14 +41,14 @@ You received a webhook event. Here is the payload: **Available variables:** -| Variable | Description | -| --------------- | ----------------------------- | -| `{{body}}` | Raw request body | -| `{{bodyJson}}` | Pretty-printed JSON body | -| `{{method}}` | HTTP method (e.g., `POST`) | -| `{{headers}}` | Request headers | -| `{{path}}` | Request path | -| `{{query}}` | Query string parameters | +| Variable | Description | +|---|---| +| `{{body}}` | Raw request body | +| `{{bodyJson}}` | Pretty-printed JSON body | +| `{{method}}` | HTTP method (e.g., `POST`) | +| `{{headers}}` | Request headers | +| `{{path}}` | Request path | +| `{{query}}` | Query string parameters | | `{{timestamp}}` | Time the webhook was received | You can tailor the template to give your agent more context. For example: diff --git a/packages/kilo-docs/previous-docs-redirects.js b/packages/kilo-docs/previous-docs-redirects.js index 24c86982e35..5a0f3027bce 100644 --- a/packages/kilo-docs/previous-docs-redirects.js +++ b/packages/kilo-docs/previous-docs-redirects.js @@ -807,6 +807,12 @@ module.exports = [ basePath: false, permanent: true, }, + { + source: "/docs/automate/tools/codebase-search", + destination: "/docs/automate/tools/semantic-search", + basePath: false, + permanent: true, + }, { source: "/docs/automate/kiloclaw/:path*", destination: "/docs/kiloclaw/:path*", diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-accordion/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-accordion/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-accordion/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-accordion/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-accordion/multiple-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-accordion/multiple-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-accordion/multiple-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-accordion/multiple-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-appicon/all-icons-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-appicon/all-icons-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-appicon/all-icons-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-appicon/all-icons-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-appicon/cursor-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-appicon/cursor-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-appicon/cursor-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-appicon/cursor-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-appicon/vs-code-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-appicon/vs-code-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-appicon/vs-code-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-appicon/vs-code-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-appicon/zed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-appicon/zed-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-appicon/zed-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-appicon/zed-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-avatar/all-sizes-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-avatar/all-sizes-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-avatar/all-sizes-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-avatar/all-sizes-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-avatar/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-avatar/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-avatar/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-avatar/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-avatar/large-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-avatar/large-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-avatar/large-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-avatar/large-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-avatar/normal-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-avatar/normal-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-avatar/normal-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-avatar/normal-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-avatar/small-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-avatar/small-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-avatar/small-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-avatar/small-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-avatar/with-custom-colors-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-avatar/with-custom-colors-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-avatar/with-custom-colors-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-avatar/with-custom-colors-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-avatar/with-image-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-avatar/with-image-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-avatar/with-image-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-avatar/with-image-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-basictool/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-basictool/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-basictool/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-basictool/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/default-open-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-basictool/default-open-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/default-open-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-basictool/default-open-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/generic-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-basictool/generic-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/generic-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-basictool/generic-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/locked-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-basictool/locked-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/locked-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-basictool/locked-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/no-children-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-basictool/no-children-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/no-children-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-basictool/no-children-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/with-args-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-basictool/with-args-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-basictool/with-args-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-basictool/with-args-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/disabled-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/disabled-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/disabled-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/disabled-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/ghost-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/ghost-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/ghost-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/ghost-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/large-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/large-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/large-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/large-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/normal-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/normal-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/normal-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/normal-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/primary-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/primary-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/primary-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/primary-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/secondary-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/secondary-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/secondary-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/secondary-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/small-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/small-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/small-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/small-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/with-icon-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/with-icon-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-button/with-icon-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-button/with-icon-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-card/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-card/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-card/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-card/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-card/error-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-card/error-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-card/error-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-card/error-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-card/info-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-card/info-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-card/info-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-card/info-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-card/normal-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-card/normal-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-card/normal-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-card/normal-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-card/success-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-card/success-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-card/success-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-card/success-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-card/warning-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-card/warning-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-card/warning-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-card/warning-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-checkbox/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-checkbox/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-checkbox/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-checkbox/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-checkbox/checked-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-checkbox/checked-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-checkbox/checked-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-checkbox/checked-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-checkbox/disabled-checked-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-checkbox/disabled-checked-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-checkbox/disabled-checked-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-checkbox/disabled-checked-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-checkbox/disabled-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-checkbox/disabled-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-checkbox/disabled-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-checkbox/disabled-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-checkbox/indeterminate-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-checkbox/indeterminate-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-checkbox/indeterminate-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-checkbox/indeterminate-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-checkbox/unchecked-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-checkbox/unchecked-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-checkbox/unchecked-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-checkbox/unchecked-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-checkbox/with-description-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-checkbox/with-description-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-checkbox/with-description-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-checkbox/with-description-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-code/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-code/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-code/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-code/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-code/no-line-numbers-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-code/no-line-numbers-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-code/no-line-numbers-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-code/no-line-numbers-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-code/python-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-code/python-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-code/python-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-code/python-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-code/short-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-code/short-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-code/short-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-code/short-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-code/split-overflow-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-code/split-overflow-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-code/split-overflow-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-code/split-overflow-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-code/type-script-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-code/type-script-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-code/type-script-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-code/type-script-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-collapsible/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-collapsible/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/collapsed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-collapsible/collapsed-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/collapsed-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-collapsible/collapsed-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/ghost-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-collapsible/ghost-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/ghost-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-collapsible/ghost-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/normal-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-collapsible/normal-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-collapsible/normal-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-collapsible/normal-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-contextmenu/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-contextmenu/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-contextmenu/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-contextmenu/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-contextmenu/with-groups-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-contextmenu/with-groups-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-contextmenu/with-groups-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-contextmenu/with-groups-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dialog/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dialog/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dialog/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dialog/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dialog/large-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dialog/large-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dialog/large-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dialog/large-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dialog/no-title-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dialog/no-title-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dialog/no-title-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dialog/no-title-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dialog/normal-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dialog/normal-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dialog/normal-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dialog/normal-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dialog/x-large-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dialog/x-large-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dialog/x-large-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dialog/x-large-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diff/added-file-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diff/added-file-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diff/added-file-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diff/added-file-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diff/config-change-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diff/config-change-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diff/config-change-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diff/config-change-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diff/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diff/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diff/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diff/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diff/deleted-file-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diff/deleted-file-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diff/deleted-file-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diff/deleted-file-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diff/split-view-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diff/split-view-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diff/split-view-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diff/split-view-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diffchanges/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diffchanges/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diffchanges/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diffchanges/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diffchanges/bars-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diffchanges/bars-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diffchanges/bars-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diffchanges/bars-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diffchanges/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diffchanges/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diffchanges/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diffchanges/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diffssr/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diffssr/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diffssr/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diffssr/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diffssr/with-split-style-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diffssr/with-split-style-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-diffssr/with-split-style-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-diffssr/with-split-style-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dropdownmenu/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dropdownmenu/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dropdownmenu/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dropdownmenu/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dropdownmenu/with-checkbox-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dropdownmenu/with-checkbox-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dropdownmenu/with-checkbox-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dropdownmenu/with-checkbox-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dropdownmenu/with-groups-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dropdownmenu/with-groups-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dropdownmenu/with-groups-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dropdownmenu/with-groups-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dropdownmenu/with-sub-menu-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dropdownmenu/with-sub-menu-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-dropdownmenu/with-sub-menu-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-dropdownmenu/with-sub-menu-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-errordetails/collapsed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-errordetails/collapsed-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-errordetails/collapsed-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-errordetails/collapsed-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-errordetails/expanded-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-errordetails/expanded-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-errordetails/expanded-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-errordetails/expanded-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-fileicon/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-fileicon/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-fileicon/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-fileicon/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-fileicon/folder-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-fileicon/folder-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-fileicon/folder-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-fileicon/folder-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-fileicon/folder-expanded-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-fileicon/folder-expanded-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-fileicon/folder-expanded-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-fileicon/folder-expanded-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-fileicon/java-script-file-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-fileicon/java-script-file-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-fileicon/java-script-file-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-fileicon/java-script-file-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-fileicon/markdown-file-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-fileicon/markdown-file-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-fileicon/markdown-file-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-fileicon/markdown-file-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-fileicon/type-script-file-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-fileicon/type-script-file-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-fileicon/type-script-file-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-fileicon/type-script-file-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-hovercard/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-hovercard/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-hovercard/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-hovercard/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-hovercard/with-user-card-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-hovercard/with-user-card-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-hovercard/with-user-card-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-hovercard/with-user-card-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-icon/all-icons-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-icon/all-icons-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-icon/all-icons-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-icon/all-icons-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-icon/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-icon/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-icon/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-icon/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-icon/large-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-icon/large-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-icon/large-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-icon/large-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-icon/medium-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-icon/medium-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-icon/medium-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-icon/medium-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-icon/small-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-icon/small-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-icon/small-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-icon/small-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-iconbutton/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-iconbutton/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-iconbutton/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-iconbutton/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-iconbutton/ghost-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-iconbutton/ghost-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-iconbutton/ghost-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-iconbutton/ghost-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-iconbutton/large-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-iconbutton/large-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-iconbutton/large-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-iconbutton/large-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-iconbutton/primary-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-iconbutton/primary-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-iconbutton/primary-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-iconbutton/primary-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-iconbutton/secondary-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-iconbutton/secondary-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-iconbutton/secondary-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-iconbutton/secondary-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-iconbutton/small-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-iconbutton/small-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-iconbutton/small-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-iconbutton/small-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-imagepreview/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-imagepreview/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-imagepreview/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-imagepreview/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-imagepreview/with-landscape-image-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-imagepreview/with-landscape-image-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-imagepreview/with-landscape-image-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-imagepreview/with-landscape-image-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-imagepreview/with-portrait-image-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-imagepreview/with-portrait-image-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-imagepreview/with-portrait-image-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-imagepreview/with-portrait-image-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-inlineinput/controlled-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-inlineinput/controlled-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-inlineinput/controlled-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-inlineinput/controlled-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-inlineinput/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-inlineinput/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-inlineinput/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-inlineinput/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-inlineinput/disabled-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-inlineinput/disabled-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-inlineinput/disabled-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-inlineinput/disabled-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-inlineinput/with-width-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-inlineinput/with-width-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-inlineinput/with-width-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-inlineinput/with-width-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-keybind/combination-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-keybind/combination-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-keybind/combination-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-keybind/combination-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-keybind/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-keybind/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-keybind/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-keybind/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-linecomment/anchor-closed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-linecomment/anchor-closed-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-linecomment/anchor-closed-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-linecomment/anchor-closed-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-linecomment/anchor-open-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-linecomment/anchor-open-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-linecomment/anchor-open-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-linecomment/anchor-open-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-linecomment/comment-closed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-linecomment/comment-closed-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-linecomment/comment-closed-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-linecomment/comment-closed-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-linecomment/comment-default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-linecomment/comment-default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-linecomment/comment-default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-linecomment/comment-default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-linecomment/editor-default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-linecomment/editor-default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-linecomment/editor-default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-linecomment/editor-default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-linecomment/editor-with-value-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-linecomment/editor-with-value-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-linecomment/editor-with-value-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-linecomment/editor-with-value-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-linecomment/interactive-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-linecomment/interactive-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-linecomment/interactive-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-linecomment/interactive-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-list/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-list/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-list/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-list/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-list/empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-list/empty-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-list/empty-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-list/empty-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-list/with-current-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-list/with-current-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-list/with-current-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-list/with-current-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-list/with-search-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-list/with-search-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-list/with-search-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-list/with-search-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-logo/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-logo/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-logo/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-logo/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-logo/full-logo-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-logo/full-logo-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-logo/full-logo-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-logo/full-logo-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-logo/mark-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-logo/mark-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-logo/mark-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-logo/mark-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-logo/splash-mark-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-logo/splash-mark-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-logo/splash-mark-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-logo/splash-mark-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-markdown/code-block-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-markdown/code-block-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-markdown/code-block-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-markdown/code-block-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-markdown/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-markdown/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-markdown/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-markdown/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-markdown/lists-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-markdown/lists-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-markdown/lists-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-markdown/lists-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-markdown/short-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-markdown/short-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-markdown/short-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-markdown/short-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagenav/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagenav/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagenav/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagenav/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagenav/compact-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagenav/compact-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagenav/compact-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagenav/compact-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagenav/normal-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagenav/normal-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagenav/normal-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagenav/normal-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/assistant-message-story-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/assistant-message-story-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/assistant-message-story-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/assistant-message-story-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/full-conversation-turn-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/full-conversation-turn-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/full-conversation-turn-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/full-conversation-turn-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/tool-hint-errors-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/tool-hint-errors-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/tool-hint-errors-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/tool-hint-errors-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/user-message-story-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/user-message-story-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/user-message-story-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/user-message-story-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-bash-tool-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/with-bash-tool-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-bash-tool-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/with-bash-tool-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-bash-tool-expanded-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/with-bash-tool-expanded-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-bash-tool-expanded-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/with-bash-tool-expanded-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-context-group-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/with-context-group-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-context-group-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/with-context-group-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-error-tool-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/with-error-tool-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-error-tool-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/with-error-tool-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-reasoning-collapsed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/with-reasoning-collapsed-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-reasoning-collapsed-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/with-reasoning-collapsed-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-reasoning-expanded-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/with-reasoning-expanded-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-reasoning-expanded-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/with-reasoning-expanded-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-running-tool-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/with-running-tool-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-messagepart/with-running-tool-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-messagepart/with-running-tool-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-popover/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-popover/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-popover/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-popover/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-popover/no-title-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-popover/no-title-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-popover/no-title-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-popover/no-title-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-popover/with-description-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-popover/with-description-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-popover/with-description-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-popover/with-description-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progress/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progress/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progress/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progress/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progress/custom-range-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progress/custom-range-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progress/custom-range-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progress/custom-range-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progress/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progress/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progress/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progress/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progress/empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progress/empty-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progress/empty-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progress/empty-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progress/full-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progress/full-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progress/full-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progress/full-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progress/hidden-label-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progress/hidden-label-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progress/hidden-label-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progress/hidden-label-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progress/with-label-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progress/with-label-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progress/with-label-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progress/with-label-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progress/with-value-label-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progress/with-value-label-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progress/with-value-label-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progress/with-value-label-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progresscircle/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progresscircle/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progresscircle/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progresscircle/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progresscircle/complete-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progresscircle/complete-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progresscircle/complete-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progresscircle/complete-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progresscircle/empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progresscircle/empty-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progresscircle/empty-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progresscircle/empty-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progresscircle/half-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progresscircle/half-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progresscircle/half-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progresscircle/half-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progresscircle/large-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progresscircle/large-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progresscircle/large-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progresscircle/large-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progresscircle/quarter-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progresscircle/quarter-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progresscircle/quarter-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progresscircle/quarter-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progresscircle/three-quarters-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progresscircle/three-quarters-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-progresscircle/three-quarters-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-progresscircle/three-quarters-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-providericon/all-icons-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-providericon/all-icons-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-providericon/all-icons-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-providericon/all-icons-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-providericon/anthropic-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-providericon/anthropic-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-providericon/anthropic-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-providericon/anthropic-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-providericon/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-providericon/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-providericon/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-providericon/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-providericon/gallery-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-providericon/gallery-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-providericon/gallery-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-providericon/gallery-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-providericon/google-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-providericon/google-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-providericon/google-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-providericon/google-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-providericon/large-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-providericon/large-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-providericon/large-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-providericon/large-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-providericon/small-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-providericon/small-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-providericon/small-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-providericon/small-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-radiogroup/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-radiogroup/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-radiogroup/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-radiogroup/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-radiogroup/medium-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-radiogroup/medium-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-radiogroup/medium-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-radiogroup/medium-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-radiogroup/small-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-radiogroup/small-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-radiogroup/small-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-radiogroup/small-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-radiogroup/with-custom-labels-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-radiogroup/with-custom-labels-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-radiogroup/with-custom-labels-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-radiogroup/with-custom-labels-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-resizehandle/horizontal-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-resizehandle/horizontal-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-resizehandle/horizontal-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-resizehandle/horizontal-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-resizehandle/vertical-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-resizehandle/vertical-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-resizehandle/vertical-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-resizehandle/vertical-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-select/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-select/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-select/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-select/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-select/disabled-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-select/disabled-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-select/disabled-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-select/disabled-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-select/settings-variant-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-select/settings-variant-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-select/settings-variant-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-select/settings-variant-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-select/with-current-value-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-select/with-current-value-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-select/with-current-value-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-select/with-current-value-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-select/with-group-by-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-select/with-group-by-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-select/with-group-by-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-select/with-group-by-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionreview/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-sessionreview/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionreview/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-sessionreview/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionreview/empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-sessionreview/empty-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionreview/empty-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-sessionreview/empty-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionreview/single-file-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-sessionreview/single-file-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionreview/single-file-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-sessionreview/single-file-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionreview/split-view-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-sessionreview/split-view-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionreview/split-view-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-sessionreview/split-view-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-sessionturn/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-sessionturn/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/with-steps-expanded-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-sessionturn/with-steps-expanded-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/with-steps-expanded-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-sessionturn/with-steps-expanded-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/working-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-sessionturn/working-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-sessionturn/working-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-sessionturn/working-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-spinner/colored-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-spinner/colored-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-spinner/colored-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-spinner/colored-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-spinner/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-spinner/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-spinner/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-spinner/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-spinner/large-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-spinner/large-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-spinner/large-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-spinner/large-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-spinner/small-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-spinner/small-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-spinner/small-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-spinner/small-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-stickyaccordionheader/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-stickyaccordionheader/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-stickyaccordionheader/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-stickyaccordionheader/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-stickyaccordionheader/in-scroll-container-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-stickyaccordionheader/in-scroll-container-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-stickyaccordionheader/in-scroll-container-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-stickyaccordionheader/in-scroll-container-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-switch/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-switch/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-switch/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-switch/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-switch/disabled-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-switch/disabled-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-switch/disabled-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-switch/disabled-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-switch/disabled-on-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-switch/disabled-on-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-switch/disabled-on-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-switch/disabled-on-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-switch/off-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-switch/off-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-switch/off-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-switch/off-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-switch/on-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-switch/on-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-switch/on-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-switch/on-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-switch/with-description-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-switch/with-description-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-switch/with-description-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-switch/with-description-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tabs/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tabs/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tabs/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tabs/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tabs/alt-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tabs/alt-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tabs/alt-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tabs/alt-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tabs/normal-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tabs/normal-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tabs/normal-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tabs/normal-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tabs/pill-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tabs/pill-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tabs/pill-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tabs/pill-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tabs/settings-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tabs/settings-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tabs/settings-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tabs/settings-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tabs/vertical-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tabs/vertical-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tabs/vertical-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tabs/vertical-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tag/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tag/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tag/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tag/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tag/large-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tag/large-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tag/large-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tag/large-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tag/normal-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tag/normal-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tag/normal-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tag/normal-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/copyable-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/copyable-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/copyable-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/copyable-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/copyable-link-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/copyable-link-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/copyable-link-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/copyable-link-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/disabled-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/disabled-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/disabled-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/disabled-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/ghost-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/ghost-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/ghost-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/ghost-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/multiline-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/multiline-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/multiline-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/multiline-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/normal-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/normal-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/normal-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/normal-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/with-description-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/with-description-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/with-description-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/with-description-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/with-error-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/with-error-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/with-error-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/with-error-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/with-label-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/with-label-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-textfield/with-label-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-textfield/with-label-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-toast/all-variants-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-toast/all-variants-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-toast/all-variants-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-toast/all-variants-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-toast/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-toast/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-toast/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-toast/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-toast/error-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-toast/error-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-toast/error-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-toast/error-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-toast/loading-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-toast/loading-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-toast/loading-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-toast/loading-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-toast/success-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-toast/success-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-toast/success-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-toast/success-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-toast/with-actions-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-toast/with-actions-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-toast/with-actions-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-toast/with-actions-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-toast/with-icon-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-toast/with-icon-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-toast/with-icon-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-toast/with-icon-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tooltip/default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tooltip/default-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tooltip/default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tooltip/default-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tooltip/force-open-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tooltip/force-open-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tooltip/force-open-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tooltip/force-open-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tooltip/placement-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tooltip/placement-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tooltip/placement-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tooltip/placement-chromium-linux.png diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tooltip/with-keybind-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tooltip/with-keybind-chromium-linux.png similarity index 100% rename from packages/kilo-ui/tests/visual-regression.spec.ts-snapshots/components-tooltip/with-keybind-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-ui/visual-regression/components-tooltip/with-keybind-chromium-linux.png diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-expanded-pending-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/bash-expanded-pending-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-expanded-pending-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/bash-expanded-pending-chromium-linux.png diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rule-approved-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/bash-rule-approved-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rule-approved-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/bash-rule-approved-chromium-linux.png diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rule-denied-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/bash-rule-denied-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rule-denied-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/bash-rule-denied-chromium-linux.png diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rules-mixed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/bash-rules-mixed-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/bash-rules-mixed-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/bash-rules-mixed-chromium-linux.png diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/config-preloaded-expanded-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/config-preloaded-expanded-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/config-preloaded-expanded-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/config-preloaded-expanded-chromium-linux.png diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/edit-expanded-pending-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/edit-expanded-pending-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/edit-expanded-pending-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/edit-expanded-pending-chromium-linux.png diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/external-dir-expanded-pending-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/external-dir-expanded-pending-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/external-dir-expanded-pending-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/external-dir-expanded-pending-chromium-linux.png diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/glob-expanded-pending-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/glob-expanded-pending-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/glob-expanded-pending-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/glob-expanded-pending-chromium-linux.png diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/glob-rule-approved-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/glob-rule-approved-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/glob-rule-approved-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/glob-rule-approved-chromium-linux.png diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/many-rules-expanded-pending-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/many-rules-expanded-pending-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/many-rules-expanded-pending-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/many-rules-expanded-pending-chromium-linux.png diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/many-rules-mixed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/many-rules-mixed-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/many-rules-mixed-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/many-rules-mixed-chromium-linux.png diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/subagent-expanded-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/subagent-expanded-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/subagent-expanded-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/subagent-expanded-chromium-linux.png diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/websearch-expanded-pending-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/websearch-expanded-pending-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/websearch-expanded-pending-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/websearch-expanded-pending-chromium-linux.png diff --git a/packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/write-expanded-pending-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/write-expanded-pending-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/permission-dock-dropdown.spec.ts-snapshots/permission-dock-dropdown/write-expanded-pending-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/permission-dock-dropdown/write-expanded-pending-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/all-colors-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/all-colors-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/all-colors-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/all-colors-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/collapsed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/collapsed-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/collapsed-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/collapsed-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/default-color-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/default-color-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/default-color-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/default-color-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/dense-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/dense-sidebar-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/dense-sidebar-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/dense-sidebar-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/empty-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/empty-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/empty-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/expanded-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/expanded-with-items-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/expanded-with-items-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/expanded-with-items-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/first-and-last-section-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/first-and-last-section-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/first-and-last-section-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/first-and-last-section-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/long-section-name-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/long-section-name-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/long-section-name-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/long-section-name-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/multiple-sections-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/multiple-sections-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/multiple-sections-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/multiple-sections-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/with-active-worktree-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-active-worktree-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/with-active-worktree-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-active-worktree-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/with-busy-worktree-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-busy-worktree-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/with-busy-worktree-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-busy-worktree-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/with-pr-badges-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/with-pr-badges-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-pr-badges-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/with-stale-worktree-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-stale-worktree-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/with-stale-worktree-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-stale-worktree-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/with-versions-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-versions-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager-sections/with-versions-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager-sections/with-versions-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/diff-panel-with-diffs-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/diff-panel-with-diffs-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/diff-panel-with-diffs-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/file-tree-empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/file-tree-empty-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/file-tree-empty-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/file-tree-empty-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/file-tree-with-changes-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/file-tree-with-changes-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/file-tree-with-changes-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/file-tree-with-changes-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/full-screen-diff-with-changes-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/full-screen-diff-with-changes-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/full-screen-diff-with-changes-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-checks-failing-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-approved-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-approved-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-approved-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-changes-requested-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-changes-requested-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-changes-requested-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-changes-requested-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-checks-failing-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-checks-failing-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-checks-failing-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-closed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-closed-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-closed-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-closed-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-draft-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-draft-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-draft-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-draft-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-merged-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-merged-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-merged-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-merged-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-no-review-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-no-review-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-no-review-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-no-review-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-pending-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-pending-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/pr-badge-pending-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/pr-badge-pending-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-multiple-tabs-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-multiple-tabs-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-multiple-tabs-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-multiple-tabs-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-single-tab-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-single-tab-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-single-tab-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-single-tab-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-with-review-tab-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-with-review-tab-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/tab-bar-with-review-tab-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/tab-bar-with-review-tab-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-active-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-active-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-active-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-active-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-busy-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-busy-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-busy-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-busy-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-default-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-default-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-default-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-default-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-grouped-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-grouped-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-grouped-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-grouped-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-pending-delete-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-pending-delete-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-pending-delete-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-pending-delete-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-stale-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-stale-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-stale-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-stale-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-with-stats-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-with-stats-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/agentmanager/worktree-item-with-stats-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/worktree-item-with-stats-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-idle-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-idle-chromium-linux.png new file mode 100644 index 00000000000..32ab1dbd8c6 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-idle-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fbf2dc34c9c1ba549244a750a5ce828089fc378fa2352645c9a2267270a0c08 +size 18021 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/chat-view-with-messages-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-with-messages-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/chat-view-with-messages-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-with-messages-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/chat-view-with-pending-question-empty-input-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-with-pending-question-empty-input-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/chat-view-with-pending-question-empty-input-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-with-pending-question-empty-input-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-subagent-to-queued-user-spacing-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-subagent-to-queued-user-spacing-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-subagent-to-queued-user-spacing-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-subagent-to-queued-user-spacing-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/question-dock-many-options-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-many-options-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/question-dock-many-options-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-many-options-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/question-dock-multi-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-multi-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/question-dock-multi-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-multi-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/question-dock-single-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-single-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/question-dock-single-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/question-dock-single-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/suggest-bar-review-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/suggest-bar-review-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/suggest-bar-review-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/suggest-bar-review-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/task-header-with-todos-all-done-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-all-done-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/task-header-with-todos-all-done-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-all-done-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png new file mode 100644 index 00000000000..f1e6bc8a35b --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/task-header-with-todos-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d34153690f8aa4ef7e06e12385a184c151843f3ae75a9955619a6d7e88dc8c8b +size 7981 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/welcome-with-switcher-and-notification-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/welcome-with-switcher-and-notification-chromium-linux.png new file mode 100644 index 00000000000..96ce67cb9ae --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/welcome-with-switcher-and-notification-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d2cdb6dfc9a907b8ff98bb1982f324efa6d19e4ee98f9f690b8adbc2a0a163de +size 28994 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/components-shell/shell-execution-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/components-shell/shell-execution-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/components-shell/shell-execution-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/components-shell/shell-execution-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/bash-with-permission-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/bash-with-permission-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/bash-with-permission-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/bash-with-permission-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-busy-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-busy-chromium-linux.png new file mode 100644 index 00000000000..dad76b65cf7 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-busy-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5584b7b6d9cc3b80082e1d6fb24eda43d084fb437cf10c15fa3117e99ad9e89 +size 4300 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/chat-idle-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-idle-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/chat-idle-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-idle-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png new file mode 100644 index 00000000000..04959213872 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d29bb3a9356cf8bec6bb0a9d6e4d0b59d147c1fa8adee7140c81a061eea407d +size 6930 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/glob-with-permission-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/glob-with-permission-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/glob-with-permission-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/glob-with-permission-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-cards-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-cards-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-cards-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-cards-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png new file mode 100644 index 00000000000..4aa53ac29e4 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c715bd9f470078fea565d3884395009981235a5e6de068c7c529f77f9dd9ebec +size 26962 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/multiple-tool-calls-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/multiple-tool-calls-chromium-linux.png new file mode 100644 index 00000000000..3937f335fc9 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/multiple-tool-calls-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6c05d543611aff7bf10ef406de42844fa1aa825275e380c6ded05d03170aa9b0 +size 8057 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png new file mode 100644 index 00000000000..a17e69d6219 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-apply-patch-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7aa0924c6679cbff6e816dd86371a2d483afb7f0840824503ef4ba325c25d6c5 +size 26282 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-bash-many-rules-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-bash-many-rules-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-bash-many-rules-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-bash-many-rules-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-config-preloaded-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-config-preloaded-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-config-preloaded-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-config-preloaded-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png new file mode 100644 index 00000000000..cdca3764fb9 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-edit-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f28a551185ec9d7607d02295803b155e0f7bac378fcefdf0996c6ca51c0afad6 +size 21892 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-external-dir-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-external-dir-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-external-dir-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-external-dir-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-heredoc-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-heredoc-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-heredoc-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-subagent-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-subagent-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-subagent-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-subagent-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-todo-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-todo-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-todo-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-todo-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-websearch-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-websearch-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-websearch-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-websearch-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-write-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-write-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/permission-dock-write-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/permission-dock-write-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-above-chatbox-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-above-chatbox-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-above-chatbox-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-above-chatbox-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-dismissed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-dismissed-chromium-linux.png new file mode 100644 index 00000000000..1260df31e9d --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-dismissed-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b1ebe718b7747f0b0db029396c728ae859f388f11cf206bb020fcd7027f3b0a0 +size 5046 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-compact-update-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-compact-update-chromium-linux.png new file mode 100644 index 00000000000..bb7d8b71d2d --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-compact-update-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9b47f6adfc72399cb1535be3a69661b027eec3068880aba35821d5240e0c3492 +size 16739 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/todo-write-completed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-completed-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/todo-write-completed-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-completed-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-docs-overview-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-docs-overview-chromium-linux.png new file mode 100644 index 00000000000..c8ecdbcbb55 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-docs-overview-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4d7ac9d6322d2cf673a6353cb07cf9979ad55351769ba9d90eed386170f6c42 +size 40854 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/todo-write-with-permission-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-with-permission-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/todo-write-with-permission-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/todo-write-with-permission-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/tool-cards-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/tool-cards-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/history-sessionlist/with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/history-sessionlist/with-items-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/history-sessionlist/with-items-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/history-sessionlist/with-items-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/installed-mcp-card-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/installed-mcp-card-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/installed-mcp-card-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/installed-mcp-card-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/installed-mode-card-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/installed-mode-card-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/installed-mode-card-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/installed-mode-card-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/installed-skill-card-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/installed-skill-card-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/installed-skill-card-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/installed-skill-card-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/mcp-tab-empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-empty-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/mcp-tab-empty-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-empty-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/mcp-tab-with-installed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-installed-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/mcp-tab-with-installed-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-installed-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/mcp-tab-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-items-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/mcp-tab-with-items-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/mcp-tab-with-items-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/modes-tab-empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/modes-tab-empty-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/modes-tab-empty-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/modes-tab-empty-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/modes-tab-with-installed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/modes-tab-with-installed-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/modes-tab-with-installed-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/modes-tab-with-installed-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/modes-tab-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/modes-tab-with-items-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/modes-tab-with-items-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/modes-tab-with-items-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/single-mcp-card-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/single-mcp-card-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/single-mcp-card-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/single-mcp-card-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/single-mode-card-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/single-mode-card-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/single-mode-card-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/single-mode-card-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/single-skill-card-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/single-skill-card-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/single-skill-card-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/single-skill-card-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/skills-tab-empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-empty-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/skills-tab-empty-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-empty-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/skills-tab-with-installed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-installed-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/skills-tab-with-installed-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-installed-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/skills-tab-with-items-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-items-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/marketplace/skills-tab-with-items-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/marketplace/skills-tab-with-items-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/profile/logged-in-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/profile/logged-in-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/profile/logged-in-personal-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-personal-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/profile/logged-in-personal-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/logged-in-personal-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/profile/not-logged-in-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/not-logged-in-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/profile/not-logged-in-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/profile/not-logged-in-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/default-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-200-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/default-200-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-200-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/default-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-420-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/default-420-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/default-420-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/with-model-override-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-200-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/with-model-override-200-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-200-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/with-model-override-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-420-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/with-model-override-420-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-420-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/with-thinking-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-200-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/with-thinking-200-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-200-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/with-thinking-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-420-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/prompt-input/with-thinking-420-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-thinking-420-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/agent-behaviour-agents-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-agents-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/agent-behaviour-agents-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-agents-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/agent-behaviour-edit-custom-mode-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-edit-custom-mode-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/agent-behaviour-edit-custom-mode-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-edit-custom-mode-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/agent-behaviour-workflows-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-workflows-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/agent-behaviour-workflows-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-workflows-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/agent-behaviour-workflows-empty-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-workflows-empty-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/agent-behaviour-workflows-empty-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/agent-behaviour-workflows-empty-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png new file mode 100644 index 00000000000..9535764bbbb --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/indexing-provider-blur-race-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6edf67819cd2115c4c85a15b16f7b948a2cb48f19c15ee66ea606f3d0e1971aa +size 58183 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/mcp-edit-view-local-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mcp-edit-view-local-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/mcp-edit-view-local-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mcp-edit-view-local-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/mcp-edit-view-local-with-env-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mcp-edit-view-local-with-env-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/mcp-edit-view-local-with-env-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mcp-edit-view-local-with-env-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/mcp-edit-view-remote-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mcp-edit-view-remote-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/mcp-edit-view-remote-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mcp-edit-view-remote-chromium-linux.png diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/mode-edit-export-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-export-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/settings/mode-edit-export-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/mode-edit-export-chromium-linux.png diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/providers-configure-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/providers-configure-chromium-linux.png new file mode 100644 index 00000000000..996569f48fb --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/providers-configure-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9c87ac5179843ad5dadf73023f87d1f0b0589a81420e9ef406534a335598a0a2 +size 28082 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png new file mode 100644 index 00000000000..2a9f410b3bb --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/settings/settings-panel-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:16e602e6a870e4b66a466a0895f92e9df117f6137809c79ed797f24d2b6a1e88 +size 31028 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/shared/model-selector-no-providers-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-no-providers-chromium-linux.png similarity index 100% rename from packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/shared/model-selector-no-providers-chromium-linux.png rename to packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/model-selector-no-providers-chromium-linux.png diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index b666153be24..d80641de90a 100644 --- a/packages/kilo-docs/source-links.md +++ b/packages/kilo-docs/source-links.md @@ -1,7 +1,7 @@ # Source Code Links - + - @@ -16,6 +16,7 @@ - + - @@ -33,6 +34,8 @@ - +- + - - @@ -103,7 +106,7 @@ - - - + - @@ -119,11 +122,13 @@ - - + - - +- + - - diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index f13f58bad65..68ed19044c4 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.2.14", + "version": "7.2.30", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", @@ -33,7 +33,7 @@ "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/openai": "3.0.48", "@ai-sdk/openai-compatible": "2.0.37", - "@openrouter/ai-sdk-provider": "2.5.1", + "@openrouter/ai-sdk-provider": "2.8.1", "@clack/prompts": "1.0.0-alpha.1", "ai": "catalog:", "open": "10.1.2", diff --git a/packages/kilo-gateway/src/api/constants.ts b/packages/kilo-gateway/src/api/constants.ts index 5c020ab6b0b..d7a1eb9b17b 100644 --- a/packages/kilo-gateway/src/api/constants.ts +++ b/packages/kilo-gateway/src/api/constants.ts @@ -70,6 +70,6 @@ export const HEADER_FEATURE = "X-KILOCODE-FEATURE" /** Environment variable name for feature override */ export const ENV_FEATURE = "KILOCODE_FEATURE" -export const PROMPTS = ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo"] as const +export const PROMPTS = ["codex", "gemini", "beast", "anthropic", "trinity", "anthropic_without_todo", "ling"] as const export const AI_SDK_PROVIDERS = ["alibaba", "anthropic", "openai", "openai-compatible", "openrouter"] as const diff --git a/packages/kilo-gateway/src/loader.ts b/packages/kilo-gateway/src/loader.ts index 10e0bc3bb8d..f108cf2d0d7 100644 --- a/packages/kilo-gateway/src/loader.ts +++ b/packages/kilo-gateway/src/loader.ts @@ -26,19 +26,6 @@ export async function kiloCustomLoader(provider: ProviderInfo): Promise 0 || value.cost?.output > 0) { - delete provider.models[key] - } - } - const freeCount = Object.keys(provider.models).length - console.log( - `[kilo-provider] Filtered to ${freeCount} free models (removed ${initialCount - freeCount} paid models)`, - ) - } - const autoload = Object.keys(provider.models).length > 0 console.log(`[kilo-provider] Autoload: ${autoload}`) diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index 6a79aa01ec3..0282e2f94bc 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -35,6 +35,8 @@ interface KiloRoutesDeps extends ImportDeps { Instance: ImportDeps["Instance"] & { disposeAll(): Promise } } +const FIM_TIMEOUT_MS = 30_000 + /** * Create Kilo Gateway routes with OpenCode dependencies injected * @@ -110,6 +112,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { content: z.string().optional(), }) .optional(), + text: z.string().optional(), // Text-completion style streaming (Mercury) }), ) .optional(), @@ -342,25 +345,35 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { [HEADER_FEATURE]: "autocomplete", } - const response = await fetch(endpoint, { - method: "POST", - headers, - body: JSON.stringify({ - model: fimModel, - prompt: prefix, - suffix, - max_tokens: fimMaxTokens, - temperature: fimTemperature, - stream: true, - }), - }) + const signal = AbortSignal.any([c.req.raw.signal, AbortSignal.timeout(FIM_TIMEOUT_MS)]) + let response: Response + try { + response = await fetch(endpoint, { + method: "POST", + headers, + signal, + body: JSON.stringify({ + model: fimModel, + prompt: prefix, + suffix, + max_tokens: fimMaxTokens, + temperature: fimTemperature, + stream: true, + }), + }) + } catch (err) { + if (err instanceof DOMException && err.name === "TimeoutError") + return c.json({ error: "FIM request timed out" }, 504 as any) + if (signal.aborted) return c.json({ error: "FIM request canceled" }, 499 as any) + throw err + } + if (!response.ok) { - const errorText = await response.text() - return c.json({ error: `FIM request failed: ${response.status} ${errorText}` }, response.status as any) + const text = await response.text() + return c.json({ error: `FIM request failed: ${response.status} ${text}` }, response.status as any) } - // Stream the response through return new Response(response.body, { headers: { "Content-Type": "text/event-stream", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 46c1fff9b89..bcfd9604fea 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.2.14", + "version": "7.2.30", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-i18n/src/ar.ts b/packages/kilo-i18n/src/ar.ts index ee2a0ffdaf2..3a6374008bc 100644 --- a/packages/kilo-i18n/src/ar.ts +++ b/packages/kilo-i18n/src/ar.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "إلغاء", "marketplace.contribute.prompt": "هل تفتقد مهارة أو وضعًا أو خادم MCP؟", "marketplace.contribute.cta": "ساهم على GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "نفّذ", + "plan.followup.question": "جاهز للتنفيذ؟", + "plan.followup.answer.newSession": "بدء جلسة جديدة", + "plan.followup.answer.newSession.description": "نفّذ في جلسة جديدة بسياق نظيف", + "plan.followup.answer.continue": "المتابعة هنا", + "plan.followup.answer.continue.description": "نفّذ الخطة في هذه الجلسة", } diff --git a/packages/kilo-i18n/src/br.ts b/packages/kilo-i18n/src/br.ts index 38bfb6ae825..5a9db6c39d0 100644 --- a/packages/kilo-i18n/src/br.ts +++ b/packages/kilo-i18n/src/br.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Cancelar", "marketplace.contribute.prompt": "Está faltando uma skill, modo ou servidor MCP?", "marketplace.contribute.cta": "Contribuir no GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementar", + "plan.followup.question": "Pronto para implementar?", + "plan.followup.answer.newSession": "Iniciar nova sessão", + "plan.followup.answer.newSession.description": "Implementar em uma nova sessão com contexto limpo", + "plan.followup.answer.continue": "Continuar aqui", + "plan.followup.answer.continue.description": "Implementar o plano nesta sessão", } diff --git a/packages/kilo-i18n/src/bs.ts b/packages/kilo-i18n/src/bs.ts index 63aad6b807e..1b88c607dbc 100644 --- a/packages/kilo-i18n/src/bs.ts +++ b/packages/kilo-i18n/src/bs.ts @@ -76,4 +76,12 @@ export const dict = { "marketplace.warning.cancel": "Otkaži", "marketplace.contribute.prompt": "Nedostaje vještina, način rada ili MCP server?", "marketplace.contribute.cta": "Doprinesi na GitHub-u", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementiraj", + "plan.followup.question": "Spreman za implementaciju?", + "plan.followup.answer.newSession": "Pokreni novu sesiju", + "plan.followup.answer.newSession.description": "Implementiraj u novoj sesiji s čistim kontekstom", + "plan.followup.answer.continue": "Nastavi ovdje", + "plan.followup.answer.continue.description": "Implementiraj plan u ovoj sesiji", } diff --git a/packages/kilo-i18n/src/da.ts b/packages/kilo-i18n/src/da.ts index 53442d3736b..912ecaaad72 100644 --- a/packages/kilo-i18n/src/da.ts +++ b/packages/kilo-i18n/src/da.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Annuller", "marketplace.contribute.prompt": "Mangler du en skill, tilstand eller MCP-server?", "marketplace.contribute.cta": "Bidrag på GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementér", + "plan.followup.question": "Klar til at implementere?", + "plan.followup.answer.newSession": "Start ny session", + "plan.followup.answer.newSession.description": "Implementér i en ny session med ren kontekst", + "plan.followup.answer.continue": "Fortsæt her", + "plan.followup.answer.continue.description": "Implementér planen i denne session", } diff --git a/packages/kilo-i18n/src/de.ts b/packages/kilo-i18n/src/de.ts index 190ceff2d67..2e08b98b273 100644 --- a/packages/kilo-i18n/src/de.ts +++ b/packages/kilo-i18n/src/de.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Abbrechen", "marketplace.contribute.prompt": "Fehlt ein Skill, Modus oder MCP-Server?", "marketplace.contribute.cta": "Auf GitHub beitragen", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Umsetzen", + "plan.followup.question": "Bereit zur Umsetzung?", + "plan.followup.answer.newSession": "Neue Sitzung starten", + "plan.followup.answer.newSession.description": "In einer neuen Sitzung mit leerem Kontext umsetzen", + "plan.followup.answer.continue": "Hier fortfahren", + "plan.followup.answer.continue.description": "Den Plan in dieser Sitzung umsetzen", } diff --git a/packages/kilo-i18n/src/en.ts b/packages/kilo-i18n/src/en.ts index 2e1f38828c5..cacebb54f97 100644 --- a/packages/kilo-i18n/src/en.ts +++ b/packages/kilo-i18n/src/en.ts @@ -73,4 +73,14 @@ export const dict = { "marketplace.warning.cancel": "Cancel", "marketplace.contribute.prompt": "Missing a skill, mode, or MCP server?", "marketplace.contribute.cta": "Contribute on GitHub", + + // Plan follow-up question shown after plan_exit. The English strings here must match + // the canonical `label`/`header`/`question` sent by the backend — those canonical labels + // are still what the backend matches on (see packages/opencode/src/kilocode/plan-followup.ts). + "plan.followup.header": "Implement", + "plan.followup.question": "Ready to implement?", + "plan.followup.answer.newSession": "Start new session", + "plan.followup.answer.newSession.description": "Implement in a fresh session with a clean context", + "plan.followup.answer.continue": "Continue here", + "plan.followup.answer.continue.description": "Implement the plan in this session", } diff --git a/packages/kilo-i18n/src/es.ts b/packages/kilo-i18n/src/es.ts index 62fa5ec0ac2..9325ae29a42 100644 --- a/packages/kilo-i18n/src/es.ts +++ b/packages/kilo-i18n/src/es.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Cancelar", "marketplace.contribute.prompt": "¿Falta una skill, modo o servidor MCP?", "marketplace.contribute.cta": "Contribuir en GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementar", + "plan.followup.question": "¿Listo para implementar?", + "plan.followup.answer.newSession": "Iniciar nueva sesión", + "plan.followup.answer.newSession.description": "Implementar en una sesión nueva con contexto limpio", + "plan.followup.answer.continue": "Continuar aquí", + "plan.followup.answer.continue.description": "Implementar el plan en esta sesión", } diff --git a/packages/kilo-i18n/src/fr.ts b/packages/kilo-i18n/src/fr.ts index 43e34edc4dd..51b5735b3e2 100644 --- a/packages/kilo-i18n/src/fr.ts +++ b/packages/kilo-i18n/src/fr.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Annuler", "marketplace.contribute.prompt": "Il manque une skill, un mode ou un serveur MCP ?", "marketplace.contribute.cta": "Contribuer sur GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implémenter", + "plan.followup.question": "Prêt à implémenter ?", + "plan.followup.answer.newSession": "Démarrer une nouvelle session", + "plan.followup.answer.newSession.description": "Implémenter dans une nouvelle session avec un contexte vierge", + "plan.followup.answer.continue": "Continuer ici", + "plan.followup.answer.continue.description": "Implémenter le plan dans cette session", } diff --git a/packages/kilo-i18n/src/ja.ts b/packages/kilo-i18n/src/ja.ts index f75cccfb4ae..bf1ae85ff55 100644 --- a/packages/kilo-i18n/src/ja.ts +++ b/packages/kilo-i18n/src/ja.ts @@ -69,4 +69,12 @@ export const dict = { "marketplace.warning.cancel": "キャンセル", "marketplace.contribute.prompt": "スキル、モード、MCP サーバーが見つかりませんか?", "marketplace.contribute.cta": "GitHub で貢献する", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "実装", + "plan.followup.question": "実装する準備はできましたか?", + "plan.followup.answer.newSession": "新しいセッションを開始", + "plan.followup.answer.newSession.description": "クリーンなコンテキストの新しいセッションで実装する", + "plan.followup.answer.continue": "ここで続行", + "plan.followup.answer.continue.description": "このセッションで計画を実装する", } diff --git a/packages/kilo-i18n/src/ko.ts b/packages/kilo-i18n/src/ko.ts index 23c9fe1740f..f1260654972 100644 --- a/packages/kilo-i18n/src/ko.ts +++ b/packages/kilo-i18n/src/ko.ts @@ -69,4 +69,12 @@ export const dict = { "marketplace.warning.cancel": "취소", "marketplace.contribute.prompt": "스킬, 모드 또는 MCP 서버가 없나요?", "marketplace.contribute.cta": "GitHub에서 기여하기", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "구현", + "plan.followup.question": "구현할 준비가 되셨나요?", + "plan.followup.answer.newSession": "새 세션 시작", + "plan.followup.answer.newSession.description": "깨끗한 컨텍스트의 새 세션에서 구현", + "plan.followup.answer.continue": "여기서 계속하기", + "plan.followup.answer.continue.description": "이 세션에서 계획 구현", } diff --git a/packages/kilo-i18n/src/nl.ts b/packages/kilo-i18n/src/nl.ts index 00072f19847..20e2dd6d1c9 100644 --- a/packages/kilo-i18n/src/nl.ts +++ b/packages/kilo-i18n/src/nl.ts @@ -73,4 +73,12 @@ export const dict = { "marketplace.warning.cancel": "Annuleren", "marketplace.contribute.prompt": "Mist er een skill, modus of MCP-server?", "marketplace.contribute.cta": "Bijdragen op GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementeren", + "plan.followup.question": "Klaar om te implementeren?", + "plan.followup.answer.newSession": "Nieuwe sessie starten", + "plan.followup.answer.newSession.description": "Implementeren in een nieuwe sessie met een lege context", + "plan.followup.answer.continue": "Hier doorgaan", + "plan.followup.answer.continue.description": "Het plan in deze sessie implementeren", } diff --git a/packages/kilo-i18n/src/no.ts b/packages/kilo-i18n/src/no.ts index 85cfce2679e..e1c1ac61bcf 100644 --- a/packages/kilo-i18n/src/no.ts +++ b/packages/kilo-i18n/src/no.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Avbryt", "marketplace.contribute.prompt": "Mangler du en skill, modus eller MCP-server?", "marketplace.contribute.cta": "Bidra på GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Implementer", + "plan.followup.question": "Klar til å implementere?", + "plan.followup.answer.newSession": "Start ny økt", + "plan.followup.answer.newSession.description": "Implementer i en ny økt med ren kontekst", + "plan.followup.answer.continue": "Fortsett her", + "plan.followup.answer.continue.description": "Implementer planen i denne økten", } diff --git a/packages/kilo-i18n/src/pl.ts b/packages/kilo-i18n/src/pl.ts index 48d436413c3..0593170d9b0 100644 --- a/packages/kilo-i18n/src/pl.ts +++ b/packages/kilo-i18n/src/pl.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Anuluj", "marketplace.contribute.prompt": "Brakuje skilla, trybu lub serwera MCP?", "marketplace.contribute.cta": "Wnieś wkład na GitHubie", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Wdróż", + "plan.followup.question": "Gotowy do wdrożenia?", + "plan.followup.answer.newSession": "Rozpocznij nową sesję", + "plan.followup.answer.newSession.description": "Wdróż w nowej sesji z czystym kontekstem", + "plan.followup.answer.continue": "Kontynuuj tutaj", + "plan.followup.answer.continue.description": "Wdróż plan w tej sesji", } diff --git a/packages/kilo-i18n/src/ru.ts b/packages/kilo-i18n/src/ru.ts index 0ac0e661fd8..74acae59272 100644 --- a/packages/kilo-i18n/src/ru.ts +++ b/packages/kilo-i18n/src/ru.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Отмена", "marketplace.contribute.prompt": "Не хватает навыка, режима или MCP-сервера?", "marketplace.contribute.cta": "Внести вклад на GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Реализовать", + "plan.followup.question": "Готовы реализовать?", + "plan.followup.answer.newSession": "Начать новую сессию", + "plan.followup.answer.newSession.description": "Реализовать в новой сессии с чистым контекстом", + "plan.followup.answer.continue": "Продолжить здесь", + "plan.followup.answer.continue.description": "Реализовать план в этой сессии", } diff --git a/packages/kilo-i18n/src/th.ts b/packages/kilo-i18n/src/th.ts index d8a03606c75..48b02146af5 100644 --- a/packages/kilo-i18n/src/th.ts +++ b/packages/kilo-i18n/src/th.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "ยกเลิก", "marketplace.contribute.prompt": "ไม่พบทักษะ โหมด หรือเซิร์ฟเวอร์ MCP ที่ต้องการ?", "marketplace.contribute.cta": "ร่วมสมทบบน GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "ดำเนินการ", + "plan.followup.question": "พร้อมดำเนินการหรือยัง?", + "plan.followup.answer.newSession": "เริ่มเซสชันใหม่", + "plan.followup.answer.newSession.description": "ดำเนินการในเซสชันใหม่ที่มีบริบทว่างเปล่า", + "plan.followup.answer.continue": "ดำเนินการต่อที่นี่", + "plan.followup.answer.continue.description": "ดำเนินการตามแผนในเซสชันนี้", } diff --git a/packages/kilo-i18n/src/tr.ts b/packages/kilo-i18n/src/tr.ts index 0a3a39d3dbd..e307f790772 100644 --- a/packages/kilo-i18n/src/tr.ts +++ b/packages/kilo-i18n/src/tr.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "İptal", "marketplace.contribute.prompt": "Bir yetenek, mod veya MCP sunucusu mu eksik?", "marketplace.contribute.cta": "GitHub'da katkıda bulun", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Uygula", + "plan.followup.question": "Uygulamaya hazır mısın?", + "plan.followup.answer.newSession": "Yeni oturum başlat", + "plan.followup.answer.newSession.description": "Temiz bir bağlamla yeni bir oturumda uygula", + "plan.followup.answer.continue": "Burada devam et", + "plan.followup.answer.continue.description": "Planı bu oturumda uygula", } diff --git a/packages/kilo-i18n/src/uk.ts b/packages/kilo-i18n/src/uk.ts index cb3b1090ce0..7d1707a3e51 100644 --- a/packages/kilo-i18n/src/uk.ts +++ b/packages/kilo-i18n/src/uk.ts @@ -71,4 +71,12 @@ export const dict = { "marketplace.warning.cancel": "Скасувати", "marketplace.contribute.prompt": "Бракує навички, режиму або MCP-сервера?", "marketplace.contribute.cta": "Зробити внесок на GitHub", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "Реалізувати", + "plan.followup.question": "Готові реалізувати?", + "plan.followup.answer.newSession": "Почати нову сесію", + "plan.followup.answer.newSession.description": "Реалізувати в новій сесії з чистим контекстом", + "plan.followup.answer.continue": "Продовжити тут", + "plan.followup.answer.continue.description": "Реалізувати план у цій сесії", } diff --git a/packages/kilo-i18n/src/zh.ts b/packages/kilo-i18n/src/zh.ts index d444f4e8eec..d5cd78e8778 100644 --- a/packages/kilo-i18n/src/zh.ts +++ b/packages/kilo-i18n/src/zh.ts @@ -68,4 +68,12 @@ export const dict = { "marketplace.warning.cancel": "取消", "marketplace.contribute.prompt": "缺少技能、模式或 MCP 服务器?", "marketplace.contribute.cta": "在 GitHub 上贡献", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "实现", + "plan.followup.question": "准备好实现了吗?", + "plan.followup.answer.newSession": "开启新会话", + "plan.followup.answer.newSession.description": "在具有干净上下文的新会话中实现", + "plan.followup.answer.continue": "在此继续", + "plan.followup.answer.continue.description": "在本会话中实现计划", } diff --git a/packages/kilo-i18n/src/zht.ts b/packages/kilo-i18n/src/zht.ts index 275727428b8..5cdb8dff670 100644 --- a/packages/kilo-i18n/src/zht.ts +++ b/packages/kilo-i18n/src/zht.ts @@ -68,4 +68,12 @@ export const dict = { "marketplace.warning.cancel": "取消", "marketplace.contribute.prompt": "缺少技能、模式或 MCP 伺服器?", "marketplace.contribute.cta": "在 GitHub 上貢獻", + + // Plan follow-up question shown after plan_exit + "plan.followup.header": "實作", + "plan.followup.question": "準備好實作了嗎?", + "plan.followup.answer.newSession": "開啟新工作階段", + "plan.followup.answer.newSession.description": "在具有乾淨上下文的新工作階段中實作", + "plan.followup.answer.continue": "在此繼續", + "plan.followup.answer.continue.description": "在本工作階段中實作計畫", } diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json new file mode 100644 index 00000000000..2c7531121a5 --- /dev/null +++ b/packages/kilo-indexing/package.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@kilocode/kilo-indexing", + "version": "7.2.30", + "type": "module", + "license": "MIT", + "description": "Standalone indexing engine and host helpers for Kilo Code", + "keywords": [ + "kilo", + "kilocode", + "indexing", + "plugin", + "search", + "embeddings" + ], + "exports": { + ".": "./src/index.ts", + "./config": "./src/config.ts", + "./detect": "./src/detect.ts", + "./engine": "./src/indexing/index.ts", + "./server": "./src/server/routes.ts", + "./status": "./src/status.ts" + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "typecheck": "tsgo --noEmit", + "build": "tsc", + "test": "bun test --timeout 30000" + }, + "dependencies": { + "@aws-sdk/client-bedrock-runtime": "3.1005.0", + "@aws-sdk/credential-provider-ini": "3.972.31", + "@kilocode/kilo-gateway": "workspace:*", + "@kilocode/plugin": "workspace:*", + "@lancedb/lancedb": "0.26.2", + "@qdrant/js-client-rest": "1.17.0", + "async-mutex": "0.5.0", + "chokidar": "4.0.3", + "glob": "13.0.6", + "hono": "catalog:", + "hono-openapi": "catalog:", + "ignore": "7.0.5", + "minimatch": "10.2.5", + "openai": "6.27.0", + "p-limit": "7.3.0", + "tree-sitter-wasms": "0.1.13", + "uuid": "14.0.0", + "web-tree-sitter": "0.25.10", + "zod": "catalog:" + }, + "devDependencies": { + "@tsconfig/node22": "catalog:", + "@types/node": "catalog:", + "@typescript/native-preview": "catalog:", + "typescript": "catalog:" + } +} diff --git a/packages/kilo-indexing/src/config.ts b/packages/kilo-indexing/src/config.ts new file mode 100644 index 00000000000..e9811da4549 --- /dev/null +++ b/packages/kilo-indexing/src/config.ts @@ -0,0 +1,150 @@ +import z from "zod" +import type { IndexingConfigInput } from "./indexing/config-manager" +import type { EmbedderProvider } from "./indexing/interfaces/manager" + +const providers = [ + "openai", + "ollama", + "openai-compatible", + "gemini", + "mistral", + "vercel-ai-gateway", + "bedrock", + "openrouter", + "voyage", +] as const satisfies readonly EmbedderProvider[] + +export const IndexingConfig = z + .object({ + enabled: z.boolean().optional().describe("Enable codebase indexing"), + provider: z.enum(providers).optional().describe("Embedding provider to use for codebase indexing"), + model: z.string().optional().describe("Embedding model ID (uses provider default if omitted)"), + dimension: z + .number() + .int() + .positive() + .optional() + .describe("Override embedding vector dimension (auto-detected from model if omitted)"), + vectorStore: z.enum(["lancedb", "qdrant"]).optional().describe("Vector store backend (default: qdrant)"), + openai: z + .object({ apiKey: z.string().optional() }) + .strict() + .optional() + .describe("OpenAI embedding provider options"), + ollama: z + .object({ baseUrl: z.string().optional() }) + .strict() + .optional() + .describe("Ollama embedding provider options"), + "openai-compatible": z + .object({ + baseUrl: z.string().optional(), + apiKey: z.string().optional(), + }) + .strict() + .optional() + .describe("OpenAI-compatible embedding provider options"), + gemini: z + .object({ apiKey: z.string().optional() }) + .strict() + .optional() + .describe("Gemini embedding provider options"), + mistral: z + .object({ apiKey: z.string().optional() }) + .strict() + .optional() + .describe("Mistral embedding provider options"), + "vercel-ai-gateway": z + .object({ apiKey: z.string().optional() }) + .strict() + .optional() + .describe("Vercel AI Gateway embedding provider options"), + bedrock: z + .object({ + region: z.string().optional(), + profile: z.string().optional(), + }) + .strict() + .optional() + .describe("AWS Bedrock embedding provider options"), + openrouter: z + .object({ + apiKey: z.string().optional(), + specificProvider: z.string().optional(), + }) + .strict() + .optional() + .describe("OpenRouter embedding provider options"), + voyage: z + .object({ apiKey: z.string().optional() }) + .strict() + .optional() + .describe("Voyage embedding provider options"), + qdrant: z + .object({ + url: z.string().optional(), + apiKey: z.string().optional(), + }) + .strict() + .optional() + .describe("Qdrant vector store connection options"), + lancedb: z + .object({ directory: z.string().optional() }) + .strict() + .optional() + .describe("LanceDB vector store options"), + searchMinScore: z + .number() + .min(0) + .max(1) + .optional() + .describe("Minimum similarity score for search results (default: 0.4)"), + searchMaxResults: z.number().int().positive().optional().describe("Maximum number of search results (default: 50)"), + embeddingBatchSize: z + .number() + .int() + .positive() + .optional() + .describe("Number of code segments per embedding batch (default: 60)"), + scannerMaxBatchRetries: z + .number() + .int() + .positive() + .optional() + .describe("Maximum retry attempts for failed embedding batches (default: 3)"), + }) + .strict() + .meta({ ref: "IndexingConfig" }) + +export type IndexingConfig = z.infer + +export function toIndexingConfigInput(cfg: IndexingConfig | undefined): IndexingConfigInput { + const provider = cfg?.provider ?? "openai" + + return { + enabled: cfg?.enabled ?? false, + embedderProvider: provider, + vectorStoreProvider: cfg?.vectorStore, + modelId: cfg?.model, + modelDimension: cfg?.dimension, + lancedbVectorStoreDirectory: cfg?.lancedb?.directory, + qdrantUrl: cfg?.qdrant?.url, + qdrantApiKey: cfg?.qdrant?.apiKey, + searchMinScore: cfg?.searchMinScore, + searchMaxResults: cfg?.searchMaxResults, + embeddingBatchSize: cfg?.embeddingBatchSize, + scannerMaxBatchRetries: cfg?.scannerMaxBatchRetries, + openAiKey: cfg?.openai?.apiKey, + ollamaBaseUrl: cfg?.ollama?.baseUrl, + openAiCompatibleBaseUrl: cfg?.["openai-compatible"]?.baseUrl, + openAiCompatibleApiKey: cfg?.["openai-compatible"]?.apiKey, + geminiApiKey: cfg?.gemini?.apiKey, + mistralApiKey: cfg?.mistral?.apiKey, + vercelAiGatewayApiKey: cfg?.["vercel-ai-gateway"]?.apiKey, + bedrockRegion: cfg?.bedrock?.region, + bedrockProfile: cfg?.bedrock?.profile, + openRouterApiKey: cfg?.openrouter?.apiKey, + openRouterSpecificProvider: cfg?.openrouter?.specificProvider, + voyageApiKey: cfg?.voyage?.apiKey, + } +} diff --git a/packages/kilo-indexing/src/detect.ts b/packages/kilo-indexing/src/detect.ts new file mode 100644 index 00000000000..722b95a0b60 --- /dev/null +++ b/packages/kilo-indexing/src/detect.ts @@ -0,0 +1,102 @@ +export const INDEXING_PLUGIN_NAMES = ["kilo-indexing", "@kilocode/kilo-indexing"] as const + +// RATIONALE: PluginSpec is string | [string, Record] — accept both forms. +type Candidate = string | readonly [string, ...unknown[]] + +const names = new Set(INDEXING_PLUGIN_NAMES) +const pathRx = /^[A-Za-z]:[\\/]/ + +export function normalizePluginName(value: string): string { + if (!value) return "" + if (value.startsWith("file://")) { + return normalizePath(fromFileUrl(value)) + } + if (isPathSpecifier(value)) { + return normalizePath(value) + } + return normalizePackage(value) +} + +function specifier(value: Candidate): string { + return typeof value === "string" ? value : value[0] +} + +export function isIndexingPlugin(value: Candidate): boolean { + return names.has(normalizePluginName(specifier(value))) +} + +export function hasIndexingPlugin(values?: readonly Candidate[]): boolean { + return values?.some(isIndexingPlugin) ?? false +} + +function stripVersion(value: string): string { + if (!value.startsWith("@")) { + const at = value.lastIndexOf("@") + return at > 0 ? value.slice(0, at) : value + } + + const slash = value.indexOf("/") + if (slash === -1) return value + const at = value.indexOf("@", slash) + return at === -1 ? value : value.slice(0, at) +} + +function normalizePackage(value: string): string { + return stripVersion(value) +} + +function isPathSpecifier(value: string): boolean { + if (value.startsWith("@")) return false + if (value.startsWith(".") || value.startsWith("/") || value.startsWith("\\")) return true + if (pathRx.test(value)) return true + if (!value.includes("/") && !value.includes("\\")) return false + + const normalized = value.replaceAll("\\", "/") + if (normalized.includes("/node_modules/")) return true + if (normalized.includes("/.opencode/") || normalized.includes("/.kilo/") || normalized.includes("/.kilocode/")) { + return true + } + + return /\.[cm]?[jt]s$/.test(normalized) +} + +function normalizePath(value: string): string { + const parts = value.split(/[\\/]+/).filter(Boolean) + const idx = parts.lastIndexOf("node_modules") + + if (idx >= 0) { + const head = parts[idx + 1] + if (head?.startsWith("@")) { + const tail = parts[idx + 2] + if (tail) return `${head}/${tail}` + } + if (head) return head + } + + const scoped = parts.findIndex((part, i) => part === "@kilocode" && parts[i + 1] === "kilo-indexing") + if (scoped >= 0) return "@kilocode/kilo-indexing" + + const workspace = parts.findIndex((part, i) => part === "packages" && parts[i + 1] === "kilo-indexing") + if (workspace >= 0) return "@kilocode/kilo-indexing" + + return stem(value) +} + +function fromFileUrl(value: string): string { + const url = new URL(value) + const path = decodeURIComponent(url.pathname) + if (/^\/[A-Za-z]:\//.test(path)) return path.slice(1) + if (url.host) return `//${url.host}${path}` + return path +} + +function stem(value: string): string { + const part = + value + .split(/[\\/]+/) + .filter(Boolean) + .at(-1) ?? value + const dot = part.lastIndexOf(".") + if (dot <= 0) return part + return part.slice(0, dot) +} diff --git a/packages/kilo-indexing/src/file/ignore.ts b/packages/kilo-indexing/src/file/ignore.ts new file mode 100644 index 00000000000..292405cb9f9 --- /dev/null +++ b/packages/kilo-indexing/src/file/ignore.ts @@ -0,0 +1,76 @@ +import { minimatch } from "minimatch" + +export namespace FileIgnore { + const folders = new Set([ + "node_modules", + "bower_components", + ".pnpm-store", + "vendor", + ".npm", + "dist", + "build", + "out", + ".next", + "target", + "bin", + "obj", + ".git", + ".svn", + ".hg", + ".vscode", + ".idea", + ".turbo", + ".output", + "desktop", + ".sst", + ".cache", + ".webkit-cache", + "__pycache__", + ".pytest_cache", + "mypy_cache", + ".history", + ".gradle", + ]) + + const files = [ + "**/*.swp", + "**/*.swo", + "**/*.pyc", + "**/.DS_Store", + "**/Thumbs.db", + "**/logs/**", + "**/tmp/**", + "**/temp/**", + "**/*.log", + "**/coverage/**", + "**/.nyc_output/**", + ] + + export const PATTERNS = [...files, ...folders] + + export function match( + filePath: string, + opts?: { + extra?: string[] + whitelist?: string[] + }, + ) { + const normalized = filePath.replaceAll("\\", "/") + + for (const pattern of opts?.whitelist || []) { + if (minimatch(normalized, pattern, { dot: true })) return false + } + + const parts = normalized.split("/") + for (const part of parts) { + if (folders.has(part)) return true + } + + const extra = opts?.extra || [] + for (const pattern of [...files, ...extra]) { + if (minimatch(normalized, pattern, { dot: true })) return true + } + + return false + } +} diff --git a/packages/kilo-indexing/src/headers.ts b/packages/kilo-indexing/src/headers.ts new file mode 100644 index 00000000000..e030325cea7 --- /dev/null +++ b/packages/kilo-indexing/src/headers.ts @@ -0,0 +1,6 @@ +import { getDefaultHeaders } from "@kilocode/kilo-gateway" + +/** + * Default headers for KiloCode requests + */ +export const DEFAULT_HEADERS = getDefaultHeaders() diff --git a/packages/kilo-indexing/src/index.ts b/packages/kilo-indexing/src/index.ts new file mode 100644 index 00000000000..f9d343e1530 --- /dev/null +++ b/packages/kilo-indexing/src/index.ts @@ -0,0 +1,13 @@ +export { KiloIndexingPlugin, default } from "./plugin.js" +export { IndexingConfig, toIndexingConfigInput } from "./config.js" +export { hasIndexingPlugin, isIndexingPlugin, normalizePluginName, INDEXING_PLUGIN_NAMES } from "./detect.js" +export { + INDEXING_STATUS_STATES, + IndexingStatus, + IndexingStatusState, + disabledIndexingStatus, + normalizeIndexingStatus, +} from "./status.js" + +export type { IndexingConfig as IndexingConfigInfo } from "./config.js" +export type { IndexingStatus as IndexingStatusInfo, IndexingStatusState as IndexingStatusStateInfo } from "./status.js" diff --git a/packages/kilo-indexing/src/indexing/cache-manager.ts b/packages/kilo-indexing/src/indexing/cache-manager.ts new file mode 100644 index 00000000000..5973cf96c74 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/cache-manager.ts @@ -0,0 +1,80 @@ +import { createHash } from "crypto" +import fs from "fs/promises" +import path from "path" +import type { ICacheManager } from "./interfaces/cache" +import { Log } from "../util/log" + +const log = Log.create({ service: "indexing-cache" }) + +/** + * Manages the file-hash cache for code indexing. + * + * RATIONALE: Replaced vscode.ExtensionContext storage and vscode.workspace.fs + * with plain filesystem access so the cache manager works outside VS Code. + */ +export class CacheManager implements ICacheManager { + private readonly cachePath: string + private fileHashes: Record = {} + private saveTimer: ReturnType | undefined + + constructor( + private readonly cacheDirectory: string, + private readonly workspacePath: string, + ) { + const hash = createHash("sha256").update(workspacePath).digest("hex") + this.cachePath = path.join(cacheDirectory, `roo-index-cache-${hash}.json`) + } + + async initialize(): Promise { + try { + const raw = await fs.readFile(this.cachePath, "utf-8") + this.fileHashes = JSON.parse(raw) + } catch { + this.fileHashes = {} + } + } + + private scheduleSave(): void { + if (this.saveTimer) clearTimeout(this.saveTimer) + this.saveTimer = setTimeout(() => this.performSave(), 1500) + } + + private async performSave(): Promise { + try { + await fs.mkdir(path.dirname(this.cachePath), { recursive: true }) + const tmp = `${this.cachePath}.tmp` + await fs.writeFile(tmp, JSON.stringify(this.fileHashes), "utf-8") + await fs.rename(tmp, this.cachePath) + } catch (err) { + log.error("failed to save cache", { err }) + } + } + + async clearCacheFile(): Promise { + try { + this.fileHashes = {} + await fs.mkdir(path.dirname(this.cachePath), { recursive: true }) + await fs.writeFile(this.cachePath, "{}", "utf-8") + } catch (err) { + log.error("failed to clear cache file", { err }) + } + } + + getHash(filePath: string): string | undefined { + return this.fileHashes[filePath] + } + + updateHash(filePath: string, hash: string): void { + this.fileHashes[filePath] = hash + this.scheduleSave() + } + + deleteHash(filePath: string): void { + delete this.fileHashes[filePath] + this.scheduleSave() + } + + getAllHashes(): Record { + return { ...this.fileHashes } + } +} diff --git a/packages/kilo-indexing/src/indexing/config-bridge.ts b/packages/kilo-indexing/src/indexing/config-bridge.ts new file mode 100644 index 00000000000..5094e34f98a --- /dev/null +++ b/packages/kilo-indexing/src/indexing/config-bridge.ts @@ -0,0 +1,2 @@ +export { toIndexingConfigInput } from "../config" +export type { IndexingConfig } from "../config" diff --git a/packages/kilo-indexing/src/indexing/config-manager.ts b/packages/kilo-indexing/src/indexing/config-manager.ts new file mode 100644 index 00000000000..6653551bc6d --- /dev/null +++ b/packages/kilo-indexing/src/indexing/config-manager.ts @@ -0,0 +1,312 @@ +import type { EmbedderProvider } from "./interfaces/manager" +import type { CodeIndexConfig, PreviousConfigSnapshot } from "./interfaces/config" +import { DEFAULT_SEARCH_MIN_SCORE, DEFAULT_MAX_SEARCH_RESULTS } from "./constants" +import { getDefaultModelId, getModelDimension, getModelScoreThreshold } from "./model-registry" +import { isEmbeddingProfileEqual, resolveEmbeddingProfile } from "./embedding-profile" + +/** + * Raw input fed to CodeIndexConfigManager from the host environment. + * The host (CLI, extension, tests) builds this object and passes it in; + * the config manager never reads storage or secrets directly. + */ +export interface IndexingConfigInput { + enabled: boolean + embedderProvider: EmbedderProvider + vectorStoreProvider?: "lancedb" | "qdrant" + lancedbVectorStoreDirectory?: string + modelId?: string + modelDimension?: number + qdrantUrl?: string + qdrantApiKey?: string + searchMinScore?: number + searchMaxResults?: number + embeddingBatchSize?: number + scannerMaxBatchRetries?: number + openAiKey?: string + ollamaBaseUrl?: string + openAiCompatibleBaseUrl?: string + openAiCompatibleApiKey?: string + geminiApiKey?: string + mistralApiKey?: string + vercelAiGatewayApiKey?: string + bedrockRegion?: string + bedrockProfile?: string + openRouterApiKey?: string + openRouterSpecificProvider?: string + voyageApiKey?: string +} + +/** + * Manages configuration state and validation for the code indexing feature. + * + * RATIONALE: Replaced the legacy ContextProxy/getGlobalState/getSecret approach + * with a plain IndexingConfigInput object supplied by the host. The manager + * owns no storage; it only validates and projects the input into the shapes the + * rest of the indexing engine expects. + */ +export class CodeIndexConfigManager { + private enabled = false + private embedderProvider: EmbedderProvider = "openai" + private vectorStoreProvider: "lancedb" | "qdrant" = "qdrant" + private lancedbVectorStoreDirectory?: string + private modelId?: string + private modelDimension?: number + private openAiOptions?: { apiKey: string } + private ollamaOptions?: { baseUrl: string; modelId?: string } + private openAiCompatibleOptions?: { baseUrl: string; apiKey: string } + private geminiOptions?: { apiKey: string } + private mistralOptions?: { apiKey: string } + private vercelAiGatewayOptions?: { apiKey: string } + private bedrockOptions?: { region: string; profile?: string } + private openRouterOptions?: { apiKey: string; specificProvider?: string } + private voyageOptions?: { apiKey: string } + private qdrantUrl?: string = "http://localhost:6333" + private qdrantApiKey?: string + private searchMinScore?: number + private searchMaxResults?: number + private embeddingBatchSize?: number + private scannerMaxBatchRetries?: number + + constructor(input: IndexingConfigInput) { + this.applyInput(input) + } + + /** + * Applies new configuration input. Returns whether a restart is needed. + */ + public loadConfiguration(input: IndexingConfigInput): { requiresRestart: boolean } { + const snapshot = this.captureSnapshot() + this.applyInput(input) + const requiresRestart = this.doesConfigChangeRequireRestart(snapshot) + return { requiresRestart } + } + + private applyInput(input: IndexingConfigInput): void { + this.enabled = input.enabled + this.embedderProvider = input.embedderProvider + this.vectorStoreProvider = input.vectorStoreProvider ?? "qdrant" + this.lancedbVectorStoreDirectory = input.lancedbVectorStoreDirectory + this.qdrantUrl = input.qdrantUrl ?? "http://localhost:6333" + this.qdrantApiKey = input.qdrantApiKey + this.searchMinScore = input.searchMinScore + this.searchMaxResults = input.searchMaxResults + this.embeddingBatchSize = input.embeddingBatchSize + this.scannerMaxBatchRetries = input.scannerMaxBatchRetries + this.modelId = input.modelId + + // Validate and set model dimension + if (input.modelDimension !== undefined && input.modelDimension !== null) { + const dimension = Number(input.modelDimension) + this.modelDimension = !isNaN(dimension) && dimension > 0 ? dimension : undefined + } else { + this.modelDimension = undefined + } + + this.openAiOptions = input.openAiKey ? { apiKey: input.openAiKey } : undefined + const url = input.ollamaBaseUrl ?? (input.embedderProvider === "ollama" ? "http://localhost:11434" : undefined) + this.ollamaOptions = url ? { baseUrl: url, modelId: input.modelId } : undefined + this.openAiCompatibleOptions = + input.openAiCompatibleBaseUrl && input.openAiCompatibleApiKey + ? { baseUrl: input.openAiCompatibleBaseUrl, apiKey: input.openAiCompatibleApiKey } + : undefined + this.geminiOptions = input.geminiApiKey ? { apiKey: input.geminiApiKey } : undefined + this.mistralOptions = input.mistralApiKey ? { apiKey: input.mistralApiKey } : undefined + this.vercelAiGatewayOptions = input.vercelAiGatewayApiKey ? { apiKey: input.vercelAiGatewayApiKey } : undefined + this.bedrockOptions = input.bedrockRegion + ? { region: input.bedrockRegion, profile: input.bedrockProfile } + : undefined + this.openRouterOptions = input.openRouterApiKey + ? { apiKey: input.openRouterApiKey, specificProvider: input.openRouterSpecificProvider } + : undefined + this.voyageOptions = input.voyageApiKey ? { apiKey: input.voyageApiKey } : undefined + } + + private captureSnapshot(): PreviousConfigSnapshot { + return { + enabled: this.enabled, + configured: this.isConfigured(), + embedderProvider: this.embedderProvider, + vectorStoreProvider: this.vectorStoreProvider, + lancedbVectorStoreDirectory: this.lancedbVectorStoreDirectory, + modelId: this.modelId, + modelDimension: this.modelDimension, + openAiKey: this.openAiOptions?.apiKey ?? "", + ollamaBaseUrl: this.ollamaOptions?.baseUrl ?? "", + openAiCompatibleBaseUrl: this.openAiCompatibleOptions?.baseUrl ?? "", + openAiCompatibleApiKey: this.openAiCompatibleOptions?.apiKey ?? "", + geminiApiKey: this.geminiOptions?.apiKey ?? "", + mistralApiKey: this.mistralOptions?.apiKey ?? "", + vercelAiGatewayApiKey: this.vercelAiGatewayOptions?.apiKey ?? "", + bedrockRegion: this.bedrockOptions?.region ?? "", + bedrockProfile: this.bedrockOptions?.profile ?? "", + openRouterApiKey: this.openRouterOptions?.apiKey ?? "", + openRouterSpecificProvider: this.openRouterOptions?.specificProvider ?? "", + voyageApiKey: this.voyageOptions?.apiKey ?? "", + qdrantUrl: this.qdrantUrl ?? "", + qdrantApiKey: this.qdrantApiKey ?? "", + } + } + + public isConfigured(): boolean { + const provider = this.embedderProvider + const qdrant = this.qdrantUrl + const isLancedb = this.vectorStoreProvider === "lancedb" + // LanceDB doesn't need a qdrant URL; qdrant does + const hasStore = isLancedb || !!qdrant + + if (provider === "openai") return !!(this.openAiOptions?.apiKey && hasStore) + if (provider === "ollama") return !!(this.ollamaOptions?.baseUrl && hasStore) + if (provider === "openai-compatible") + return !!(this.openAiCompatibleOptions?.baseUrl && this.openAiCompatibleOptions?.apiKey && hasStore) + if (provider === "gemini") return !!(this.geminiOptions?.apiKey && hasStore) + if (provider === "mistral") return !!(this.mistralOptions?.apiKey && hasStore) + if (provider === "vercel-ai-gateway") return !!(this.vercelAiGatewayOptions?.apiKey && hasStore) + if (provider === "bedrock") return !!(this.bedrockOptions?.region && hasStore) + if (provider === "openrouter") return !!(this.openRouterOptions?.apiKey && hasStore) + if (provider === "voyage") return !!(this.voyageOptions?.apiKey && hasStore) + return false + } + + doesConfigChangeRequireRestart(prev: PreviousConfigSnapshot): boolean { + const nowConfigured = this.isConfigured() + + const prevEnabled = prev.enabled ?? false + const prevConfigured = prev.configured ?? false + const prevProvider = prev.embedderProvider ?? "openai" + + // Enable/disable transitions + if ((!prevEnabled || !prevConfigured) && this.enabled && nowConfigured) return true + if (prevEnabled && !this.enabled) return true + if ((!prevEnabled || !prevConfigured) && (!this.enabled || !nowConfigured)) return false + if (!this.enabled) return false + + // Provider change + if (prevProvider !== this.embedderProvider) return true + + // Vector store provider change + if ((prev.vectorStoreProvider ?? "qdrant") !== this.vectorStoreProvider) return true + + // LanceDB path change + if ( + this.vectorStoreProvider === "lancedb" && + (prev.lancedbVectorStoreDirectory ?? "") !== (this.lancedbVectorStoreDirectory ?? "") + ) + return true + + // Auth changes + if ((prev.openAiKey ?? "") !== (this.openAiOptions?.apiKey ?? "")) return true + if ((prev.ollamaBaseUrl ?? "") !== (this.ollamaOptions?.baseUrl ?? "")) return true + if ( + (prev.openAiCompatibleBaseUrl ?? "") !== (this.openAiCompatibleOptions?.baseUrl ?? "") || + (prev.openAiCompatibleApiKey ?? "") !== (this.openAiCompatibleOptions?.apiKey ?? "") + ) + return true + if ((prev.geminiApiKey ?? "") !== (this.geminiOptions?.apiKey ?? "")) return true + if ((prev.mistralApiKey ?? "") !== (this.mistralOptions?.apiKey ?? "")) return true + if ((prev.vercelAiGatewayApiKey ?? "") !== (this.vercelAiGatewayOptions?.apiKey ?? "")) return true + if ( + (prev.bedrockRegion ?? "") !== (this.bedrockOptions?.region ?? "") || + (prev.bedrockProfile ?? "") !== (this.bedrockOptions?.profile ?? "") + ) + return true + if ((prev.openRouterApiKey ?? "") !== (this.openRouterOptions?.apiKey ?? "")) return true + if ((prev.openRouterSpecificProvider ?? "") !== (this.openRouterOptions?.specificProvider ?? "")) return true + if ((prev.voyageApiKey ?? "") !== (this.voyageOptions?.apiKey ?? "")) return true + + // Qdrant connection changes + if ((prev.qdrantUrl ?? "") !== (this.qdrantUrl ?? "") || (prev.qdrantApiKey ?? "") !== (this.qdrantApiKey ?? "")) + return true + + if (this.hasEmbeddingProfileChanged(prevProvider, prev.modelId, prev.modelDimension)) return true + + return false + } + + private hasEmbeddingProfileChanged( + prevProvider: EmbedderProvider, + prevModelId?: string, + prevModelDimension?: number, + ): boolean { + const prev = resolveEmbeddingProfile(prevProvider, prevModelId, prevModelDimension) + const cur = resolveEmbeddingProfile(this.embedderProvider, this.modelId, this.modelDimension) + + if (prev && cur) return !isEmbeddingProfileEqual(prev, cur) + + const prevId = prevModelId ?? getDefaultModelId(prevProvider) + const curId = this.modelId ?? getDefaultModelId(this.embedderProvider) + if (prevProvider === this.embedderProvider && prevId === curId) return false + + return true + } + + public getConfig(): CodeIndexConfig { + return { + isConfigured: this.isConfigured(), + embedderProvider: this.embedderProvider, + vectorStoreProvider: this.vectorStoreProvider ?? "qdrant", + lancedbVectorStoreDirectoryPlaceholder: this.lancedbVectorStoreDirectory, + modelId: this.modelId, + modelDimension: this.modelDimension, + openAiOptions: this.openAiOptions, + ollamaOptions: this.ollamaOptions, + openAiCompatibleOptions: this.openAiCompatibleOptions, + geminiOptions: this.geminiOptions, + mistralOptions: this.mistralOptions, + vercelAiGatewayOptions: this.vercelAiGatewayOptions, + bedrockOptions: this.bedrockOptions, + openRouterOptions: this.openRouterOptions, + voyageOptions: this.voyageOptions, + qdrantUrl: this.qdrantUrl, + qdrantApiKey: this.qdrantApiKey, + searchMinScore: this.currentSearchMinScore, + searchMaxResults: this.currentSearchMaxResults, + embeddingBatchSize: this.currentEmbeddingBatchSize, + scannerMaxBatchRetries: this.currentScannerMaxBatchRetries, + } + } + + public get isFeatureEnabled(): boolean { + return this.enabled + } + + public get isFeatureConfigured(): boolean { + return this.isConfigured() + } + + public get currentEmbedderProvider(): EmbedderProvider { + return this.embedderProvider + } + + public get qdrantConfig(): { url?: string; apiKey?: string } { + return { url: this.qdrantUrl, apiKey: this.qdrantApiKey } + } + + public get currentModelId(): string | undefined { + return this.modelId + } + + public get currentModelDimension(): number | undefined { + const id = this.modelId ?? getDefaultModelId(this.embedderProvider) + const dim = getModelDimension(this.embedderProvider, id) + if (!dim && this.modelDimension && this.modelDimension > 0) return this.modelDimension + return dim + } + + public get currentSearchMinScore(): number { + if (this.searchMinScore !== undefined) return this.searchMinScore + const id = this.modelId ?? getDefaultModelId(this.embedderProvider) + return getModelScoreThreshold(this.embedderProvider, id) ?? DEFAULT_SEARCH_MIN_SCORE + } + + public get currentSearchMaxResults(): number { + return this.searchMaxResults ?? DEFAULT_MAX_SEARCH_RESULTS + } + + public get currentEmbeddingBatchSize(): number | undefined { + return this.embeddingBatchSize + } + + public get currentScannerMaxBatchRetries(): number | undefined { + return this.scannerMaxBatchRetries + } +} diff --git a/packages/kilo-indexing/src/indexing/constants/index.ts b/packages/kilo-indexing/src/indexing/constants/index.ts new file mode 100644 index 00000000000..a381a6351c0 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/constants/index.ts @@ -0,0 +1,69 @@ +/** + * Codebase Index Constants + */ +export const CODEBASE_INDEX_DEFAULTS = { + MIN_SEARCH_RESULTS: 10, + MAX_SEARCH_RESULTS: 200, + DEFAULT_SEARCH_RESULTS: 50, + SEARCH_RESULTS_STEP: 10, + MIN_SEARCH_SCORE: 0, + MAX_SEARCH_SCORE: 1, + DEFAULT_SEARCH_MIN_SCORE: 0.4, + SEARCH_SCORE_STEP: 0.05, + MIN_EMBEDDING_BATCH_SIZE: 10, + MAX_EMBEDDING_BATCH_SIZE: 200, + DEFAULT_EMBEDDING_BATCH_SIZE: 60, + EMBEDDING_BATCH_SIZE_STEP: 10, + MIN_SCANNER_MAX_BATCH_RETRIES: 1, + MAX_SCANNER_MAX_BATCH_RETRIES: 10, + DEFAULT_SCANNER_MAX_BATCH_RETRIES: 3, + SCANNER_MAX_BATCH_RETRIES_STEP: 1, +} as const + +/**Parser */ +export const MAX_BLOCK_CHARS = 1000 +export const MIN_BLOCK_CHARS = 50 +export const MIN_CHUNK_REMAINDER_CHARS = 200 // Minimum characters for the *next* chunk after a split +export const MAX_CHARS_TOLERANCE_FACTOR = 1.15 // 15% tolerance for max chars + +/**Search */ +export const DEFAULT_SEARCH_MIN_SCORE = CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_MIN_SCORE +export const DEFAULT_MAX_SEARCH_RESULTS = CODEBASE_INDEX_DEFAULTS.DEFAULT_SEARCH_RESULTS + +/**File Watcher */ +export const QDRANT_CODE_BLOCK_NAMESPACE = "f47ac10b-58cc-4372-a567-0e02b2c3d479" +export const MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024 // 1MB + +/**Directory Scanner */ +export const MAX_LIST_FILES_LIMIT_CODE_INDEX = 50_000 +export const BATCH_SEGMENT_THRESHOLD = 60 // Number of code segments to batch for embeddings/upserts +export const MAX_BATCH_RETRIES = 3 +export const INITIAL_RETRY_DELAY_MS = 500 +export const PARSING_CONCURRENCY = 10 +export const MAX_PENDING_BATCHES = 20 // Maximum number of batches to accumulate before waiting + +/**Manager Recovery */ +export const MAX_MANAGER_RECOVERY_ATTEMPTS = 3 +export const INITIAL_MANAGER_RECOVERY_DELAY_MS = 500 + +/**Embedder Validation */ +export const REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS = 15_000 +export const REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES = 0 +export const OLLAMA_EMBEDDER_REQUEST_TIMEOUT_MS = 120_000 + +/**OpenAI Embedder */ +export const MAX_BATCH_TOKENS = 100000 +export const MAX_ITEM_TOKENS = 8191 +export const BATCH_PROCESSING_CONCURRENCY = 10 + +/**Gemini Embedder */ +export const GEMINI_MAX_ITEM_TOKENS = 2048 + +/**Managed Indexing */ +export const MANAGED_MAX_CHUNK_CHARS = 1000 +export const MANAGED_MIN_CHUNK_CHARS = 50 +export const MANAGED_OVERLAP_LINES = 5 +export const MANAGED_BATCH_SIZE = 60 +export const MANAGED_FILE_WATCH_DEBOUNCE_MS = 500 +export const MANAGED_MAX_CONCURRENT_FILES = 10 +export const MANAGED_MAX_CONCURRENT_BATCHES = 50 diff --git a/packages/kilo-indexing/src/indexing/embedders/bedrock.ts b/packages/kilo-indexing/src/indexing/embedders/bedrock.ts new file mode 100644 index 00000000000..80946205eff --- /dev/null +++ b/packages/kilo-indexing/src/indexing/embedders/bedrock.ts @@ -0,0 +1,306 @@ +import { BedrockRuntimeClient, InvokeModelCommand, type InvokeModelCommandInput } from "@aws-sdk/client-bedrock-runtime" +import { fromIni } from "@aws-sdk/credential-provider-ini" +import type { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces" +import { + MAX_BATCH_TOKENS, + MAX_ITEM_TOKENS, + MAX_BATCH_RETRIES as MAX_RETRIES, + INITIAL_RETRY_DELAY_MS as INITIAL_DELAY_MS, +} from "../constants" +import { getDefaultModelId } from "../model-registry" +import { withValidationErrorHandling, formatEmbeddingError, type HttpError } from "../shared/validation-helpers" +import { Log } from "../../util/log" + +const log = Log.create({ service: "embedder-bedrock" }) + +/** + * Amazon Bedrock implementation of the embedder interface with batching and rate limiting + */ +export class BedrockEmbedder implements IEmbedder { + private bedrockClient: BedrockRuntimeClient + private readonly defaultModelId: string + + /** + * Creates a new Amazon Bedrock embedder + * @param region AWS region for Bedrock service (required) + * @param profile AWS profile name for credentials (optional - uses default credential chain if not provided) + * @param modelId Optional model ID override + */ + constructor( + private readonly region: string, + private readonly profile?: string, + modelId?: string, + ) { + if (!region) { + throw new Error("Region is required for AWS Bedrock embedder") + } + + // If profile is specified, use that profile. + // Otherwise omit credentials so the AWS SDK uses the default provider chain. + const cfg = this.profile + ? { + region: this.region, + credentials: fromIni({ profile: this.profile }), + } + : { + region: this.region, + } + + this.bedrockClient = new BedrockRuntimeClient(cfg) + + this.defaultModelId = modelId || getDefaultModelId("bedrock") + } + + /** + * Creates embeddings for the given texts with batching and rate limiting + * @param texts Array of text strings to embed + * @param model Optional model identifier + * @returns Promise resolving to embedding response + */ + async createEmbeddings(texts: string[], model?: string): Promise { + const modelToUse = model || this.defaultModelId + + const allEmbeddings: number[][] = [] + const usage = { promptTokens: 0, totalTokens: 0 } + const remainingTexts = [...texts] + + while (remainingTexts.length > 0) { + const currentBatch: string[] = [] + let currentBatchTokens = 0 + const processedIndices: number[] = [] + + for (let i = 0; i < remainingTexts.length; i++) { + const text = remainingTexts[i] + const itemTokens = Math.ceil(text.length / 4) + + if (itemTokens > MAX_ITEM_TOKENS) { + log.warn(`Text at index ${i} exceeds token limit (${itemTokens} > ${MAX_ITEM_TOKENS})`) + processedIndices.push(i) + continue + } + + if (currentBatchTokens + itemTokens <= MAX_BATCH_TOKENS) { + currentBatch.push(text) + currentBatchTokens += itemTokens + processedIndices.push(i) + } else { + break + } + } + + // Remove processed items from remainingTexts (in reverse order to maintain correct indices) + for (let i = processedIndices.length - 1; i >= 0; i--) { + remainingTexts.splice(processedIndices[i], 1) + } + + if (currentBatch.length > 0) { + const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse) + allEmbeddings.push(...batchResult.embeddings) + usage.promptTokens += batchResult.usage.promptTokens + usage.totalTokens += batchResult.usage.totalTokens + } + } + + return { embeddings: allEmbeddings, usage } + } + + /** + * Helper method to handle batch embedding with retries and exponential backoff + * @param batchTexts Array of texts to embed in this batch + * @param model Model identifier to use + * @returns Promise resolving to embeddings and usage statistics + */ + private async _embedBatchWithRetries( + batchTexts: string[], + model: string, + ): Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }> { + for (let attempts = 0; attempts < MAX_RETRIES; attempts++) { + try { + const embeddings: number[][] = [] + let totalPromptTokens = 0 + let totalTokens = 0 + + // Process each text in the batch + // Note: Amazon Titan models typically don't support batch embedding in a single request + // So we process them individually + for (const text of batchTexts) { + const embedding = await this._invokeEmbeddingModel(text, model) + embeddings.push(embedding.embedding) + totalPromptTokens += embedding.inputTextTokenCount || 0 + totalTokens += embedding.inputTextTokenCount || 0 + } + + return { + embeddings, + usage: { + promptTokens: totalPromptTokens, + totalTokens, + }, + } + } catch (error: any) { + const hasMoreAttempts = attempts < MAX_RETRIES - 1 + + // Check if it's a rate limit error + if (error.name === "ThrottlingException" && hasMoreAttempts) { + const delayMs = INITIAL_DELAY_MS * Math.pow(2, attempts) + log.warn(`Rate limit hit, retrying in ${delayMs}ms (attempt ${attempts + 1}/${MAX_RETRIES})`) + await new Promise((resolve) => setTimeout(resolve, delayMs)) + continue + } + + log.error("Bedrock embedder batch error", { + err: error instanceof Error ? error.message : String(error), + location: "BedrockEmbedder:_embedBatchWithRetries", + attempt: attempts + 1, + }) + + // Format and throw the error + throw formatEmbeddingError(error, MAX_RETRIES) + } + } + + throw new Error(`Embedding failed after ${MAX_RETRIES} attempts`) + } + + /** + * Invokes the embedding model for a single text + * @param text The text to embed + * @param model The model identifier to use + * @returns Promise resolving to embedding and token count + */ + private async _invokeEmbeddingModel( + text: string, + model: string, + ): Promise<{ embedding: number[]; inputTextTokenCount?: number }> { + let requestBody: any + let modelId = model + + // Prepare the request body based on the model + if (model.startsWith("amazon.nova-2-multimodal")) { + // Nova multimodal embeddings use a task-based format with embeddingParams + // Reference: https://docs.aws.amazon.com/bedrock/latest/userguide/embeddings-nova.html + requestBody = { + taskType: "SINGLE_EMBEDDING", + singleEmbeddingParams: { + embeddingPurpose: "GENERIC_INDEX", + embeddingDimension: 1024, // Nova supports 1024 or 3072 + text: { + truncationMode: "END", + value: text, + }, + }, + } + } else if (model.startsWith("amazon.titan-embed")) { + requestBody = { + inputText: text, + } + } else if (model.startsWith("cohere.embed")) { + requestBody = { + texts: [text], + input_type: "search_document", // or "search_query" depending on use case + } + } else { + // Default to Titan format + requestBody = { + inputText: text, + } + } + + const params: InvokeModelCommandInput = { + modelId, + body: JSON.stringify(requestBody), + contentType: "application/json", + accept: "application/json", + } + + const command = new InvokeModelCommand(params) + + const response = await this.bedrockClient.send(command) + + // Parse the response + const responseBody = JSON.parse(new TextDecoder().decode(response.body)) + + // Extract embedding based on model type + if (model.startsWith("amazon.nova-2-multimodal")) { + // Nova multimodal returns { embeddings: [{ embedding: [...] }] } + // Reference: AWS Bedrock documentation + return { + embedding: responseBody.embeddings?.[0]?.embedding || responseBody.embedding, + inputTextTokenCount: responseBody.inputTextTokenCount, + } + } else if (model.startsWith("amazon.titan-embed")) { + return { + embedding: responseBody.embedding, + inputTextTokenCount: responseBody.inputTextTokenCount, + } + } else if (model.startsWith("cohere.embed")) { + return { + embedding: responseBody.embeddings[0], + // Cohere doesn't provide token count in response + } + } else { + // Default to Titan format + return { + embedding: responseBody.embedding, + inputTextTokenCount: responseBody.inputTextTokenCount, + } + } + } + + /** + * Validates the Bedrock embedder configuration by attempting a minimal embedding request + * @returns Promise resolving to validation result with success status and optional error message + */ + async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { + return withValidationErrorHandling(async () => { + try { + // Test with a minimal embedding request + const result = await this._invokeEmbeddingModel("test", this.defaultModelId) + + // Check if we got a valid response + if (!result.embedding || result.embedding.length === 0) { + return { + valid: false, + error: "Bedrock returned an invalid response format", + } + } + + return { valid: true } + } catch (error: any) { + // Check for specific AWS errors + if (error.name === "UnrecognizedClientException") { + return { + valid: false, + error: "Invalid AWS credentials for Bedrock", + } + } + + if (error.name === "AccessDeniedException") { + return { + valid: false, + error: "Access denied to Bedrock embedding model", + } + } + + if (error.name === "ResourceNotFoundException") { + return { + valid: false, + error: `Bedrock model '${this.defaultModelId}' not found`, + } + } + + log.error("Bedrock embedder validation error", { + err: error instanceof Error ? error.message : String(error), + location: "BedrockEmbedder:validateConfiguration", + }) + throw error + } + }, "bedrock") + } + + get embedderInfo(): EmbedderInfo { + return { + name: "bedrock", + } + } +} diff --git a/packages/kilo-indexing/src/indexing/embedders/gemini.ts b/packages/kilo-indexing/src/indexing/embedders/gemini.ts new file mode 100644 index 00000000000..026b6814698 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/embedders/gemini.ts @@ -0,0 +1,90 @@ +import { OpenAICompatibleEmbedder } from "./openai-compatible" +import type { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces/embedder" +import { GEMINI_MAX_ITEM_TOKENS } from "../constants" +import { Log } from "../../util/log" +import { getDefaultModelId } from "../model-registry" + +const log = Log.create({ service: "embedder-gemini" }) + +/** + * Gemini embedder implementation that wraps the OpenAI Compatible embedder + * with configuration for Google's Gemini embedding API. + * + * Supported models: + * - text-embedding-004 (dimension: 768) + * - gemini-embedding-001 (dimension: 3072) + */ +export class GeminiEmbedder implements IEmbedder { + private readonly openAICompatibleEmbedder: OpenAICompatibleEmbedder + private static readonly GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/" + private readonly modelId: string + + /** + * Creates a new Gemini embedder + * @param apiKey The Gemini API key for authentication + * @param modelId The model ID to use (defaults to gemini-embedding-001) + */ + constructor(apiKey: string, modelId?: string) { + if (!apiKey) { + throw new Error("API key is required for Gemini embedder") + } + + // Use provided model or default + this.modelId = modelId || getDefaultModelId("gemini") + + // Create an OpenAI Compatible embedder with Gemini's configuration + this.openAICompatibleEmbedder = new OpenAICompatibleEmbedder( + GeminiEmbedder.GEMINI_BASE_URL, + apiKey, + this.modelId, + GEMINI_MAX_ITEM_TOKENS, + ) + } + + /** + * Creates embeddings for the given texts using Gemini's embedding API + * @param texts Array of text strings to embed + * @param model Optional model identifier (uses constructor model if not provided) + * @returns Promise resolving to embedding response + */ + async createEmbeddings(texts: string[], model?: string): Promise { + try { + // Use the provided model or fall back to the instance's model + const modelToUse = model || this.modelId + return await this.openAICompatibleEmbedder.createEmbeddings(texts, modelToUse) + } catch (error) { + log.error("Gemini embedder error in createEmbeddings", { + err: error instanceof Error ? error.message : String(error), + location: "GeminiEmbedder:createEmbeddings", + }) + throw error + } + } + + /** + * Validates the Gemini embedder configuration by delegating to the underlying OpenAI-compatible embedder + * @returns Promise resolving to validation result with success status and optional error message + */ + async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { + try { + // Delegate validation to the OpenAI-compatible embedder + // The error messages will be specific to Gemini since we're using Gemini's base URL + return await this.openAICompatibleEmbedder.validateConfiguration() + } catch (error) { + log.error("Gemini embedder validation error", { + err: error instanceof Error ? error.message : String(error), + location: "GeminiEmbedder:validateConfiguration", + }) + throw error + } + } + + /** + * Returns information about this embedder + */ + get embedderInfo(): EmbedderInfo { + return { + name: "gemini", + } + } +} diff --git a/packages/kilo-indexing/src/indexing/embedders/mistral.ts b/packages/kilo-indexing/src/indexing/embedders/mistral.ts new file mode 100644 index 00000000000..09df5d6bdb9 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/embedders/mistral.ts @@ -0,0 +1,90 @@ +import { OpenAICompatibleEmbedder } from "./openai-compatible" +import type { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces/embedder" +import { MAX_ITEM_TOKENS } from "../constants" +import { Log } from "../../util/log" +import { getDefaultModelId } from "../model-registry" + +const log = Log.create({ service: "embedder-mistral" }) + +/** + * Mistral embedder implementation that wraps the OpenAI Compatible embedder + * with configuration for Mistral's embedding API. + * + * Supported models: + * - codestral-embed-2505 (dimension: 1536) + * - mistral-embed (dimension: 1024) + */ +export class MistralEmbedder implements IEmbedder { + private readonly openAICompatibleEmbedder: OpenAICompatibleEmbedder + private static readonly MISTRAL_BASE_URL = "https://api.mistral.ai/v1" + private readonly modelId: string + + /** + * Creates a new Mistral embedder + * @param apiKey The Mistral API key for authentication + * @param modelId The model ID to use (defaults to codestral-embed-2505) + */ + constructor(apiKey: string, modelId?: string) { + if (!apiKey) { + throw new Error("API key is required for Mistral embedder") + } + + // Use provided model or default + this.modelId = modelId || getDefaultModelId("mistral") + + // Create an OpenAI Compatible embedder with Mistral's configuration + this.openAICompatibleEmbedder = new OpenAICompatibleEmbedder( + MistralEmbedder.MISTRAL_BASE_URL, + apiKey, + this.modelId, + MAX_ITEM_TOKENS, // This is the max token limit (8191), not the embedding dimension + ) + } + + /** + * Creates embeddings for the given texts using Mistral's embedding API + * @param texts Array of text strings to embed + * @param model Optional model identifier (uses constructor model if not provided) + * @returns Promise resolving to embedding response + */ + async createEmbeddings(texts: string[], model?: string): Promise { + try { + // Use the provided model or fall back to the instance's model + const modelToUse = model || this.modelId + return await this.openAICompatibleEmbedder.createEmbeddings(texts, modelToUse) + } catch (error) { + log.error("Mistral embedder error in createEmbeddings", { + err: error instanceof Error ? error.message : String(error), + location: "MistralEmbedder:createEmbeddings", + }) + throw error + } + } + + /** + * Validates the Mistral embedder configuration by delegating to the underlying OpenAI-compatible embedder + * @returns Promise resolving to validation result with success status and optional error message + */ + async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { + try { + // Delegate validation to the OpenAI-compatible embedder + // The error messages will be specific to Mistral since we're using Mistral's base URL + return await this.openAICompatibleEmbedder.validateConfiguration() + } catch (error) { + log.error("Mistral embedder validation error", { + err: error instanceof Error ? error.message : String(error), + location: "MistralEmbedder:validateConfiguration", + }) + throw error + } + } + + /** + * Returns information about this embedder + */ + get embedderInfo(): EmbedderInfo { + return { + name: "mistral", + } + } +} diff --git a/packages/kilo-indexing/src/indexing/embedders/ollama.ts b/packages/kilo-indexing/src/indexing/embedders/ollama.ts new file mode 100644 index 00000000000..72691a2323f --- /dev/null +++ b/packages/kilo-indexing/src/indexing/embedders/ollama.ts @@ -0,0 +1,270 @@ +import type { EmbedderInfo, EmbeddingResponse, IEmbedder } from "../interfaces" +import { getModelQueryPrefix } from "../model-registry" +import { MAX_ITEM_TOKENS, OLLAMA_EMBEDDER_REQUEST_TIMEOUT_MS } from "../constants" +import { withValidationErrorHandling, sanitizeErrorMessage } from "../shared/validation-helpers" +import { Log } from "../../util/log" + +const log = Log.create({ service: "embedder-ollama" }) + +type OllamaEmbeddingResult = { + embeddings?: number[][] +} + +type OllamaModel = { + name?: string +} + +type OllamaModelsResult = { + models?: OllamaModel[] +} + +/** + * Implements the IEmbedder interface using a local Ollama instance. + */ +export class CodeIndexOllamaEmbedder implements IEmbedder { + private readonly baseUrl: string + private readonly defaultModelId: string + private readonly dimensions?: number + + constructor(baseUrl: string, modelId?: string, dimension?: number) { + let normalizedUrl = baseUrl || "http://localhost:11434" + + // Normalize the baseUrl by removing all trailing slashes + normalizedUrl = normalizedUrl.replace(/\/+$/, "") + + this.baseUrl = normalizedUrl + this.defaultModelId = modelId || "nomic-embed-text:latest" + this.dimensions = dimension + } + + /** + * Creates embeddings for the given texts using the specified Ollama model. + * @param texts - An array of strings to embed. + * @param model - Optional model ID to override the default. + * @returns A promise that resolves to an EmbeddingResponse containing the embeddings and usage data. + */ + async createEmbeddings(texts: string[], model?: string): Promise { + const modelToUse = model || this.defaultModelId + const dimensions = this.dimensions + const url = `${this.baseUrl}/api/embed` // Endpoint as specified + + // Apply model-specific query prefix if required + const queryPrefix = getModelQueryPrefix("ollama", modelToUse) + const processedTexts = queryPrefix + ? texts.map((text, index) => { + // Prevent double-prefixing + if (text.startsWith(queryPrefix)) { + return text + } + const prefixedText = `${queryPrefix}${text}` + const estimatedTokens = Math.ceil(prefixedText.length / 4) + if (estimatedTokens > MAX_ITEM_TOKENS) { + log.warn(`Text at index ${index} with prefix exceeds token limit (${estimatedTokens} > ${MAX_ITEM_TOKENS})`) + // Return original text if adding prefix would exceed limit + return text + } + return prefixedText + }) + : texts + + try { + // Add timeout to prevent indefinite hanging + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), OLLAMA_EMBEDDER_REQUEST_TIMEOUT_MS) + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: modelToUse, + input: processedTexts, + dimensions, + }), + signal: controller.signal, + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + let errorBody = "Could not read error body" + try { + errorBody = await response.text() + } catch (e) { + // Ignore error reading body + } + throw new Error(`Ollama request failed: ${response.status} ${response.statusText} - ${errorBody}`) + } + + const data = (await response.json()) as OllamaEmbeddingResult + + // Extract embeddings using 'embeddings' key as requested + const embeddings = data.embeddings + if (!embeddings || !Array.isArray(embeddings)) { + throw new Error("Invalid Ollama response structure: missing embeddings array") + } + + return { + embeddings: embeddings, + } + } catch (error: unknown) { + log.error("Ollama embedding failed", { + err: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + location: "OllamaEmbedder:createEmbeddings", + }) + + // Handle specific error types with better messages + if (error instanceof Error && error.name === "AbortError") { + throw new Error("Connection to embedding service failed (timeout)") + } else if ( + error instanceof Error && + (error.message.includes("fetch failed") || ("code" in error && error.code === "ECONNREFUSED")) + ) { + throw new Error(`Ollama service is not running at ${this.baseUrl}`) + } else if (error instanceof Error && "code" in error && error.code === "ENOTFOUND") { + throw new Error(`Ollama host not found at ${this.baseUrl}`) + } + + // Re-throw a more specific error for the caller + throw new Error(`Ollama embedding failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + + /** + * Validates the Ollama embedder configuration by checking service availability and model existence + * @returns Promise resolving to validation result with success status and optional error message + */ + async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { + return withValidationErrorHandling( + async () => { + // First check if Ollama service is running by trying to list models + const modelsUrl = `${this.baseUrl}/api/tags` + + // Add timeout to prevent indefinite hanging + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), OLLAMA_EMBEDDER_REQUEST_TIMEOUT_MS) + + const modelsResponse = await fetch(modelsUrl, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + signal: controller.signal, + }) + clearTimeout(timeoutId) + + if (!modelsResponse.ok) { + if (modelsResponse.status === 404) { + return { + valid: false, + error: `Ollama service is not running at ${this.baseUrl}`, + } + } + return { + valid: false, + error: `Ollama service unavailable at ${this.baseUrl} (status ${modelsResponse.status})`, + } + } + + // Check if the specific model exists + const modelsData = (await modelsResponse.json()) as OllamaModelsResult + const models = modelsData.models ?? [] + + // Check both with and without :latest suffix + const modelExists = models.some((m) => { + const modelName = m.name ?? "" + return ( + modelName === this.defaultModelId || + modelName === `${this.defaultModelId}:latest` || + modelName === this.defaultModelId.replace(":latest", "") + ) + }) + + if (!modelExists) { + const availableModels = models.map((m) => m.name ?? "").join(", ") + return { + valid: false, + error: `Model '${this.defaultModelId}' not found. Available models: ${availableModels}`, + } + } + + // Try a test embedding to ensure the model works for embeddings + const testUrl = `${this.baseUrl}/api/embed` + + // Add timeout for test request too + const testController = new AbortController() + const testTimeoutId = setTimeout(() => testController.abort(), OLLAMA_EMBEDDER_REQUEST_TIMEOUT_MS) + + const testResponse = await fetch(testUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: this.defaultModelId, + input: ["test"], + }), + signal: testController.signal, + }) + clearTimeout(testTimeoutId) + + if (!testResponse.ok) { + return { + valid: false, + error: `Model '${this.defaultModelId}' is not capable of generating embeddings`, + } + } + + return { valid: true } + }, + "ollama", + { + beforeStandardHandling: (error: any) => { + // Handle Ollama-specific connection errors + if ( + error?.message?.includes("fetch failed") || + error?.code === "ECONNREFUSED" || + error?.message?.includes("ECONNREFUSED") + ) { + log.error("Ollama connection failed", { + err: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + location: "OllamaEmbedder:validateConfiguration:connectionFailed", + }) + return { + valid: false, + error: `Ollama service is not running at ${this.baseUrl}`, + } + } else if (error?.code === "ENOTFOUND" || error?.message?.includes("ENOTFOUND")) { + log.error("Ollama host not found", { + err: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + location: "OllamaEmbedder:validateConfiguration:hostNotFound", + }) + return { + valid: false, + error: `Ollama host not found at ${this.baseUrl}`, + } + } else if (error?.name === "AbortError") { + log.error("Ollama connection timeout", { + err: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + location: "OllamaEmbedder:validateConfiguration:timeout", + }) + // Handle timeout + return { + valid: false, + error: "Connection to embedding service failed (timeout)", + } + } + // Let standard handling take over + return undefined + }, + }, + ) + } + + get embedderInfo(): EmbedderInfo { + return { + name: "ollama", + } + } +} diff --git a/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts b/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts new file mode 100644 index 00000000000..45ef6dc08d9 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/embedders/openai-compatible.ts @@ -0,0 +1,484 @@ +import { OpenAI } from "openai" +import type { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces/embedder" +import { + MAX_BATCH_TOKENS, + MAX_ITEM_TOKENS, + MAX_BATCH_RETRIES as MAX_RETRIES, + INITIAL_RETRY_DELAY_MS as INITIAL_DELAY_MS, + REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, + REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, +} from "../constants" +import { getDefaultModelId, getModelQueryPrefix } from "../model-registry" +import { withValidationErrorHandling, type HttpError, formatEmbeddingError } from "../shared/validation-helpers" +import { Mutex } from "async-mutex" +import { Log } from "../../util/log" + +const log = Log.create({ service: "embedder-openai-compatible" }) + +interface EmbeddingItem { + embedding: string | number[] + [key: string]: any +} + +interface OpenAIEmbeddingResponse { + data: EmbeddingItem[] + usage?: { + prompt_tokens?: number + total_tokens?: number + } +} + +/** + * OpenAI Compatible implementation of the embedder interface with batching and rate limiting. + * This embedder allows using any OpenAI-compatible API endpoint by specifying a custom baseURL. + */ + +export class OpenAICompatibleEmbedder implements IEmbedder { + private embeddingsClient: OpenAI + private readonly defaultModelId: string + private readonly baseUrl: string + private readonly apiKey: string + private readonly isFullUrl: boolean + private readonly maxItemTokens: number + + // Global rate limiting state shared across all instances + private static globalRateLimitState = { + isRateLimited: false, + rateLimitResetTime: 0, + consecutiveRateLimitErrors: 0, + lastRateLimitError: 0, + // Mutex to ensure thread-safe access to rate limit state + mutex: new Mutex(), + } + + /** + * Creates a new OpenAI Compatible embedder + * @param baseUrl The base URL for the OpenAI-compatible API endpoint + * @param apiKey The API key for authentication + * @param modelId Optional model identifier (defaults to "text-embedding-3-small") + * @param maxItemTokens Optional maximum tokens per item (defaults to MAX_ITEM_TOKENS) + */ + constructor(baseUrl: string, apiKey: string, modelId?: string, maxItemTokens?: number) { + if (!baseUrl) { + throw new Error("Base URL is required for OpenAI-compatible embedder") + } + if (!apiKey) { + throw new Error("API key is required for OpenAI-compatible embedder") + } + + this.baseUrl = baseUrl + this.apiKey = apiKey + + try { + this.embeddingsClient = new OpenAI({ + baseURL: baseUrl, + apiKey: apiKey, + }) + } catch (error) { + throw error instanceof Error ? error : new Error(String(error)) + } + + this.defaultModelId = modelId || getDefaultModelId("openai-compatible") + // Cache the URL type check for performance + this.isFullUrl = this.isFullEndpointUrl(baseUrl) + this.maxItemTokens = maxItemTokens || MAX_ITEM_TOKENS + } + + /** + * Creates embeddings for the given texts with batching and rate limiting + * @param texts Array of text strings to embed + * @param model Optional model identifier + * @returns Promise resolving to embedding response + */ + async createEmbeddings(texts: string[], model?: string): Promise { + const modelToUse = model || this.defaultModelId + + // Apply model-specific query prefix if required + const queryPrefix = getModelQueryPrefix("openai-compatible", modelToUse) + const processedTexts = queryPrefix + ? texts.map((text, index) => { + // Prevent double-prefixing + if (text.startsWith(queryPrefix)) { + return text + } + const prefixedText = `${queryPrefix}${text}` + const estimatedTokens = Math.ceil(prefixedText.length / 4) + if (estimatedTokens > MAX_ITEM_TOKENS) { + log.warn(`Text at index ${index} with prefix exceeds token limit (${estimatedTokens} > ${MAX_ITEM_TOKENS})`) + // Return original text if adding prefix would exceed limit + return text + } + return prefixedText + }) + : texts + + const allEmbeddings: number[][] = [] + const usage = { promptTokens: 0, totalTokens: 0 } + const remainingTexts = [...processedTexts] + + while (remainingTexts.length > 0) { + const currentBatch: string[] = [] + let currentBatchTokens = 0 + const processedIndices: number[] = [] + + for (let i = 0; i < remainingTexts.length; i++) { + const text = remainingTexts[i] + const itemTokens = Math.ceil(text.length / 4) + + if (itemTokens > this.maxItemTokens) { + log.warn(`Text at index ${i} exceeds token limit (${itemTokens} > ${this.maxItemTokens})`) + processedIndices.push(i) + continue + } + + if (currentBatchTokens + itemTokens <= MAX_BATCH_TOKENS) { + currentBatch.push(text) + currentBatchTokens += itemTokens + processedIndices.push(i) + } else { + break + } + } + + // Remove processed items from remainingTexts (in reverse order to maintain correct indices) + for (let i = processedIndices.length - 1; i >= 0; i--) { + remainingTexts.splice(processedIndices[i]!, 1) + } + + if (currentBatch.length > 0) { + const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse) + allEmbeddings.push(...batchResult.embeddings) + usage.promptTokens += batchResult.usage.promptTokens + usage.totalTokens += batchResult.usage.totalTokens + } + } + + return { embeddings: allEmbeddings, usage } + } + + /** + * Determines if the provided URL is a full endpoint URL or a base URL that needs the endpoint appended by the SDK. + * Uses smart pattern matching for known providers while accepting we can't cover all possible patterns. + * @param url The URL to check + * @returns true if it's a full endpoint URL, false if it's a base URL + */ + private isFullEndpointUrl(url: string): boolean { + // Known patterns for major providers + const patterns = [ + // Azure OpenAI: /deployments/{deployment-name}/embeddings + /\/deployments\/[^\/]+\/embeddings(\?|$)/, + // Azure Databricks: /serving-endpoints/{endpoint-name}/invocations + /\/serving-endpoints\/[^\/]+\/invocations(\?|$)/, + // Direct endpoints: ends with /embeddings (before query params) + /\/embeddings(\?|$)/, + // Some providers use /embed instead of /embeddings + /\/embed(\?|$)/, + ] + + return patterns.some((pattern) => pattern.test(url)) + } + + /** + * Makes a direct HTTP request to the embeddings endpoint + * Used when the user provides a full endpoint URL (e.g., Azure OpenAI with query parameters) + * @param url The full endpoint URL + * @param batchTexts Array of texts to embed + * @param model Model identifier to use + * @returns Promise resolving to OpenAI-compatible response + */ + private async makeDirectEmbeddingRequest( + url: string, + batchTexts: string[], + model: string, + signal?: AbortSignal, + ): Promise { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + // Azure OpenAI uses 'api-key' header, while OpenAI uses 'Authorization' + // We'll try 'api-key' first for Azure compatibility + "api-key": this.apiKey, + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + input: batchTexts, + model: model, + encoding_format: "base64", + }), + signal, + }) + + if (!response || !response.ok) { + const status = response?.status || 0 + let errorText = "No response" + try { + if (response && typeof response.text === "function") { + errorText = await response.text() + } else if (response) { + errorText = `Error ${status}` + } + } catch { + // Ignore text parsing errors + errorText = `Error ${status}` + } + const error = new Error(`HTTP ${status}: ${errorText}`) as HttpError + error.status = status || response?.status || 0 + throw error + } + + try { + return (await response.json()) as OpenAIEmbeddingResponse + } catch (e) { + const error = new Error(`Failed to parse response JSON`) as HttpError + error.status = response.status + throw error + } + } + + /** + * Helper method to handle batch embedding with retries and exponential backoff + * @param batchTexts Array of texts to embed in this batch + * @param model Model identifier to use + * @returns Promise resolving to embeddings and usage statistics + */ + private async _embedBatchWithRetries( + batchTexts: string[], + model: string, + ): Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }> { + // Use cached value for performance + const isFullUrl = this.isFullUrl + + for (let attempts = 0; attempts < MAX_RETRIES; attempts++) { + // Check global rate limit before attempting request + await this.waitForGlobalRateLimit() + + try { + let response: OpenAIEmbeddingResponse + + if (isFullUrl) { + // Use direct HTTP request for full endpoint URLs + response = await this.makeDirectEmbeddingRequest(this.baseUrl, batchTexts, model) + } else { + // Use OpenAI SDK for base URLs + response = (await this.embeddingsClient.embeddings.create({ + input: batchTexts, + model: model, + // OpenAI package (as of v4.78.1) has a parsing issue that truncates embedding dimensions to 256 + // when processing numeric arrays, which breaks compatibility with models using larger dimensions. + // By requesting base64 encoding, we bypass the package's parser and handle decoding ourselves. + encoding_format: "base64", + })) as OpenAIEmbeddingResponse + } + + // Convert base64 embeddings to float32 arrays + const processedEmbeddings = response.data.map((item: EmbeddingItem) => { + if (typeof item.embedding === "string") { + const buffer = Buffer.from(item.embedding, "base64") + + // Create Float32Array view over the buffer + const float32Array = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4) + + return { + ...item, + embedding: Array.from(float32Array), + } + } + return item + }) + + // Replace the original data with processed embeddings + response.data = processedEmbeddings + + const embeddings = response.data.map((item) => item.embedding as number[]) + + return { + embeddings: embeddings, + usage: { + promptTokens: response.usage?.prompt_tokens || 0, + totalTokens: response.usage?.total_tokens || 0, + }, + } + } catch (error) { + log.error("OpenAI Compatible embedder batch error", { + err: error instanceof Error ? error.message : String(error), + location: "OpenAICompatibleEmbedder:_embedBatchWithRetries", + attempt: attempts + 1, + }) + + const hasMoreAttempts = attempts < MAX_RETRIES - 1 + + // Check if it's a rate limit error + const httpError = error as HttpError + if (httpError?.status === 429) { + // Update global rate limit state + await this.updateGlobalRateLimitState(httpError) + + if (hasMoreAttempts) { + // Calculate delay based on global rate limit state + const baseDelay = INITIAL_DELAY_MS * Math.pow(2, attempts) + const globalDelay = await this.getGlobalRateLimitDelay() + const delayMs = Math.max(baseDelay, globalDelay) + + log.warn(`Rate limit hit, retrying in ${delayMs}ms (attempt ${attempts + 1}/${MAX_RETRIES})`) + await new Promise((resolve) => setTimeout(resolve, delayMs)) + continue + } + } + + // Format and throw the error + throw formatEmbeddingError(error, MAX_RETRIES) + } + } + + throw new Error(`Embedding failed after ${MAX_RETRIES} attempts`) + } + + /** + * Validates the OpenAI-compatible embedder configuration by testing endpoint connectivity and API key + * @returns Promise resolving to validation result with success status and optional error message + */ + async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { + return withValidationErrorHandling(async () => { + try { + // Test with a minimal embedding request + const testTexts = ["test"] + const modelToUse = this.defaultModelId + + let response: OpenAIEmbeddingResponse + + if (this.isFullUrl) { + // Test direct HTTP request for full endpoint URLs + const ctl = new AbortController() + const timer = setTimeout(() => ctl.abort(), REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS) + try { + response = await this.makeDirectEmbeddingRequest(this.baseUrl, testTexts, modelToUse, ctl.signal) + } finally { + clearTimeout(timer) + } + } else { + // Test using OpenAI SDK for base URLs + response = (await this.embeddingsClient.embeddings.create( + { + input: testTexts, + model: modelToUse, + encoding_format: "base64", + }, + { + timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, + maxRetries: REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, + }, + )) as OpenAIEmbeddingResponse + } + + // Check if we got a valid response + if (!response?.data || response.data.length === 0) { + return { + valid: false, + error: "Invalid response from embedding endpoint", + } + } + + return { valid: true } + } catch (error) { + log.error("OpenAI Compatible embedder validation error", { + err: error instanceof Error ? error.message : String(error), + location: "OpenAICompatibleEmbedder:validateConfiguration", + }) + throw error + } + }, "openai-compatible") + } + + /** + * Returns information about this embedder + */ + get embedderInfo(): EmbedderInfo { + return { + name: "openai-compatible", + } + } + + /** + * Waits if there's an active global rate limit + */ + private async waitForGlobalRateLimit(): Promise { + const release = await OpenAICompatibleEmbedder.globalRateLimitState.mutex.acquire() + try { + const state = OpenAICompatibleEmbedder.globalRateLimitState + + if (state.isRateLimited && state.rateLimitResetTime > Date.now()) { + const waitTime = state.rateLimitResetTime - Date.now() + // Silent wait - no logging to prevent flooding + release() // Release mutex before waiting + await new Promise((resolve) => setTimeout(resolve, waitTime)) + return + } + + // Reset rate limit if time has passed + if (state.isRateLimited && state.rateLimitResetTime <= Date.now()) { + state.isRateLimited = false + state.consecutiveRateLimitErrors = 0 + } + } finally { + // Only release if we haven't already + try { + release() + } catch { + // Already released + } + } + } + + /** + * Updates global rate limit state when a 429 error occurs + */ + private async updateGlobalRateLimitState(error: HttpError): Promise { + const release = await OpenAICompatibleEmbedder.globalRateLimitState.mutex.acquire() + try { + const state = OpenAICompatibleEmbedder.globalRateLimitState + const now = Date.now() + + // Increment consecutive rate limit errors + if (now - state.lastRateLimitError < 60000) { + // Within 1 minute + state.consecutiveRateLimitErrors++ + } else { + state.consecutiveRateLimitErrors = 1 + } + + state.lastRateLimitError = now + + // Calculate exponential backoff based on consecutive errors + const baseDelay = 5000 // 5 seconds base + const maxDelay = 300000 // 5 minutes max + const exponentialDelay = Math.min(baseDelay * Math.pow(2, state.consecutiveRateLimitErrors - 1), maxDelay) + + // Set global rate limit + state.isRateLimited = true + state.rateLimitResetTime = now + exponentialDelay + + // Silent rate limit activation - no logging to prevent flooding + } finally { + release() + } + } + + /** + * Gets the current global rate limit delay + */ + private async getGlobalRateLimitDelay(): Promise { + const release = await OpenAICompatibleEmbedder.globalRateLimitState.mutex.acquire() + try { + const state = OpenAICompatibleEmbedder.globalRateLimitState + + if (state.isRateLimited && state.rateLimitResetTime > Date.now()) { + return state.rateLimitResetTime - Date.now() + } + + return 0 + } finally { + release() + } + } +} diff --git a/packages/kilo-indexing/src/indexing/embedders/openai.ts b/packages/kilo-indexing/src/indexing/embedders/openai.ts new file mode 100644 index 00000000000..c9cc57c1807 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/embedders/openai.ts @@ -0,0 +1,204 @@ +import { OpenAI } from "openai" +import type { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces" +import { + MAX_BATCH_TOKENS, + MAX_ITEM_TOKENS, + MAX_BATCH_RETRIES as MAX_RETRIES, + INITIAL_RETRY_DELAY_MS as INITIAL_DELAY_MS, + REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, + REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, +} from "../constants" +import { getModelQueryPrefix } from "../model-registry" +import { withValidationErrorHandling, formatEmbeddingError, type HttpError } from "../shared/validation-helpers" +import { Log } from "../../util/log" + +const log = Log.create({ service: "embedder-openai" }) + +/** + * OpenAI implementation of the embedder interface with batching and rate limiting + */ +export class OpenAiEmbedder implements IEmbedder { + private embeddingsClient: OpenAI + private readonly defaultModelId: string + + /** + * Creates a new OpenAI embedder + * @param apiKey The OpenAI API key for authentication + * @param modelId Optional model identifier (defaults to "text-embedding-3-small") + */ + constructor(apiKey: string, modelId?: string) { + try { + this.embeddingsClient = new OpenAI({ apiKey }) + } catch (error) { + throw error instanceof Error ? error : new Error(String(error)) + } + + this.defaultModelId = modelId || "text-embedding-3-small" + } + + /** + * Creates embeddings for the given texts with batching and rate limiting + * @param texts Array of text strings to embed + * @param model Optional model identifier + * @returns Promise resolving to embedding response + */ + async createEmbeddings(texts: string[], model?: string): Promise { + const modelToUse = model || this.defaultModelId + + // Apply model-specific query prefix if required + const queryPrefix = getModelQueryPrefix("openai", modelToUse) + const processedTexts = queryPrefix + ? texts.map((text, index) => { + // Prevent double-prefixing + if (text.startsWith(queryPrefix)) { + return text + } + const prefixedText = `${queryPrefix}${text}` + const estimatedTokens = Math.ceil(prefixedText.length / 4) + if (estimatedTokens > MAX_ITEM_TOKENS) { + log.warn(`Text at index ${index} with prefix exceeds token limit (${estimatedTokens} > ${MAX_ITEM_TOKENS})`) + // Return original text if adding prefix would exceed limit + return text + } + return prefixedText + }) + : texts + + const allEmbeddings: number[][] = [] + const usage = { promptTokens: 0, totalTokens: 0 } + const remainingTexts = [...processedTexts] + + while (remainingTexts.length > 0) { + const currentBatch: string[] = [] + let currentBatchTokens = 0 + const processedIndices: number[] = [] + + for (let i = 0; i < remainingTexts.length; i++) { + const text = remainingTexts[i] + const itemTokens = Math.ceil(text.length / 4) + + if (itemTokens > MAX_ITEM_TOKENS) { + log.warn(`Text at index ${i} exceeds token limit (${itemTokens} > ${MAX_ITEM_TOKENS})`) + processedIndices.push(i) + continue + } + + if (currentBatchTokens + itemTokens <= MAX_BATCH_TOKENS) { + currentBatch.push(text) + currentBatchTokens += itemTokens + processedIndices.push(i) + } else { + break + } + } + + // Remove processed items from remainingTexts (in reverse order to maintain correct indices) + for (let i = processedIndices.length - 1; i >= 0; i--) { + remainingTexts.splice(processedIndices[i], 1) + } + + if (currentBatch.length > 0) { + const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse) + allEmbeddings.push(...batchResult.embeddings) + usage.promptTokens += batchResult.usage.promptTokens + usage.totalTokens += batchResult.usage.totalTokens + } + } + + return { embeddings: allEmbeddings, usage } + } + + /** + * Helper method to handle batch embedding with retries and exponential backoff + * @param batchTexts Array of texts to embed in this batch + * @param model Model identifier to use + * @returns Promise resolving to embeddings and usage statistics + */ + private async _embedBatchWithRetries( + batchTexts: string[], + model: string, + ): Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }> { + for (let attempts = 0; attempts < MAX_RETRIES; attempts++) { + try { + const response = await this.embeddingsClient.embeddings.create({ + input: batchTexts, + model: model, + }) + + return { + embeddings: response.data.map((item: any) => item.embedding), + usage: { + promptTokens: response.usage?.prompt_tokens || 0, + totalTokens: response.usage?.total_tokens || 0, + }, + } + } catch (error: any) { + const hasMoreAttempts = attempts < MAX_RETRIES - 1 + + // Check if it's a rate limit error + const httpError = error as HttpError + if (httpError?.status === 429 && hasMoreAttempts) { + const delayMs = INITIAL_DELAY_MS * Math.pow(2, attempts) + log.warn(`Rate limit hit, retrying in ${delayMs}ms (attempt ${attempts + 1}/${MAX_RETRIES})`) + await new Promise((resolve) => setTimeout(resolve, delayMs)) + continue + } + + log.error("OpenAI embedder batch error", { + err: error instanceof Error ? error.message : String(error), + location: "OpenAiEmbedder:_embedBatchWithRetries", + attempt: attempts + 1, + }) + + // Format and throw the error + throw formatEmbeddingError(error, MAX_RETRIES) + } + } + + throw new Error(`Embedding failed after ${MAX_RETRIES} attempts`) + } + + /** + * Validates the OpenAI embedder configuration by attempting a minimal embedding request + * @returns Promise resolving to validation result with success status and optional error message + */ + async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { + return withValidationErrorHandling(async () => { + try { + // Test with a minimal embedding request + const response = await this.embeddingsClient.embeddings.create( + { + input: ["test"], + model: this.defaultModelId, + }, + { + timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, + maxRetries: REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, + }, + ) + + // Check if we got a valid response + if (!response.data || response.data.length === 0) { + return { + valid: false, + error: "OpenAI returned an invalid response format", + } + } + + return { valid: true } + } catch (error) { + log.error("OpenAI embedder validation error", { + err: error instanceof Error ? error.message : String(error), + location: "OpenAiEmbedder:validateConfiguration", + }) + throw error + } + }, "openai") + } + + get embedderInfo(): EmbedderInfo { + return { + name: "openai", + } + } +} diff --git a/packages/kilo-indexing/src/indexing/embedders/openrouter.ts b/packages/kilo-indexing/src/indexing/embedders/openrouter.ts new file mode 100644 index 00000000000..c055964a5c3 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/embedders/openrouter.ts @@ -0,0 +1,427 @@ +import { OpenAI } from "openai" +import type { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces/embedder" +import { + MAX_BATCH_TOKENS, + MAX_ITEM_TOKENS, + MAX_BATCH_RETRIES as MAX_RETRIES, + INITIAL_RETRY_DELAY_MS as INITIAL_DELAY_MS, + REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, + REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, +} from "../constants" +import { getDefaultModelId, getModelQueryPrefix } from "../model-registry" +import { withValidationErrorHandling, type HttpError, formatEmbeddingError } from "../shared/validation-helpers" +import { Mutex } from "async-mutex" +import { DEFAULT_HEADERS } from "../../headers" +import { Log } from "../../util/log" + +const log = Log.create({ service: "embedder-openrouter" }) + +// Default provider name when no specific provider is selected +export const OPENROUTER_DEFAULT_PROVIDER_NAME = "[default]" + +interface EmbeddingItem { + embedding: string | number[] + [key: string]: any +} + +interface OpenRouterEmbeddingResponse { + data: EmbeddingItem[] + usage?: { + prompt_tokens?: number + total_tokens?: number + } +} + +/** + * OpenRouter implementation of the embedder interface with batching and rate limiting. + * OpenRouter provides an OpenAI-compatible API that gives access to hundreds of models + * through a single endpoint, automatically handling fallbacks and cost optimization. + */ +export class OpenRouterEmbedder implements IEmbedder { + private embeddingsClient: OpenAI + private readonly defaultModelId: string + private readonly apiKey: string + private readonly maxItemTokens: number + private readonly baseUrl: string = "https://openrouter.ai/api/v1" + private readonly specificProvider?: string + private readonly dimensions?: number + + // Global rate limiting state shared across all instances + private static globalRateLimitState = { + isRateLimited: false, + rateLimitResetTime: 0, + consecutiveRateLimitErrors: 0, + lastRateLimitError: 0, + // Mutex to ensure thread-safe access to rate limit state + mutex: new Mutex(), + } + + /** + * Creates a new OpenRouter embedder + * @param apiKey The API key for authentication + * @param modelId Optional model identifier (defaults to "openai/text-embedding-3-large") + * @param maxItemTokens Optional maximum tokens per item (defaults to MAX_ITEM_TOKENS) + * @param specificProvider Optional specific provider to route requests to + * @param dimensions Optional embedding dimensions override + */ + constructor( + apiKey: string, + modelId?: string, + maxItemTokens?: number, + specificProvider?: string, + dimensions?: number, + ) { + if (!apiKey) { + throw new Error("API key is required for OpenRouter embedder") + } + + this.apiKey = apiKey + // Only set specificProvider if it's not the default value + this.specificProvider = + specificProvider && specificProvider !== OPENROUTER_DEFAULT_PROVIDER_NAME ? specificProvider : undefined + + try { + this.embeddingsClient = new OpenAI({ + baseURL: this.baseUrl, + apiKey: apiKey, + defaultHeaders: DEFAULT_HEADERS, + }) + } catch (error) { + throw error instanceof Error ? error : new Error(String(error)) + } + + this.defaultModelId = modelId || getDefaultModelId("openrouter") + this.maxItemTokens = maxItemTokens || MAX_ITEM_TOKENS + this.dimensions = dimensions + } + + /** + * Creates embeddings for the given texts with batching and rate limiting + * @param texts Array of text strings to embed + * @param model Optional model identifier + * @returns Promise resolving to embedding response + */ + async createEmbeddings(texts: string[], model?: string): Promise { + const modelToUse = model || this.defaultModelId + + // Apply model-specific query prefix if required + const queryPrefix = getModelQueryPrefix("openrouter", modelToUse) + const processedTexts = queryPrefix + ? texts.map((text, index) => { + // Prevent double-prefixing + if (text.startsWith(queryPrefix)) { + return text + } + const prefixedText = `${queryPrefix}${text}` + const estimatedTokens = Math.ceil(prefixedText.length / 4) + if (estimatedTokens > MAX_ITEM_TOKENS) { + log.warn(`Text at index ${index} with prefix exceeds token limit (${estimatedTokens} > ${MAX_ITEM_TOKENS})`) + // Return original text if adding prefix would exceed limit + return text + } + return prefixedText + }) + : texts + + const allEmbeddings: number[][] = [] + const usage = { promptTokens: 0, totalTokens: 0 } + const remainingTexts = [...processedTexts] + + while (remainingTexts.length > 0) { + const currentBatch: string[] = [] + let currentBatchTokens = 0 + const processedIndices: number[] = [] + + for (let i = 0; i < remainingTexts.length; i++) { + const text = remainingTexts[i] + if (text === undefined) { + continue + } + const itemTokens = Math.ceil(text.length / 4) + + if (itemTokens > this.maxItemTokens) { + log.warn(`Text at index ${i} exceeds token limit (${itemTokens} > ${this.maxItemTokens})`) + processedIndices.push(i) + continue + } + + if (currentBatchTokens + itemTokens <= MAX_BATCH_TOKENS) { + currentBatch.push(text) + currentBatchTokens += itemTokens + processedIndices.push(i) + } else { + break + } + } + + // Remove processed items from remainingTexts (in reverse order to maintain correct indices) + for (let i = processedIndices.length - 1; i >= 0; i--) { + const idx = processedIndices[i] + if (idx === undefined) { + continue + } + remainingTexts.splice(idx, 1) + } + + if (currentBatch.length > 0) { + const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse) + allEmbeddings.push(...batchResult.embeddings) + usage.promptTokens += batchResult.usage.promptTokens + usage.totalTokens += batchResult.usage.totalTokens + } + } + + return { embeddings: allEmbeddings, usage } + } + + /** + * Helper method to handle batch embedding with retries and exponential backoff + * @param batchTexts Array of texts to embed in this batch + * @param model Model identifier to use + * @returns Promise resolving to embeddings and usage statistics + */ + private async _embedBatchWithRetries( + batchTexts: string[], + model: string, + ): Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }> { + for (let attempts = 0; attempts < MAX_RETRIES; attempts++) { + // Check global rate limit before attempting request + await this.waitForGlobalRateLimit() + + try { + // Build the request parameters + const requestParams: any = { + input: batchTexts, + model: model, + // OpenAI package (as of v4.78.1) has a parsing issue that truncates embedding dimensions to 256 + // when processing numeric arrays, which breaks compatibility with models using larger dimensions. + // By requesting base64 encoding, we bypass the package's parser and handle decoding ourselves. + encoding_format: "base64", + } + + if (this.dimensions !== undefined) { + requestParams.dimensions = this.dimensions + } + + // Add provider routing if a specific provider is set + if (this.specificProvider) { + requestParams.provider = { + order: [this.specificProvider], + only: [this.specificProvider], + allow_fallbacks: false, + } + } + + const response = (await this.embeddingsClient.embeddings.create(requestParams)) as OpenRouterEmbeddingResponse + + // Convert base64 embeddings to float32 arrays + const processedEmbeddings = response.data.map((item: EmbeddingItem) => { + if (typeof item.embedding === "string") { + const buffer = Buffer.from(item.embedding, "base64") + + // Create Float32Array view over the buffer + const float32Array = new Float32Array(buffer.buffer, buffer.byteOffset, buffer.byteLength / 4) + + return { + ...item, + embedding: Array.from(float32Array), + } + } + return item + }) + + // Replace the original data with processed embeddings + response.data = processedEmbeddings + + const embeddings = response.data.map((item) => item.embedding as number[]) + + return { + embeddings: embeddings, + usage: { + promptTokens: response.usage?.prompt_tokens || 0, + totalTokens: response.usage?.total_tokens || 0, + }, + } + } catch (error) { + log.error("OpenRouter embedder batch error", { + err: error instanceof Error ? error.message : String(error), + location: "OpenRouterEmbedder:_embedBatchWithRetries", + attempt: attempts + 1, + }) + + const hasMoreAttempts = attempts < MAX_RETRIES - 1 + + // Check if it's a rate limit error + const httpError = error as HttpError + if (httpError?.status === 429) { + // Update global rate limit state + await this.updateGlobalRateLimitState(httpError) + + if (hasMoreAttempts) { + // Calculate delay based on global rate limit state + const baseDelay = INITIAL_DELAY_MS * Math.pow(2, attempts) + const globalDelay = await this.getGlobalRateLimitDelay() + const delayMs = Math.max(baseDelay, globalDelay) + + log.warn(`Rate limit hit, retrying in ${delayMs}ms (attempt ${attempts + 1}/${MAX_RETRIES})`) + await new Promise((resolve) => setTimeout(resolve, delayMs)) + continue + } + } + + // Format and throw the error + throw formatEmbeddingError(error, MAX_RETRIES) + } + } + + throw new Error(`Embedding failed after ${MAX_RETRIES} attempts`) + } + + /** + * Validates the OpenRouter embedder configuration by testing API connectivity + * @returns Promise resolving to validation result with success status and optional error message + */ + async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { + return withValidationErrorHandling(async () => { + try { + // Test with a minimal embedding request + const testTexts = ["test"] + const modelToUse = this.defaultModelId + + // Build the request parameters + const requestParams: any = { + input: testTexts, + model: modelToUse, + encoding_format: "base64", + } + + if (this.dimensions !== undefined) { + requestParams.dimensions = this.dimensions + } + + // Add provider routing if a specific provider is set + if (this.specificProvider) { + requestParams.provider = { + order: [this.specificProvider], + only: [this.specificProvider], + allow_fallbacks: false, + } + } + + const response = (await this.embeddingsClient.embeddings.create(requestParams, { + timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, + maxRetries: REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, + })) as OpenRouterEmbeddingResponse + + // Check if we got a valid response + if (!response?.data || response.data.length === 0) { + return { + valid: false, + error: "Invalid response from OpenRouter embedding endpoint", + } + } + + return { valid: true } + } catch (error) { + log.error("OpenRouter embedder validation error", { + err: error instanceof Error ? error.message : String(error), + location: "OpenRouterEmbedder:validateConfiguration", + }) + throw error + } + }, "openrouter") + } + + /** + * Returns information about this embedder + */ + get embedderInfo(): EmbedderInfo { + return { + name: "openrouter", + } + } + + /** + * Waits if there's an active global rate limit + */ + private async waitForGlobalRateLimit(): Promise { + const release = await OpenRouterEmbedder.globalRateLimitState.mutex.acquire() + let mutexReleased = false + + try { + const state = OpenRouterEmbedder.globalRateLimitState + + if (state.isRateLimited && state.rateLimitResetTime > Date.now()) { + const waitTime = state.rateLimitResetTime - Date.now() + // Silent wait - no logging to prevent flooding + release() + mutexReleased = true + await new Promise((resolve) => setTimeout(resolve, waitTime)) + return + } + + // Reset rate limit if time has passed + if (state.isRateLimited && state.rateLimitResetTime <= Date.now()) { + state.isRateLimited = false + state.consecutiveRateLimitErrors = 0 + } + } finally { + // Only release if we haven't already + if (!mutexReleased) { + release() + } + } + } + + /** + * Updates global rate limit state when a 429 error occurs + */ + private async updateGlobalRateLimitState(error: HttpError): Promise { + const release = await OpenRouterEmbedder.globalRateLimitState.mutex.acquire() + try { + const state = OpenRouterEmbedder.globalRateLimitState + const now = Date.now() + + // Increment consecutive rate limit errors + if (now - state.lastRateLimitError < 60000) { + // Within 1 minute + state.consecutiveRateLimitErrors++ + } else { + state.consecutiveRateLimitErrors = 1 + } + + state.lastRateLimitError = now + + // Calculate exponential backoff based on consecutive errors + const baseDelay = 5000 // 5 seconds base + const maxDelay = 300000 // 5 minutes max + const exponentialDelay = Math.min(baseDelay * Math.pow(2, state.consecutiveRateLimitErrors - 1), maxDelay) + + // Set global rate limit + state.isRateLimited = true + state.rateLimitResetTime = now + exponentialDelay + + // Silent rate limit activation - no logging to prevent flooding + } finally { + release() + } + } + + /** + * Gets the current global rate limit delay + */ + private async getGlobalRateLimitDelay(): Promise { + const release = await OpenRouterEmbedder.globalRateLimitState.mutex.acquire() + try { + const state = OpenRouterEmbedder.globalRateLimitState + + if (state.isRateLimited && state.rateLimitResetTime > Date.now()) { + return state.rateLimitResetTime - Date.now() + } + + return 0 + } finally { + release() + } + } +} diff --git a/packages/kilo-indexing/src/indexing/embedders/vercel-ai-gateway.ts b/packages/kilo-indexing/src/indexing/embedders/vercel-ai-gateway.ts new file mode 100644 index 00000000000..e5c0ad3bb2e --- /dev/null +++ b/packages/kilo-indexing/src/indexing/embedders/vercel-ai-gateway.ts @@ -0,0 +1,98 @@ +import { OpenAICompatibleEmbedder } from "./openai-compatible" +import type { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces/embedder" +import { MAX_ITEM_TOKENS } from "../constants" +import { Log } from "../../util/log" + +const log = Log.create({ service: "embedder-vercel-ai-gateway" }) + +/** + * Vercel AI Gateway embedder implementation that wraps the OpenAI Compatible embedder + * with configuration for Vercel AI Gateway's embedding API. + * + * Supported models: + * - openai/text-embedding-3-small (dimension: 1536) + * - openai/text-embedding-3-large (dimension: 3072) + * - openai/text-embedding-ada-002 (dimension: 1536) + * - cohere/embed-v4.0 (dimension: 1024) + * - google/gemini-embedding-001 (dimension: 768) + * - google/text-embedding-005 (dimension: 768) + * - google/text-multilingual-embedding-002 (dimension: 768) + * - amazon/titan-embed-text-v2 (dimension: 1024) + * - mistral/codestral-embed (dimension: 1536) + * - mistral/mistral-embed (dimension: 1024) + */ +export class VercelAiGatewayEmbedder implements IEmbedder { + private readonly openAICompatibleEmbedder: OpenAICompatibleEmbedder + private static readonly VERCEL_AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh/v1" + private static readonly DEFAULT_MODEL = "openai/text-embedding-3-large" + private readonly modelId: string + + /** + * Creates a new Vercel AI Gateway embedder + * @param apiKey The Vercel AI Gateway API key for authentication + * @param modelId The model ID to use (defaults to mistral/codestral-embed) + */ + constructor(apiKey: string, modelId?: string) { + if (!apiKey) { + throw new Error("API key is required for Vercel AI Gateway embedder") + } + + // Use provided model or default + this.modelId = modelId || VercelAiGatewayEmbedder.DEFAULT_MODEL + + // Create an OpenAI Compatible embedder with Vercel AI Gateway's configuration + this.openAICompatibleEmbedder = new OpenAICompatibleEmbedder( + VercelAiGatewayEmbedder.VERCEL_AI_GATEWAY_BASE_URL, + apiKey, + this.modelId, + MAX_ITEM_TOKENS, + ) + } + + /** + * Creates embeddings for the given texts using Vercel AI Gateway's embedding API + * @param texts Array of text strings to embed + * @param model Optional model identifier (uses constructor model if not provided) + * @returns Promise resolving to embedding response + */ + async createEmbeddings(texts: string[], model?: string): Promise { + try { + // Use the provided model or fall back to the instance's model + const modelToUse = model || this.modelId + return await this.openAICompatibleEmbedder.createEmbeddings(texts, modelToUse) + } catch (error) { + log.error("Vercel AI Gateway embedder error in createEmbeddings", { + err: error instanceof Error ? error.message : String(error), + location: "VercelAiGatewayEmbedder:createEmbeddings", + }) + throw error + } + } + + /** + * Validates the Vercel AI Gateway embedder configuration by delegating to the underlying OpenAI-compatible embedder + * @returns Promise resolving to validation result with success status and optional error message + */ + async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { + try { + // Delegate validation to the OpenAI-compatible embedder + // The error messages will be specific to Vercel AI Gateway since we're using Vercel's base URL + return await this.openAICompatibleEmbedder.validateConfiguration() + } catch (error) { + log.error("Vercel AI Gateway embedder validation error", { + err: error instanceof Error ? error.message : String(error), + location: "VercelAiGatewayEmbedder:validateConfiguration", + }) + throw error + } + } + + /** + * Returns information about this embedder + */ + get embedderInfo(): EmbedderInfo { + return { + name: "vercel-ai-gateway", + } + } +} diff --git a/packages/kilo-indexing/src/indexing/embedders/voyage.ts b/packages/kilo-indexing/src/indexing/embedders/voyage.ts new file mode 100644 index 00000000000..f1d2777b901 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/embedders/voyage.ts @@ -0,0 +1,265 @@ +import type { IEmbedder, EmbeddingResponse, EmbedderInfo } from "../interfaces/embedder" +import { + MAX_BATCH_TOKENS, + MAX_ITEM_TOKENS, + MAX_BATCH_RETRIES as MAX_RETRIES, + INITIAL_RETRY_DELAY_MS as INITIAL_DELAY_MS, + REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, +} from "../constants" +import { getModelQueryPrefix } from "../model-registry" +import { withValidationErrorHandling, formatEmbeddingError, type HttpError } from "../shared/validation-helpers" +import { Log } from "../../util/log" + +const log = Log.create({ service: "embedder-voyage" }) + +/** + * Response structure from Voyage AI embedding API + */ +interface VoyageEmbeddingItem { + embedding: number[] + index: number +} + +interface VoyageEmbeddingResponse { + data: VoyageEmbeddingItem[] + model: string + usage?: { + total_tokens?: number + } +} + +/** + * Voyage AI embedder implementation using the native Voyage API. + * + * Voyage AI provides high-quality embedding models including code-specific models. + * API endpoint: https://api.voyageai.com/v1/embeddings + * + * Supported models: + * - voyage-code-3 (dimension: 1024, code-optimized) + * - voyage-4-large (dimension: 1024) + * - voyage-4 (dimension: 1024) + * - voyage-4-lite (dimension: 1024) + * - voyage-finance-2 (dimension: 1024) + * - voyage-law-2 (dimension: 1024) + */ +export class VoyageEmbedder implements IEmbedder { + private static readonly VOYAGE_BASE_URL = "https://api.voyageai.com/v1/embeddings" + private static readonly DEFAULT_MODEL = "voyage-code-3" + private readonly apiKey: string + private readonly modelId: string + + /** + * Creates a new Voyage AI embedder + * @param apiKey The Voyage AI API key for authentication + * @param modelId The model ID to use (defaults to voyage-code-3) + */ + constructor(apiKey: string, modelId?: string) { + if (!apiKey) { + throw new Error("API key is required for Voyage embedder") + } + + this.apiKey = apiKey + this.modelId = modelId || VoyageEmbedder.DEFAULT_MODEL + } + + /** + * Creates embeddings for the given texts using Voyage AI's embedding API + * @param texts Array of text strings to embed + * @param model Optional model identifier (uses constructor model if not provided) + * @returns Promise resolving to embedding response + */ + async createEmbeddings(texts: string[], model?: string): Promise { + const modelToUse = model || this.modelId + + // Apply model-specific query prefix if required + const queryPrefix = getModelQueryPrefix("voyage", modelToUse) + const processedTexts = queryPrefix + ? texts.map((text, index) => { + // Prevent double-prefixing + if (text.startsWith(queryPrefix)) { + return text + } + const prefixedText = `${queryPrefix}${text}` + const estimatedTokens = Math.ceil(prefixedText.length / 4) + if (estimatedTokens > MAX_ITEM_TOKENS) { + log.warn(`Text at index ${index} with prefix exceeds token limit (${estimatedTokens} > ${MAX_ITEM_TOKENS})`) + // Return original text if adding prefix would exceed limit + return text + } + return prefixedText + }) + : texts + + const allEmbeddings: number[][] = [] + const usage = { promptTokens: 0, totalTokens: 0 } + const remainingTexts = [...processedTexts] + + while (remainingTexts.length > 0) { + const currentBatch: string[] = [] + let currentBatchTokens = 0 + const processedIndices: number[] = [] + + for (let i = 0; i < remainingTexts.length; i++) { + const text = remainingTexts[i]! + const itemTokens = Math.ceil(text.length / 4) + + if (itemTokens > MAX_ITEM_TOKENS) { + log.warn(`Text at index ${i} exceeds token limit (${itemTokens} > ${MAX_ITEM_TOKENS})`) + processedIndices.push(i) + continue + } + + if (currentBatchTokens + itemTokens <= MAX_BATCH_TOKENS) { + currentBatch.push(text) + currentBatchTokens += itemTokens + processedIndices.push(i) + } else { + break + } + } + + // Remove processed items from remainingTexts (in reverse order to maintain correct indices) + for (let i = processedIndices.length - 1; i >= 0; i--) { + remainingTexts.splice(processedIndices[i]!, 1) + } + + if (currentBatch.length > 0) { + const batchResult = await this._embedBatchWithRetries(currentBatch, modelToUse) + allEmbeddings.push(...batchResult.embeddings) + usage.promptTokens += batchResult.usage.promptTokens + usage.totalTokens += batchResult.usage.totalTokens + } + } + + return { embeddings: allEmbeddings, usage } + } + + /** + * Helper method to handle batch embedding with retries and exponential backoff + * @param batchTexts Array of texts to embed in this batch + * @param model Model identifier to use + * @returns Promise resolving to embeddings and usage statistics + */ + private async _embedBatchWithRetries( + batchTexts: string[], + model: string, + ): Promise<{ embeddings: number[][]; usage: { promptTokens: number; totalTokens: number } }> { + for (let attempts = 0; attempts < MAX_RETRIES; attempts++) { + try { + const response = await fetch(VoyageEmbedder.VOYAGE_BASE_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + input: batchTexts, + model: model, + input_type: "document", // For indexing, we use "document" type + }), + }) + + if (!response.ok) { + const errorBody = await response.text().catch(() => "Unknown error") + const error = new Error(`HTTP ${response.status}: ${errorBody}`) as HttpError + error.status = response.status + throw error + } + + const result = (await response.json()) as VoyageEmbeddingResponse + + // Sort by index to ensure correct order + const sortedData = [...result.data].sort((a, b) => a.index - b.index) + + return { + embeddings: sortedData.map((item) => item.embedding), + usage: { + promptTokens: result.usage?.total_tokens || 0, + totalTokens: result.usage?.total_tokens || 0, + }, + } + } catch (error: any) { + const hasMoreAttempts = attempts < MAX_RETRIES - 1 + + // Check if it's a rate limit error + const httpError = error as HttpError + if (httpError?.status === 429 && hasMoreAttempts) { + const delayMs = INITIAL_DELAY_MS * Math.pow(2, attempts) + log.warn(`Rate limit hit, retrying in ${delayMs}ms (attempt ${attempts + 1}/${MAX_RETRIES})`) + await new Promise((resolve) => setTimeout(resolve, delayMs)) + continue + } + + log.error("Voyage AI embedder batch error", { + err: error instanceof Error ? error.message : String(error), + location: "VoyageEmbedder:_embedBatchWithRetries", + attempt: attempts + 1, + }) + + // Format and throw the error + throw formatEmbeddingError(error, MAX_RETRIES) + } + } + + throw new Error(`Embedding failed after ${MAX_RETRIES} attempts`) + } + + /** + * Validates the Voyage AI embedder configuration by attempting a minimal embedding request + * @returns Promise resolving to validation result with success status and optional error message + */ + async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { + return withValidationErrorHandling(async () => { + try { + const ctl = new AbortController() + const timer = setTimeout(() => ctl.abort(), REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS) + const response = await fetch(VoyageEmbedder.VOYAGE_BASE_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + input: ["test"], + model: this.modelId, + }), + signal: ctl.signal, + }).finally(() => clearTimeout(timer)) + + if (!response.ok) { + const errorBody = await response.text().catch(() => "Unknown error") + const error = new Error(`HTTP ${response.status}: ${errorBody}`) as HttpError + error.status = response.status + throw error + } + + const result = (await response.json()) as VoyageEmbeddingResponse + + // Check if we got a valid response + if (!result.data || result.data.length === 0) { + return { + valid: false, + error: "Voyage AI returned an invalid response format", + } + } + + return { valid: true } + } catch (error) { + log.error("Voyage AI embedder validation error", { + err: error instanceof Error ? error.message : String(error), + location: "VoyageEmbedder:validateConfiguration", + }) + throw error + } + }, "voyage") + } + + /** + * Returns information about this embedder + */ + get embedderInfo(): EmbedderInfo { + return { + name: "voyage", + } + } +} diff --git a/packages/kilo-indexing/src/indexing/embedding-profile.ts b/packages/kilo-indexing/src/indexing/embedding-profile.ts new file mode 100644 index 00000000000..843288e80a9 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/embedding-profile.ts @@ -0,0 +1,35 @@ +import type { EmbedderProvider } from "./interfaces/manager" +import { getDefaultModelId, getModelDimension } from "./model-registry" + +export interface EmbeddingProfile { + provider: EmbedderProvider + modelId: string + dimension: number +} + +function parseDimension(value?: number): number | undefined { + if (value === undefined || value === null) return undefined + const dim = Number(value) + if (!Number.isFinite(dim) || dim <= 0) return undefined + return dim +} + +export function resolveEmbeddingProfile( + provider: EmbedderProvider, + modelId?: string, + modelDimension?: number, +): EmbeddingProfile | undefined { + const id = modelId ?? getDefaultModelId(provider) + const dim = getModelDimension(provider, id) ?? parseDimension(modelDimension) + if (!dim) return undefined + return { + provider, + modelId: id, + dimension: dim, + } +} + +export function isEmbeddingProfileEqual(a?: EmbeddingProfile, b?: EmbeddingProfile): boolean { + if (!a || !b) return false + return a.provider === b.provider && a.modelId === b.modelId && a.dimension === b.dimension +} diff --git a/packages/kilo-indexing/src/indexing/index.ts b/packages/kilo-indexing/src/indexing/index.ts new file mode 100644 index 00000000000..31f385c79c7 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/index.ts @@ -0,0 +1,35 @@ +export { CodeIndexManager } from "./manager" +export { CodeIndexConfigManager, type IndexingConfigInput } from "./config-manager" +export { CodeIndexStateManager, type IndexingState } from "./state-manager" +export { CodeIndexSearchService } from "./search-service" +export { CodeIndexOrchestrator } from "./orchestrator" +export { CodeIndexServiceFactory } from "./service-factory" +export { CacheManager } from "./cache-manager" +export { Emitter, type Disposable } from "./runtime" + +export type { ICodeIndexManager, IndexProgressUpdate, EmbedderProvider } from "./interfaces/manager" + +export type { + IndexingTelemetryEvent, + IndexingTelemetryMode, + IndexingTelemetryReporter, + IndexingTelemetrySource, + IndexingTelemetryTrigger, +} from "./interfaces/telemetry" + +export type { CodeIndexConfig, PreviousConfigSnapshot } from "./interfaces/config" + +export type { IEmbedder, EmbeddingResponse, EmbedderInfo, AvailableEmbedders } from "./interfaces/embedder" + +export type { IVectorStore, VectorStoreSearchResult, PointStruct, Payload } from "./interfaces/vector-store" + +export type { + ICodeParser, + IDirectoryScanner, + IFileWatcher, + CodeBlock, + FileProcessingResult, + BatchProcessingSummary, +} from "./interfaces/file-processor" + +export type { ICacheManager } from "./interfaces/cache" diff --git a/packages/kilo-indexing/src/indexing/interfaces/cache.ts b/packages/kilo-indexing/src/indexing/interfaces/cache.ts new file mode 100644 index 00000000000..0f77bc21fc4 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/interfaces/cache.ts @@ -0,0 +1,6 @@ +export interface ICacheManager { + getHash(filePath: string): string | undefined + updateHash(filePath: string, hash: string): void + deleteHash(filePath: string): void + getAllHashes(): Record +} diff --git a/packages/kilo-indexing/src/indexing/interfaces/config.ts b/packages/kilo-indexing/src/indexing/interfaces/config.ts new file mode 100644 index 00000000000..96c370748c9 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/interfaces/config.ts @@ -0,0 +1,56 @@ +import type { EmbedderProvider } from "./manager" + +/** + * Configuration state for the code indexing feature. + * + * RATIONALE: Replaced the legacy ApiHandlerOptions / ContextProxy types with + * indexing-local option shapes so the package does not depend on the + * extension's configuration plumbing. + */ +export interface CodeIndexConfig { + isConfigured: boolean + embedderProvider: EmbedderProvider + vectorStoreProvider?: "lancedb" | "qdrant" + lancedbVectorStoreDirectoryPlaceholder?: string + modelId?: string + modelDimension?: number + openAiOptions?: { apiKey: string } + ollamaOptions?: { baseUrl: string; modelId?: string } + openAiCompatibleOptions?: { baseUrl: string; apiKey: string } + geminiOptions?: { apiKey: string } + mistralOptions?: { apiKey: string } + vercelAiGatewayOptions?: { apiKey: string } + bedrockOptions?: { region: string; profile?: string } + openRouterOptions?: { apiKey: string; specificProvider?: string } + voyageOptions?: { apiKey: string } + qdrantUrl?: string + qdrantApiKey?: string + searchMinScore?: number + searchMaxResults?: number + embeddingBatchSize?: number + scannerMaxBatchRetries?: number +} + +export type PreviousConfigSnapshot = { + enabled: boolean + configured: boolean + embedderProvider: EmbedderProvider + vectorStoreProvider?: "lancedb" | "qdrant" + lancedbVectorStoreDirectory?: string + modelId?: string + modelDimension?: number + openAiKey?: string + ollamaBaseUrl?: string + openAiCompatibleBaseUrl?: string + openAiCompatibleApiKey?: string + geminiApiKey?: string + mistralApiKey?: string + vercelAiGatewayApiKey?: string + bedrockRegion?: string + bedrockProfile?: string + openRouterApiKey?: string + openRouterSpecificProvider?: string + voyageApiKey?: string + qdrantUrl?: string + qdrantApiKey?: string +} diff --git a/packages/kilo-indexing/src/indexing/interfaces/embedder.ts b/packages/kilo-indexing/src/indexing/interfaces/embedder.ts new file mode 100644 index 00000000000..8ff6de5be5d --- /dev/null +++ b/packages/kilo-indexing/src/indexing/interfaces/embedder.ts @@ -0,0 +1,44 @@ +/** + * Interface for code index embedders. + * This interface is implemented by both OpenAI and Ollama embedders. + */ +export interface IEmbedder { + /** + * Creates embeddings for the given texts. + * @param texts Array of text strings to create embeddings for + * @param model Optional model ID to use for embeddings + * @returns Promise resolving to an EmbeddingResponse + */ + createEmbeddings(texts: string[], model?: string): Promise + + /** + * Validates the embedder configuration by testing connectivity and credentials. + * @returns Promise resolving to validation result with success status and optional error message + */ + validateConfiguration(): Promise<{ valid: boolean; error?: string }> + + get embedderInfo(): EmbedderInfo +} + +export interface EmbeddingResponse { + embeddings: number[][] + usage?: { + promptTokens: number + totalTokens: number + } +} + +export type AvailableEmbedders = + | "openai" + | "ollama" + | "openai-compatible" + | "gemini" + | "mistral" + | "vercel-ai-gateway" + | "bedrock" + | "openrouter" + | "voyage" + +export interface EmbedderInfo { + name: AvailableEmbedders +} diff --git a/packages/kilo-indexing/src/indexing/interfaces/file-processor.ts b/packages/kilo-indexing/src/indexing/interfaces/file-processor.ts new file mode 100644 index 00000000000..03a80a823c2 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/interfaces/file-processor.ts @@ -0,0 +1,74 @@ +import type { PointStruct } from "./vector-store" +import type { Disposable, Emitter } from "../runtime" +import type { IndexingTelemetryMode } from "./telemetry" + +export interface ICodeParser { + parseFile( + filePath: string, + options?: { + minBlockLines?: number + maxBlockLines?: number + content?: string + fileHash?: string + }, + ): Promise +} + +export interface IDirectoryScanner { + scanDirectory( + directory: string, + onError?: (error: Error) => void, + onFilesIndexed?: (indexedCount: number) => void, + onFileParsed?: () => void, + mode?: IndexingTelemetryMode, + ): Promise<{ + stats: { + processed: number + skipped: number + } + totalBlockCount: number + }> + + updateBatchSegmentThreshold(newThreshold: number): void +} + +export interface IFileWatcher extends Disposable { + initialize(): Promise + updateBatchSegmentThreshold(newThreshold: number): void + setCollecting(collecting: boolean): void + + readonly onDidStartBatchProcessing: Emitter + readonly onBatchProgressUpdate: Emitter<{ + processedInBatch: number + totalInBatch: number + currentFile?: string + }> + readonly onDidFinishBatchProcessing: Emitter + + processFile(filePath: string): Promise +} + +export interface BatchProcessingSummary { + processedFiles: FileProcessingResult[] + batchError?: Error +} + +export interface FileProcessingResult { + path: string + status: "success" | "skipped" | "error" | "processed_for_batching" | "local_error" + error?: Error + reason?: string + newHash?: string + pointsToUpsert?: PointStruct[] +} + +export interface CodeBlock { + file_path: string + identifier: string | null + type: string + start_line: number + end_line: number + content: string + fileHash: string + segmentHash: string +} diff --git a/packages/kilo-indexing/src/indexing/interfaces/index.ts b/packages/kilo-indexing/src/indexing/interfaces/index.ts new file mode 100644 index 00000000000..994d8e7e46e --- /dev/null +++ b/packages/kilo-indexing/src/indexing/interfaces/index.ts @@ -0,0 +1,7 @@ +export * from "./embedder" +export * from "./vector-store" +export * from "./file-processor" +export * from "./manager" +export * from "./config" +export * from "./cache" +export * from "./telemetry" diff --git a/packages/kilo-indexing/src/indexing/interfaces/manager.ts b/packages/kilo-indexing/src/indexing/interfaces/manager.ts new file mode 100644 index 00000000000..5cb4c96f91c --- /dev/null +++ b/packages/kilo-indexing/src/indexing/interfaces/manager.ts @@ -0,0 +1,55 @@ +import type { VectorStoreSearchResult } from "./vector-store" +import type { Emitter } from "../runtime" +import type { IndexingTelemetryEvent } from "./telemetry" + +export interface ICodeIndexManager { + onProgressUpdate: Emitter<{ + systemStatus: IndexingState + message?: string + processedItems: number + totalItems: number + currentItemUnit: string + gitBranch?: string + manifest?: { totalFiles: number; totalChunks: number; lastUpdated: string } + }> + + onTelemetry: Emitter + + readonly state: IndexingState + readonly isFeatureEnabled: boolean + readonly isFeatureConfigured: boolean + + loadConfiguration(): Promise + startIndexing(): Promise + stopWatcher(): void + clearIndexData(): Promise + searchIndex(query: string, directoryPrefix?: string): Promise + getCurrentStatus(): { + systemStatus: IndexingState + message?: string + processedItems: number + totalItems: number + currentItemUnit: string + } + dispose(): void +} + +export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" + +export type EmbedderProvider = + | "openai" + | "ollama" + | "openai-compatible" + | "gemini" + | "mistral" + | "vercel-ai-gateway" + | "bedrock" + | "openrouter" + | "voyage" + +export interface IndexProgressUpdate { + systemStatus: IndexingState + message?: string + processedBlockCount?: number + totalBlockCount?: number +} diff --git a/packages/kilo-indexing/src/indexing/interfaces/telemetry.ts b/packages/kilo-indexing/src/indexing/interfaces/telemetry.ts new file mode 100644 index 00000000000..7dc2ce6e681 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/interfaces/telemetry.ts @@ -0,0 +1,58 @@ +import type { EmbedderProvider } from "./manager" + +export type IndexingTelemetryTrigger = "background" | "manual" +export type IndexingTelemetryMode = "full" | "incremental" +export type IndexingTelemetrySource = "scan" | "watcher" +export type IndexingVectorStore = "lancedb" | "qdrant" + +export type IndexingTelemetryMeta = { + provider: EmbedderProvider + vectorStore: IndexingVectorStore + modelId?: string +} + +export type IndexingTelemetryEvent = + | (IndexingTelemetryMeta & { + type: "started" + source: "scan" + trigger: IndexingTelemetryTrigger + mode?: IndexingTelemetryMode + }) + | (IndexingTelemetryMeta & { + type: "completed" + source: "scan" + trigger: IndexingTelemetryTrigger + mode: IndexingTelemetryMode + filesIndexed: number + filesDiscovered: number + totalBlocks: number + batchErrors: number + }) + | (IndexingTelemetryMeta & { + type: "file_count" + source: "scan" + mode: IndexingTelemetryMode + discovered: number + candidate: number + }) + | (IndexingTelemetryMeta & { + type: "batch_retry" + source: IndexingTelemetrySource + mode: IndexingTelemetryMode + attempt: number + maxRetries: number + batchSize: number + error: string + }) + | (IndexingTelemetryMeta & { + type: "error" + source: IndexingTelemetrySource + location: string + error: string + mode?: IndexingTelemetryMode + trigger?: IndexingTelemetryTrigger + retryCount?: number + maxRetries?: number + }) + +export type IndexingTelemetryReporter = (event: IndexingTelemetryEvent) => void diff --git a/packages/kilo-indexing/src/indexing/interfaces/vector-store.ts b/packages/kilo-indexing/src/indexing/interfaces/vector-store.ts new file mode 100644 index 00000000000..43253e0d8d1 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/interfaces/vector-store.ts @@ -0,0 +1,97 @@ +/** + * Interface for vector database clients + */ +export type PointStruct = { + id: string + vector: number[] + payload: Record +} + +export interface IVectorStore { + /** + * Initializes the vector store + * @returns Promise resolving to boolean indicating if a new collection was created + */ + initialize(): Promise + + /** + * Upserts points into the vector store + * @param points Array of points to upsert + */ + upsertPoints(points: PointStruct[]): Promise + + /** + * Searches for similar vectors + * @param queryVector Vector to search for + * @param directoryPrefix Optional directory prefix to filter results + * @param minScore Optional minimum score threshold + * @param maxResults Optional maximum number of results to return + * @returns Promise resolving to search results + */ + search( + queryVector: number[], + directoryPrefix?: string, + minScore?: number, + maxResults?: number, + ): Promise + + /** + * Deletes points by file path + * @param filePath Path of the file to delete points for + */ + deletePointsByFilePath(filePath: string): Promise + + /** + * Deletes points by multiple file paths + * @param filePaths Array of file paths to delete points for + */ + deletePointsByMultipleFilePaths(filePaths: string[]): Promise + + /** + * Clears all points from the collection + */ + clearCollection(): Promise + + /** + * Deletes the entire collection. + */ + deleteCollection(): Promise + + /** + * Checks if the collection exists + * @returns Promise resolving to boolean indicating if the collection exists + */ + collectionExists(): Promise + + /** + * Checks if the collection exists and has indexed points + * @returns Promise resolving to boolean indicating if the collection exists and has points + */ + hasIndexedData(): Promise + + /** + * Marks the indexing process as complete by storing metadata + * Should be called after a successful full workspace scan or incremental scan + */ + markIndexingComplete(): Promise + + /** + * Marks the indexing process as incomplete by storing metadata + * Should be called at the start of indexing to indicate work in progress + */ + markIndexingIncomplete(): Promise +} + +export interface VectorStoreSearchResult { + id: string | number + score: number + payload?: Payload | null +} + +export interface Payload { + filePath: string + codeChunk: string + startLine: number + endLine: number + [key: string]: any +} diff --git a/packages/kilo-indexing/src/indexing/manager.ts b/packages/kilo-indexing/src/indexing/manager.ts new file mode 100644 index 00000000000..5fc017f18c5 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/manager.ts @@ -0,0 +1,537 @@ +import type { VectorStoreSearchResult } from "./interfaces" +import type { IndexingState } from "./interfaces/manager" +import type { IndexingTelemetryEvent, IndexingTelemetryMeta, IndexingTelemetryTrigger } from "./interfaces/telemetry" +import { CodeIndexConfigManager, type IndexingConfigInput } from "./config-manager" +import { INITIAL_MANAGER_RECOVERY_DELAY_MS, MAX_MANAGER_RECOVERY_ATTEMPTS } from "./constants" +import { CodeIndexStateManager } from "./state-manager" +import { CodeIndexServiceFactory } from "./service-factory" +import { CodeIndexSearchService } from "./search-service" +import { CodeIndexOrchestrator } from "./orchestrator" +import { CacheManager } from "./cache-manager" +import { Emitter } from "./runtime" +import { Log } from "../util/log" +import { loadIgnore } from "./shared/load-ignore" +import { sanitizeErrorMessage } from "./shared/validation-helpers" + +const log = Log.create({ service: "indexing-manager" }) + +/** + * RATIONALE: Removed the static singleton Map and vscode.ExtensionContext. + * The manager is now constructed directly with a workspace path and cache + * directory. The host (CLI, extension) is responsible for managing instances + * per workspace. + */ +export class CodeIndexManager { + private _configManager: CodeIndexConfigManager | undefined + private readonly _stateManager: CodeIndexStateManager + private readonly _telemetry = new Emitter() + private _serviceFactory: CodeIndexServiceFactory | undefined + private _orchestrator: CodeIndexOrchestrator | undefined + private _searchService: CodeIndexSearchService | undefined + private _cacheManager: CacheManager | undefined + private _isRecoveringFromError = false + private _retryTimer: ReturnType | undefined + private _retryResolve: (() => void) | undefined + private _retryTask: Promise | undefined + private _retryAttempt = 0 + private _retryMaxAttempts = MAX_MANAGER_RECOVERY_ATTEMPTS + private _retryInitialDelayMs = INITIAL_MANAGER_RECOVERY_DELAY_MS + private _disposed = false + + constructor( + public readonly workspacePath: string, + private readonly cacheDirectory: string, + ) { + this._stateManager = new CodeIndexStateManager() + } + + public get onProgressUpdate() { + return this._stateManager.onProgressUpdate + } + + public get onTelemetry() { + return this._telemetry + } + + private getTelemetryMeta(): IndexingTelemetryMeta | undefined { + if (!this._configManager) { + return undefined + } + const cfg = this._configManager.getConfig() + return { + provider: cfg.embedderProvider, + vectorStore: cfg.vectorStoreProvider ?? "qdrant", + modelId: cfg.modelId, + } + } + + private emitStart(trigger: IndexingTelemetryTrigger): void { + const meta = this.getTelemetryMeta() + if (!meta) { + return + } + this._telemetry.fire({ + ...meta, + type: "started", + source: "scan", + trigger, + }) + } + + private emitError(location: string, err: unknown, trigger?: IndexingTelemetryTrigger): void { + const meta = this.getTelemetryMeta() + if (!meta) { + return + } + const msg = err instanceof Error ? err.message : String(err) + this._telemetry.fire({ + ...meta, + type: "error", + source: "scan", + location, + trigger, + error: sanitizeErrorMessage(msg), + }) + } + + private clearRetryTimer(): void { + if (!this._retryTimer) { + this._retryResolve = undefined + return + } + clearTimeout(this._retryTimer) + this._retryTimer = undefined + this._retryResolve?.() + this._retryResolve = undefined + } + + private resetRetryState(): void { + this._retryAttempt = 0 + this.clearRetryTimer() + } + + private async waitForRetry(delay: number): Promise { + await new Promise((resolve) => { + this._retryResolve = resolve + this._retryTimer = setTimeout(() => { + this._retryTimer = undefined + this._retryResolve = undefined + resolve() + }, delay) + }) + } + + private handleTelemetry(event: IndexingTelemetryEvent): void { + this._telemetry.fire(event) + + if (event.type === "completed") { + this.resetRetryState() + return + } + + if (event.type !== "error") return + if (event.location !== "orchestrator:startIndexing") return + if (!this.isFeatureEnabled || !this.isFeatureConfigured) return + if (this._retryTask || this._isRecoveringFromError) return + + if (this._retryAttempt >= this._retryMaxAttempts) { + log.warn("indexing recovery retries exhausted", { + workspacePath: this.workspacePath, + attempts: this._retryAttempt, + maxAttempts: this._retryMaxAttempts, + }) + return + } + + void this.recoverFromError(event.trigger ?? "background") + } + + private async runRecovery(trigger: IndexingTelemetryTrigger, attempt: number): Promise { + if (this._disposed) return + + this._isRecoveringFromError = true + this._retryAttempt = attempt + + log.info("starting indexing error recovery attempt", { + workspacePath: this.workspacePath, + attempt, + maxAttempts: this._retryMaxAttempts, + trigger, + }) + + if (!this._configManager || !this._cacheManager) { + log.warn("indexing recovery skipped: manager not initialized", { + workspacePath: this.workspacePath, + }) + this._isRecoveringFromError = false + return + } + + this._stateManager.setSystemState("Standby", "") + + try { + await this._recreateServices() + if (this._disposed) return + this.emitStart(trigger) + await this._orchestrator!.startIndexing(trigger) + if (this._disposed) return + } catch (err) { + if (this._disposed) return + log.error("indexing recovery attempt failed", { + err, + attempt, + }) + this.emitError("manager:recoverFromError", err, trigger) + this._stateManager.setSystemState( + "Error", + `Failed during recovery: ${err instanceof Error ? err.message : String(err)}`, + ) + } + + const failed = this._orchestrator?.state === "Error" || this.getCurrentStatus().systemStatus === "Error" + if (!failed) { + this.resetRetryState() + this._isRecoveringFromError = false + log.info("completed indexing error recovery", { + workspacePath: this.workspacePath, + attempt, + }) + return + } + + if (attempt >= this._retryMaxAttempts) { + this._isRecoveringFromError = false + log.warn("indexing recovery reached max attempts", { + workspacePath: this.workspacePath, + attempts: attempt, + maxAttempts: this._retryMaxAttempts, + }) + return + } + + const delay = this._retryInitialDelayMs * Math.pow(2, attempt - 1) + await this.waitForRetry(delay) + if (this._disposed) return + this._isRecoveringFromError = false + return this.runRecovery(trigger, attempt + 1) + } + + private assertInitialized() { + if (!this._configManager || !this._orchestrator || !this._searchService || !this._cacheManager) { + throw new Error("CodeIndexManager not initialized. Call initialize() first.") + } + } + + public get state(): IndexingState { + if (!this.isFeatureEnabled) return "Standby" + return this._orchestrator?.state ?? this._stateManager.state + } + + public get isFeatureEnabled(): boolean { + return this._configManager?.isFeatureEnabled ?? false + } + + public get isFeatureConfigured(): boolean { + return this._configManager?.isFeatureConfigured ?? false + } + + public get isInitialized(): boolean { + try { + this.assertInitialized() + return true + } catch (e) { + log.warn(`CodeIndexManager not initialized: ${e}`) + return false + } + } + + public async initialize(input: IndexingConfigInput): Promise<{ requiresRestart: boolean }> { + if (this._disposed) return { requiresRestart: false } + + if (!this._configManager) { + this._configManager = new CodeIndexConfigManager(input) + log.info("created indexing config manager", { workspacePath: this.workspacePath }) + } + + const { requiresRestart } = this._configManager.loadConfiguration(input) + log.info("loaded indexing configuration", { + workspacePath: this.workspacePath, + featureEnabled: this.isFeatureEnabled, + featureConfigured: this.isFeatureConfigured, + requiresRestart, + provider: this._configManager.currentEmbedderProvider, + vectorStore: this._configManager.getConfig().vectorStoreProvider, + }) + + if (!this.isFeatureEnabled) { + log.info("indexing disabled by configuration", { workspacePath: this.workspacePath }) + this._orchestrator?.stopWatcher() + return { requiresRestart } + } + + if (!this.workspacePath) { + log.info("indexing unavailable: no workspace path") + this._stateManager.setSystemState("Standby", "No workspace folder open") + return { requiresRestart } + } + + if (!this.isFeatureConfigured) { + log.info("indexing enabled but not configured", { + workspacePath: this.workspacePath, + provider: this._configManager.currentEmbedderProvider, + }) + this._orchestrator?.cancelIndexing() + this._stateManager.setSystemState( + "Standby", + "Code indexing is not configured. Save your settings to start indexing.", + ) + return { requiresRestart } + } + + if (!this._cacheManager) { + log.info("initializing indexing cache", { cacheDirectory: this.cacheDirectory }) + this._cacheManager = new CacheManager(this.cacheDirectory, this.workspacePath) + await this._cacheManager.initialize() + if (this._disposed) return { requiresRestart } + log.info("indexing cache initialized", { cacheDirectory: this.cacheDirectory }) + } + + const needsServiceRecreation = !this._serviceFactory || requiresRestart + log.info("evaluated indexing service lifecycle", { + needsServiceRecreation, + requiresRestart, + hasServiceFactory: !!this._serviceFactory, + }) + + if (needsServiceRecreation) { + try { + log.info("recreating indexing services", { workspacePath: this.workspacePath }) + await this._recreateServices() + if (this._disposed) { + this._orchestrator?.cancelIndexing() + this._orchestrator = undefined + this._searchService = undefined + return { requiresRestart } + } + log.info("indexing services recreated", { workspacePath: this.workspacePath }) + } catch (err) { + log.error("failed to recreate services", { err }) + this.emitError("manager:initialize", err, "background") + this._stateManager.setSystemState( + "Error", + `Failed to initialize: ${err instanceof Error ? err.message : String(err)}`, + ) + throw err + } + } + + const shouldStartOrRestart = + requiresRestart || (needsServiceRecreation && (!this._orchestrator || this._orchestrator.state !== "Indexing")) + + if (shouldStartOrRestart && !this._disposed) { + log.info("starting background indexing", { + workspacePath: this.workspacePath, + requiresRestart, + orchestratorState: this._orchestrator?.state, + }) + this.emitStart("background") + // Fire and forget — indexing is a long-running background process + this._orchestrator?.startIndexing("background") + } + + return { requiresRestart } + } + + public async startIndexing(): Promise { + if (this._disposed) return + if (!this.isFeatureEnabled) return + + log.info("manual indexing start requested", { workspacePath: this.workspacePath }) + + const currentStatus = this.getCurrentStatus() + if (currentStatus.systemStatus === "Error") { + log.info("recovering from indexing error state before restart", { + workspacePath: this.workspacePath, + message: currentStatus.message, + }) + this.resetRetryState() + await this.recoverFromError("manual") + return + } + + this.assertInitialized() + this.emitStart("manual") + log.info("delegating manual indexing start to orchestrator", { workspacePath: this.workspacePath }) + await this._orchestrator!.startIndexing("manual") + } + + public stopWatcher(): void { + if (!this.isFeatureEnabled) return + this._orchestrator?.stopWatcher() + } + + public cancelIndexing(): void { + if (!this.isFeatureEnabled) return + this._orchestrator?.cancelIndexing() + } + + public updateBatchSegmentThreshold(newThreshold: number): void { + this._orchestrator?.updateBatchSegmentThreshold(newThreshold) + } + + public async recoverFromError(trigger: IndexingTelemetryTrigger = "background"): Promise { + if (this._disposed) return + if (this._retryTask) { + await this._retryTask + return + } + + const attempt = this._retryAttempt + 1 + if (attempt > this._retryMaxAttempts) { + log.warn("indexing recovery skipped: retry budget exhausted", { + workspacePath: this.workspacePath, + attempts: this._retryAttempt, + maxAttempts: this._retryMaxAttempts, + }) + return + } + + const task = this.runRecovery(trigger, attempt).finally(() => { + this._retryTask = undefined + this._isRecoveringFromError = false + this.clearRetryTimer() + }) + this._retryTask = task + await task + } + + public dispose(): void { + if (this._disposed) return + this._disposed = true + this.clearRetryTimer() + this._retryTask = undefined + // RATIONALE: cancelIndexing() sets _cancelRequested and calls stopWatcher() + + // scanner.cancel(), which cooperatively aborts any in-flight scan. Using only + // stopWatcher() left the orchestrator's _runScan() unaware it should exit. + this._orchestrator?.cancelIndexing() + this._stateManager.dispose() + this._telemetry.dispose() + } + + public async clearIndexData(): Promise { + if (!this.isFeatureEnabled) return + this.assertInitialized() + await this._orchestrator!.clearIndexData() + await this._cacheManager!.clearCacheFile() + } + + public clearErrorState(): void { + this._stateManager.setSystemState("Standby", "") + } + + public getCurrentStatus() { + const status = this._stateManager.getCurrentStatus() + return { ...status, workspacePath: this.workspacePath } + } + + public async searchIndex(query: string, directoryPrefix?: string): Promise { + if (!this.isFeatureEnabled) return [] + this.assertInitialized() + return this._searchService!.searchIndex(query, directoryPrefix) + } + + private async _recreateServices(): Promise { + log.info("starting indexing service recreation", { workspacePath: this.workspacePath }) + this._orchestrator?.stopWatcher() + this._orchestrator = undefined + this._searchService = undefined + + this._serviceFactory = new CodeIndexServiceFactory( + this._configManager!, + this.workspacePath, + this._cacheManager!, + this.cacheDirectory, + (event) => this.handleTelemetry(event), + ) + + const ignoreInstance = await loadIgnore(this.workspacePath) + + const config = this._configManager!.getConfig() + const { embedder, vectorStore, scanner, fileWatcher } = this._serviceFactory.createServices( + this._cacheManager!, + ignoreInstance, + ) + log.info("created indexing services", { + workspacePath: this.workspacePath, + provider: embedder.embedderInfo.name, + vectorStore: config.vectorStoreProvider, + model: config.modelId ?? "default", + }) + + const shouldValidate = embedder && embedder.embedderInfo.name === config.embedderProvider + + if (shouldValidate) { + log.info("validating embedder configuration", { + workspacePath: this.workspacePath, + provider: embedder.embedderInfo.name, + }) + const validationResult = await this._serviceFactory.validateEmbedder(embedder) + if (!validationResult.valid) { + const errorMessage = validationResult.error || "Embedder configuration validation failed" + this._stateManager.setSystemState("Error", errorMessage) + throw new Error(errorMessage) + } + log.info("embedder configuration validated", { + workspacePath: this.workspacePath, + provider: embedder.embedderInfo.name, + }) + } + + this._orchestrator = new CodeIndexOrchestrator( + this._configManager!, + this._stateManager, + this.workspacePath, + this._cacheManager!, + vectorStore, + scanner, + fileWatcher, + (event) => this.handleTelemetry(event), + ) + + this._searchService = new CodeIndexSearchService(this._configManager!, this._stateManager, embedder, vectorStore) + + this._stateManager.setSystemState("Standby", "") + log.info("indexing services are ready", { workspacePath: this.workspacePath }) + } + + public async handleSettingsChange(input: IndexingConfigInput): Promise { + if (!this._configManager) return + + const { requiresRestart } = this._configManager.loadConfiguration(input) + log.info("processed indexing settings change", { + workspacePath: this.workspacePath, + featureEnabled: this.isFeatureEnabled, + featureConfigured: this.isFeatureConfigured, + requiresRestart, + }) + + if (!this.isFeatureEnabled) { + this._orchestrator?.stopWatcher() + this._stateManager.setSystemState("Standby", "Code indexing is disabled") + return + } + + if (requiresRestart && this.isFeatureEnabled && this.isFeatureConfigured) { + try { + if (!this._cacheManager) { + this._cacheManager = new CacheManager(this.cacheDirectory, this.workspacePath) + await this._cacheManager.initialize() + } + await this._recreateServices() + } catch (err) { + log.error("failed to recreate services on settings change", { err }) + throw err + } + } + } +} diff --git a/packages/kilo-indexing/src/indexing/model-registry.ts b/packages/kilo-indexing/src/indexing/model-registry.ts new file mode 100644 index 00000000000..b5378c5c47f --- /dev/null +++ b/packages/kilo-indexing/src/indexing/model-registry.ts @@ -0,0 +1,88 @@ +/** + * Indexing-local embedding model metadata registry. + * + * RATIONALE: The legacy codebase imported model metadata from a shared module + * (`shared/embeddingModels`) that does not exist in this package. Rather than + * recreating the full legacy module, we keep a focused registry of the models + * the indexing engine needs to know about — primarily for dimension resolution, + * default model selection, and score thresholds. + */ + +import type { EmbedderProvider } from "./interfaces/manager" + +interface ModelProfile { + dimension: number + scoreThreshold?: number + queryPrefix?: string +} + +// ASSUMPTION: These dimensions and defaults match the provider documentation +// as of 2025-Q1. Update when providers ship new embedding models. +const profiles: Record> = { + openai: { + "text-embedding-3-small": { dimension: 1536, scoreThreshold: 0.4 }, + "text-embedding-3-large": { dimension: 3072, scoreThreshold: 0.4 }, + "text-embedding-ada-002": { dimension: 1536, scoreThreshold: 0.4 }, + }, + ollama: { + "nomic-embed-text": { dimension: 768, scoreThreshold: 0.3, queryPrefix: "search_query: " }, + "mxbai-embed-large": { dimension: 1024, scoreThreshold: 0.3 }, + "all-minilm": { dimension: 384, scoreThreshold: 0.3 }, + }, + gemini: { + "gemini-embedding-001": { dimension: 3072, scoreThreshold: 0.35 }, + "text-embedding-004": { dimension: 768, scoreThreshold: 0.35 }, + "embedding-001": { dimension: 768, scoreThreshold: 0.35 }, + }, + mistral: { + "codestral-embed-2505": { dimension: 1536, scoreThreshold: 0.35 }, + "codestral-embed": { dimension: 1536, scoreThreshold: 0.35 }, + "mistral-embed": { dimension: 1024, scoreThreshold: 0.35 }, + }, + voyage: { + "voyage-code-3": { dimension: 1024, scoreThreshold: 0.35 }, + "voyage-3": { dimension: 1024, scoreThreshold: 0.35 }, + "voyage-3-lite": { dimension: 512, scoreThreshold: 0.35 }, + }, + bedrock: { + "amazon.titan-embed-text-v2:0": { dimension: 1024, scoreThreshold: 0.35 }, + "amazon.titan-embed-text-v1": { dimension: 1536, scoreThreshold: 0.35 }, + "cohere.embed-english-v3": { dimension: 1024, scoreThreshold: 0.35 }, + }, + openrouter: { + "openai/text-embedding-3-small": { dimension: 1536, scoreThreshold: 0.4 }, + "openai/text-embedding-3-large": { dimension: 3072, scoreThreshold: 0.4 }, + }, + "openai-compatible": {}, + "vercel-ai-gateway": { + "text-embedding-3-small": { dimension: 1536, scoreThreshold: 0.4 }, + }, +} + +const defaults: Record = { + openai: "text-embedding-3-small", + ollama: "nomic-embed-text", + gemini: "gemini-embedding-001", + mistral: "codestral-embed-2505", + voyage: "voyage-code-3", + bedrock: "amazon.titan-embed-text-v2:0", + openrouter: "openai/text-embedding-3-small", + "openai-compatible": "", + "vercel-ai-gateway": "text-embedding-3-small", +} + +export function getDefaultModelId(provider: EmbedderProvider): string { + return defaults[provider] ?? "" +} + +export function getModelDimension(provider: EmbedderProvider, modelId: string): number | undefined { + return profiles[provider]?.[modelId]?.dimension +} + +export function getModelScoreThreshold(provider: EmbedderProvider, modelId: string): number | undefined { + return profiles[provider]?.[modelId]?.scoreThreshold +} + +export function getModelQueryPrefix(provider: EmbedderProvider, modelId: string): string | undefined { + return profiles[provider]?.[modelId]?.queryPrefix +} diff --git a/packages/kilo-indexing/src/indexing/orchestrator.ts b/packages/kilo-indexing/src/indexing/orchestrator.ts new file mode 100644 index 00000000000..f20805bfc10 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/orchestrator.ts @@ -0,0 +1,396 @@ +import path from "path" +import type { CodeIndexConfigManager } from "./config-manager" +import { CodeIndexStateManager, type IndexingState } from "./state-manager" +import type { IFileWatcher, BatchProcessingSummary } from "./interfaces" +import type { + IndexingTelemetryEvent, + IndexingTelemetryMeta, + IndexingTelemetryMode, + IndexingTelemetryReporter, + IndexingTelemetrySource, + IndexingTelemetryTrigger, +} from "./interfaces/telemetry" +import type { IVectorStore } from "./interfaces/vector-store" +import { DirectoryScanner } from "./processors" +import type { CacheManager } from "./cache-manager" +import type { Disposable } from "./runtime" +import { Log } from "../util/log" +import { sanitizeErrorMessage } from "./shared/validation-helpers" + +const log = Log.create({ service: "indexing-orchestrator" }) + +export class CodeIndexOrchestrator { + private _fileWatcherSubscriptions: Disposable[] = [] + private _isProcessing = false + private _cancelRequested = false + + constructor( + private readonly configManager: CodeIndexConfigManager, + private readonly stateManager: CodeIndexStateManager, + private readonly workspacePath: string, + private readonly cacheManager: CacheManager, + private readonly vectorStore: IVectorStore, + private readonly scanner: DirectoryScanner, + private readonly fileWatcher: IFileWatcher, + private readonly onTelemetry?: IndexingTelemetryReporter, + ) {} + + private getTelemetryMeta(): IndexingTelemetryMeta { + const cfg = this.configManager.getConfig() + return { + provider: cfg.embedderProvider, + vectorStore: cfg.vectorStoreProvider ?? "qdrant", + modelId: cfg.modelId, + } + } + + private emitTelemetry(event: IndexingTelemetryEvent): void { + this.onTelemetry?.(event) + } + + private emitError( + location: string, + err: unknown, + source: IndexingTelemetrySource, + trigger?: IndexingTelemetryTrigger, + mode?: IndexingTelemetryMode, + ): void { + const msg = err instanceof Error ? err.message : String(err) + this.emitTelemetry({ + ...this.getTelemetryMeta(), + type: "error", + source, + location, + trigger, + mode, + error: sanitizeErrorMessage(msg), + }) + } + + public updateBatchSegmentThreshold(newThreshold: number): void { + this.scanner.updateBatchSegmentThreshold(newThreshold) + this.fileWatcher.updateBatchSegmentThreshold(newThreshold) + } + + private async _startWatcher(): Promise { + if (!this.configManager.isFeatureConfigured) { + throw new Error("Cannot start watcher: Service not configured.") + } + + log.info("starting file watcher", { workspacePath: this.workspacePath }) + this.stateManager.setSystemState("Indexing", "Initializing file watcher...") + + try { + await this.fileWatcher.initialize() + log.info("file watcher initialized", { workspacePath: this.workspacePath }) + + this._fileWatcherSubscriptions = [ + this.fileWatcher.onDidStartBatchProcessing.on((paths) => { + log.info("file watcher batch started", { + workspacePath: this.workspacePath, + filesInBatch: paths.length, + }) + if (this.stateManager.state !== "Indexing") { + this.stateManager.setSystemState("Indexing", "Processing file changes...") + } + }), + this.fileWatcher.onBatchProgressUpdate.on(({ processedInBatch, totalInBatch, currentFile }) => { + this.stateManager.reportFileQueueProgress( + processedInBatch, + totalInBatch, + currentFile ? path.basename(currentFile) : undefined, + ) + if (processedInBatch === totalInBatch) { + log.info("file watcher batch completed", { + workspacePath: this.workspacePath, + totalInBatch, + }) + if (totalInBatch > 0) { + this.stateManager.setSystemState("Indexed", "File changes processed. Index up-to-date.") + } else if (this.stateManager.state === "Indexing") { + this.stateManager.setSystemState("Indexed", "Index up-to-date. File queue empty.") + } + } + }), + this.fileWatcher.onDidFinishBatchProcessing.on((summary: BatchProcessingSummary) => { + if (summary.batchError) { + log.error("batch processing failed", { err: summary.batchError }) + } + }), + ] + this.fileWatcher.setCollecting(false) + log.info("file watcher is initialized in drain-only mode", { workspacePath: this.workspacePath }) + } catch (err) { + log.error("failed to start file watcher", { err }) + throw err + } + } + + public async startIndexing(trigger: IndexingTelemetryTrigger = "background"): Promise { + log.info("indexing start requested", { + workspacePath: this.workspacePath, + state: this.stateManager.state, + featureConfigured: this.configManager.isFeatureConfigured, + trigger, + }) + + if (!this.workspacePath) { + this.stateManager.setSystemState("Error", "Indexing requires a workspace folder.") + log.warn("start rejected: no workspace path") + return + } + + if (!this.configManager.isFeatureConfigured) { + this.stateManager.setSystemState("Standby", "Missing configuration. Save your settings to start indexing.") + log.warn("start rejected: missing configuration") + return + } + + if ( + this._isProcessing || + (this.stateManager.state !== "Standby" && + this.stateManager.state !== "Error" && + this.stateManager.state !== "Indexed") + ) { + log.warn("start rejected", { state: this.stateManager.state }) + return + } + + this._cancelRequested = false + this._isProcessing = true + this.stateManager.setSystemState("Indexing", "Initializing services...") + + let started = false + let source: IndexingTelemetrySource = "watcher" + let mode: IndexingTelemetryMode | undefined + + try { + await this._startWatcher() + + if (this._cancelRequested) { + this.stateManager.setSystemState("Standby", "Indexing cancelled.") + return + } + + source = "scan" + const collectionCreated = await this.vectorStore.initialize() + log.info("vector store initialized", { workspacePath: this.workspacePath, collectionCreated }) + started = true + + if (this._cancelRequested) { + this.stateManager.setSystemState("Standby", "Indexing cancelled.") + return + } + + if (collectionCreated) { + await this.cacheManager.clearCacheFile() + log.info("cleared indexing cache after new collection creation", { workspacePath: this.workspacePath }) + } + + const hasExistingData = await this.vectorStore.hasIndexedData() + log.info("checked vector store indexed data", { + workspacePath: this.workspacePath, + hasExistingData, + collectionCreated, + }) + + if (this._cancelRequested) { + this.stateManager.setSystemState("Standby", "Indexing cancelled.") + return + } + + mode = hasExistingData && !collectionCreated ? "incremental" : "full" + + if (mode === "incremental") { + log.info("collection has existing data, running incremental scan") + + this.stateManager.setSystemState("Indexing", "Checking for new or modified files...") + await this.vectorStore.markIndexingIncomplete() + await this._runScan(mode, trigger) + } else { + log.info("running full scan", { + workspacePath: this.workspacePath, + hasExistingData, + collectionCreated, + }) + this.stateManager.setSystemState("Indexing", "Services ready. Starting workspace scan...") + await this.vectorStore.markIndexingIncomplete() + await this._runScan(mode, trigger) + } + } catch (err) { + log.error("error during indexing", { err }) + this.emitError("orchestrator:startIndexing", err, source, trigger, mode) + + if (started) { + log.info("indexing failed after starting; preserving cache for retry") + } else { + log.info("failed to connect to vector store; preserving cache for future incremental scan") + } + + const msg = err instanceof Error ? err.message : "Unknown error" + this.stateManager.setSystemState("Error", `Failed during initial scan: ${msg}`) + this.stopWatcher() + } finally { + this._isProcessing = false + log.info("indexing start flow finished", { + workspacePath: this.workspacePath, + state: this.stateManager.state, + }) + } + } + + private async _runScan(mode: IndexingTelemetryMode, trigger: IndexingTelemetryTrigger): Promise { + if (this._cancelRequested) { + log.info("scan skipped: cancellation was requested", { workspacePath: this.workspacePath, mode }) + return + } + + log.info("starting workspace scan", { workspacePath: this.workspacePath, mode }) + let cumulativeFilesIndexed = 0 + let cumulativeFilesFound = 0 + const batchErrors: Error[] = [] + + const handleFileParsed = () => { + cumulativeFilesFound += 1 + this.stateManager.reportFileProgress(cumulativeFilesIndexed, cumulativeFilesFound) + } + + const handleFilesIndexed = (indexedCount: number) => { + cumulativeFilesIndexed += indexedCount + this.stateManager.reportFileProgress(cumulativeFilesIndexed, cumulativeFilesFound) + } + + const result = await this.scanner.scanDirectory( + this.workspacePath, + (batchError: Error) => { + log.error(`error during ${mode} scan batch`, { err: batchError }) + batchErrors.push(batchError) + }, + handleFilesIndexed, + handleFileParsed, + mode, + ) + + log.info("workspace scan completed", { + workspacePath: this.workspacePath, + mode, + filesDiscovered: cumulativeFilesFound, + filesIndexed: cumulativeFilesIndexed, + scanProcessed: result.stats.processed, + scanSkipped: result.stats.skipped, + totalBlocks: result.totalBlockCount, + batchErrorCount: batchErrors.length, + }) + + if (this._cancelRequested || this.scanner.isCancelled) { + this._isProcessing = false + if (this.stateManager.state !== "Error") { + this.stateManager.setSystemState("Standby", "Indexing cancelled.") + } + log.info("workspace scan cancelled", { workspacePath: this.workspacePath, mode }) + return + } + + if (mode === "full") { + // Validate full scan results + if (cumulativeFilesIndexed === 0 && cumulativeFilesFound > 0) { + const first = batchErrors.at(0) + const msg = first ? first.message : "No blocks were indexed" + throw new Error(`Indexing failed: ${msg}`) + } + + if (batchErrors.length > 0) { + const failureRate = (cumulativeFilesFound - cumulativeFilesIndexed) / cumulativeFilesFound + if (failureRate > 0.1) { + const first = batchErrors.at(0) + const msg = first ? first.message : "Unknown batch error" + throw new Error( + `Indexing partially failed: Only ${cumulativeFilesIndexed} of ${cumulativeFilesFound} files were indexed. ${msg}`, + ) + } + } + } + + this.fileWatcher.setCollecting(true) + await this.vectorStore.markIndexingComplete() + this.stateManager.setSystemState("Indexed", "File watcher started. Index up-to-date.") + log.info("workspace scan finalized", { + workspacePath: this.workspacePath, + mode, + filesIndexed: cumulativeFilesIndexed, + filesDiscovered: cumulativeFilesFound, + }) + + this.emitTelemetry({ + ...this.getTelemetryMeta(), + type: "completed", + source: "scan", + trigger, + mode, + filesIndexed: cumulativeFilesIndexed, + filesDiscovered: cumulativeFilesFound, + totalBlocks: result.totalBlockCount, + batchErrors: batchErrors.length, + }) + } + + public stopWatcher(): void { + log.info("stopping file watcher", { workspacePath: this.workspacePath }) + this.fileWatcher.dispose() + this.scanner.cancel() + for (const sub of this._fileWatcherSubscriptions) sub.dispose() + this._fileWatcherSubscriptions = [] + + if (this.stateManager.state !== "Error") { + this.stateManager.setSystemState("Standby", "File watcher stopped.") + } + this._isProcessing = false + log.info("file watcher stopped", { workspacePath: this.workspacePath, state: this.stateManager.state }) + } + + public cancelIndexing(): void { + log.info("cancelling indexing", { workspacePath: this.workspacePath }) + this._cancelRequested = true + this.scanner.cancel() + this.stopWatcher() + this.stateManager.setSystemState("Standby", "Indexing cancelled.") + this._isProcessing = false + log.info("indexing cancelled", { workspacePath: this.workspacePath }) + } + + public async clearIndexData(): Promise { + this._isProcessing = true + log.info("clearing index data", { workspacePath: this.workspacePath }) + + try { + this.stopWatcher() + + try { + if (this.configManager.isFeatureConfigured) { + await this.vectorStore.deleteCollection() + } else { + log.warn("service not configured, skipping vector collection clear") + } + } catch (err: any) { + log.error("failed to clear vector collection", { err }) + this.stateManager.setSystemState("Error", `Failed to clear vector collection: ${err.message}`) + } + + await this.cacheManager.clearCacheFile() + + if (this.stateManager.state !== "Error") { + this.stateManager.setSystemState("Standby", "Index data cleared successfully.") + } + } finally { + this._isProcessing = false + log.info("finished clearing index data", { + workspacePath: this.workspacePath, + state: this.stateManager.state, + }) + } + } + + public get state(): IndexingState { + return this.stateManager.state + } +} diff --git a/packages/kilo-indexing/src/indexing/processors/file-watcher.ts b/packages/kilo-indexing/src/indexing/processors/file-watcher.ts new file mode 100644 index 00000000000..22ece9fd5d3 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/processors/file-watcher.ts @@ -0,0 +1,694 @@ +import { watch as chokidarWatch, type FSWatcher as ChokidarFSWatcher } from "chokidar" +import { stat, readFile } from "fs/promises" +import { createHash } from "crypto" +import path from "path" +import { v5 as uuidv5 } from "uuid" +import type { Ignore } from "ignore" +import { Emitter, type Disposable } from "../runtime" +import { + QDRANT_CODE_BLOCK_NAMESPACE, + MAX_FILE_SIZE_BYTES, + BATCH_SEGMENT_THRESHOLD, + MAX_BATCH_RETRIES, + INITIAL_RETRY_DELAY_MS, +} from "../constants" +import { scannerExtensions } from "../shared/supported-extensions" +import { + type IFileWatcher, + type FileProcessingResult, + type IEmbedder, + type IVectorStore, + type PointStruct, + type BatchProcessingSummary, +} from "../interfaces" +import type { IndexingTelemetryMeta, IndexingTelemetryReporter } from "../interfaces/telemetry" +import { codeParser } from "./parser" +import { CacheManager } from "../cache-manager" +import { + generateNormalizedAbsolutePath, + generateRelativeFilePath, + generateRelativeIgnorePath, +} from "../shared/get-relative-path" +import { FileIgnore } from "../../file/ignore" +import { Log } from "../../util/log" +import { sanitizeErrorMessage } from "../shared/validation-helpers" + +const log = Log.create({ service: "file-watcher" }) + +/** + * Implementation of the file watcher interface. + * + * RATIONALE: Uses chokidar instead of vscode.workspace.createFileSystemWatcher + * so the watcher works outside VS Code (CLI, tests, headless). + */ +export class FileWatcher implements IFileWatcher { + private ignoreInstance?: Ignore + private watcher?: ChokidarFSWatcher + private accumulatedEvents: Map = new Map() + private batchProcessDebounceTimer?: NodeJS.Timeout + private readonly BATCH_DEBOUNCE_DELAY_MS = 500 + private readonly FILE_PROCESSING_CONCURRENCY_LIMIT = 10 + private batchSegmentThreshold: number + private maxBatchRetries: number + private collecting = true + private draining = false + private ready?: Promise + + public readonly onDidStartBatchProcessing = new Emitter() + public readonly onBatchProgressUpdate = new Emitter<{ + processedInBatch: number + totalInBatch: number + currentFile?: string + }>() + public readonly onDidFinishBatchProcessing = new Emitter() + + constructor( + private workspacePath: string, + private readonly cacheManager: CacheManager, + private embedder?: IEmbedder, + private vectorStore?: IVectorStore, + ignoreInstance?: Ignore, + batchSegmentThreshold?: number, + maxBatchRetries?: number, + private readonly onTelemetry?: IndexingTelemetryReporter, + private readonly telemetryMeta?: IndexingTelemetryMeta, + ) { + if (ignoreInstance) { + this.ignoreInstance = ignoreInstance + } + this.batchSegmentThreshold = batchSegmentThreshold ?? BATCH_SEGMENT_THRESHOLD + this.maxBatchRetries = maxBatchRetries ?? MAX_BATCH_RETRIES + } + + private emitRetry(attempt: number, batchSize: number, err: unknown): void { + if (!this.onTelemetry || !this.telemetryMeta) { + return + } + const msg = err instanceof Error ? err.message : String(err) + this.onTelemetry({ + ...this.telemetryMeta, + type: "batch_retry", + source: "watcher", + mode: "incremental", + attempt, + maxRetries: this.maxBatchRetries, + batchSize, + error: sanitizeErrorMessage(msg), + }) + } + + private emitError(location: string, err: unknown, retryCount?: number): void { + if (!this.onTelemetry || !this.telemetryMeta) { + return + } + const msg = err instanceof Error ? err.message : String(err) + this.onTelemetry({ + ...this.telemetryMeta, + type: "error", + source: "watcher", + mode: "incremental", + location, + error: sanitizeErrorMessage(msg), + retryCount, + maxRetries: this.maxBatchRetries, + }) + } + + /** + * Initializes the file watcher using chokidar. + * + * RATIONALE: chokidar watches the filesystem directly using native OS events, + * removing the dependency on VS Code's file system watcher API. + */ + async initialize(): Promise { + if (this.ready) { + await this.ready + return + } + + log.info("initializing file watcher", { workspacePath: this.workspacePath }) + + this.watcher = chokidarWatch(this.workspacePath, { + ignored: (filePath: string) => { + const relativeFilePath = generateRelativeIgnorePath(filePath, this.workspacePath) + if (!relativeFilePath) return false + if (FileIgnore.match(relativeFilePath)) return true + return this.ignoreInstance?.ignores(relativeFilePath) ?? false + }, + persistent: true, + ignoreInitial: true, + }) + + this.watcher.on("add", (filePath) => this.handleFileEvent(filePath, "create")) + this.watcher.on("change", (filePath) => this.handleFileEvent(filePath, "change")) + this.watcher.on("unlink", (filePath) => this.handleFileEvent(filePath, "delete")) + this.ready = new Promise((resolve, reject) => { + this.watcher?.once("ready", resolve) + this.watcher?.once("error", reject) + }) + await this.ready + log.info("file watcher ready", { workspacePath: this.workspacePath }) + } + + setCollecting(collecting: boolean): void { + this.collecting = collecting + log.info("updated watcher collection mode", { + workspacePath: this.workspacePath, + collecting, + pendingEvents: this.accumulatedEvents.size, + }) + if (collecting) this.scheduleBatchProcessing() + } + + /** + * Updates the batch segment threshold. + */ + updateBatchSegmentThreshold(newThreshold: number): void { + this.batchSegmentThreshold = newThreshold + } + + /** + * Disposes the file watcher and cleans up resources. + */ + dispose(): void { + this.watcher?.close() + if (this.batchProcessDebounceTimer) { + clearTimeout(this.batchProcessDebounceTimer) + } + this.onDidStartBatchProcessing.dispose() + this.onBatchProgressUpdate.dispose() + this.onDidFinishBatchProcessing.dispose() + this.accumulatedEvents.clear() + this.ready = undefined + } + + /** + * Handles a file event from chokidar by accumulating it and scheduling batch processing. + */ + private handleFileEvent(filePath: string, type: "create" | "change" | "delete"): void { + if (!this.shouldIndex(filePath)) return + this.accumulatedEvents.set(filePath, { path: filePath, type }) + if (!this.collecting) return + this.scheduleBatchProcessing() + } + + /** + * Schedules batch processing with debounce. + */ + private scheduleBatchProcessing(): void { + if (!this.collecting) return + if (this.batchProcessDebounceTimer) { + clearTimeout(this.batchProcessDebounceTimer) + } + this.batchProcessDebounceTimer = setTimeout(() => this.triggerBatchProcessing(), this.BATCH_DEBOUNCE_DELAY_MS) + } + + /** + * Triggers processing of accumulated events. + */ + private async triggerBatchProcessing(): Promise { + if (this.draining || this.accumulatedEvents.size === 0 || !this.collecting) { + return + } + + this.draining = true + log.info("starting watcher event drain", { + workspacePath: this.workspacePath, + pendingEvents: this.accumulatedEvents.size, + }) + + while (this.collecting && this.accumulatedEvents.size > 0) { + const eventsToProcess = new Map(this.accumulatedEvents) + this.accumulatedEvents.clear() + + const filePathsInBatch = Array.from(eventsToProcess.keys()) + this.onDidStartBatchProcessing.fire(filePathsInBatch) + await this.processBatch(eventsToProcess) + } + + this.draining = false + log.info("completed watcher event drain", { workspacePath: this.workspacePath }) + } + + private shouldIndex(filePath: string) { + const relativeFilePath = generateRelativeIgnorePath(filePath, this.workspacePath) + if (!relativeFilePath) return false + const ext = path.extname(filePath).toLowerCase() + if (FileIgnore.match(relativeFilePath)) return false + if (this.ignoreInstance?.ignores(relativeFilePath)) return false + return scannerExtensions.includes(ext) || !path.extname(filePath) + } + + /** + * Handles deletion phase of batch processing. + * + * Deletes vector store points for explicitly deleted files and for files + * that changed (old points are cleared before re-upserting new ones). + */ + private async _handleBatchDeletions( + batchResults: FileProcessingResult[], + processedCountInBatch: number, + totalFilesInBatch: number, + pathsToExplicitlyDelete: string[], + filesToUpsertDetails: Array<{ path: string; originalType: "create" | "change" }>, + ): Promise<{ overallBatchError?: Error; clearedPaths: Set; processedCount: number }> { + let overallBatchError: Error | undefined + const allPathsToClearFromDB = new Set(pathsToExplicitlyDelete) + + for (const fileDetail of filesToUpsertDetails) { + if (fileDetail.originalType === "change") { + allPathsToClearFromDB.add(fileDetail.path) + } + } + + if (allPathsToClearFromDB.size > 0 && this.vectorStore) { + try { + await this.vectorStore.deletePointsByMultipleFilePaths(Array.from(allPathsToClearFromDB)) + + for (const path of pathsToExplicitlyDelete) { + this.cacheManager.deleteHash(path) + batchResults.push({ path, status: "success" }) + processedCountInBatch++ + this.onBatchProgressUpdate.fire({ + processedInBatch: processedCountInBatch, + totalInBatch: totalFilesInBatch, + currentFile: path, + }) + } + } catch (error: any) { + const errorStatus = error?.status || error?.response?.status || error?.statusCode + const errorMessage = error instanceof Error ? error.message : String(error) + + log.error("batch deletion failed", { + error: sanitizeErrorMessage(errorMessage), + location: "deletePointsByMultipleFilePaths", + errorType: "deletion_error", + errorStatus, + }) + this.emitError("file-watcher:deletePointsByMultipleFilePaths", error) + + overallBatchError = error as Error + for (const path of pathsToExplicitlyDelete) { + batchResults.push({ path, status: "error", error: error as Error }) + processedCountInBatch++ + this.onBatchProgressUpdate.fire({ + processedInBatch: processedCountInBatch, + totalInBatch: totalFilesInBatch, + currentFile: path, + }) + } + } + } + + return { overallBatchError, clearedPaths: allPathsToClearFromDB, processedCount: processedCountInBatch } + } + + /** + * Processes individual files, parses them, creates embeddings, and collects + * the resulting points for a later batch upsert. + */ + private async _processFilesAndPrepareUpserts( + filesToUpsertDetails: Array<{ path: string; originalType: "create" | "change" }>, + batchResults: FileProcessingResult[], + processedCountInBatch: number, + totalFilesInBatch: number, + pathsToExplicitlyDelete: string[], + ): Promise<{ + pointsForBatchUpsert: PointStruct[] + successfullyProcessedForUpsert: Array<{ path: string; newHash?: string }> + processedCount: number + }> { + const pointsForBatchUpsert: PointStruct[] = [] + const successfullyProcessedForUpsert: Array<{ path: string; newHash?: string }> = [] + const filesToProcessConcurrently = [...filesToUpsertDetails] + + for (let i = 0; i < filesToProcessConcurrently.length; i += this.FILE_PROCESSING_CONCURRENCY_LIMIT) { + const chunkToProcess = filesToProcessConcurrently.slice(i, i + this.FILE_PROCESSING_CONCURRENCY_LIMIT) + + const chunkProcessingPromises = chunkToProcess.map(async (fileDetail) => { + this.onBatchProgressUpdate.fire({ + processedInBatch: processedCountInBatch, + totalInBatch: totalFilesInBatch, + currentFile: fileDetail.path, + }) + try { + const result = await this.processFile(fileDetail.path) + return { path: fileDetail.path, result: result, error: undefined } + } catch (e) { + const error = e as Error + log.error(`unhandled exception processing file ${fileDetail.path}`, { error }) + return { path: fileDetail.path, result: undefined, error: error } + } + }) + + const settledChunkResults = await Promise.allSettled(chunkProcessingPromises) + + for (const settledResult of settledChunkResults) { + let resultPath: string | undefined + + if (settledResult.status === "fulfilled") { + const { path, result, error: directError } = settledResult.value + resultPath = path + + if (directError) { + batchResults.push({ path, status: "error", error: directError }) + } else if (result) { + if (result.status === "skipped" || result.status === "local_error") { + batchResults.push(result) + } else if (result.status === "processed_for_batching" && result.pointsToUpsert) { + pointsForBatchUpsert.push(...result.pointsToUpsert) + if (result.path && result.newHash) { + successfullyProcessedForUpsert.push({ path: result.path, newHash: result.newHash }) + } else if (result.path && !result.newHash) { + successfullyProcessedForUpsert.push({ path: result.path }) + } + } else { + batchResults.push({ + path, + status: "error", + error: new Error(`Unexpected result status from processFile: ${result.status} for file ${path}`), + }) + } + } else { + batchResults.push({ + path, + status: "error", + error: new Error(`Fulfilled promise with no result or error for file ${path}`), + }) + } + } else { + const error = settledResult.reason as Error + const rejectedPath = (settledResult.reason as any)?.path || "unknown" + log.error("a file processing promise was rejected", { error }) + batchResults.push({ + path: rejectedPath, + status: "error", + error: error, + }) + } + + if (!pathsToExplicitlyDelete.includes(resultPath || "")) { + processedCountInBatch++ + } + this.onBatchProgressUpdate.fire({ + processedInBatch: processedCountInBatch, + totalInBatch: totalFilesInBatch, + currentFile: resultPath, + }) + } + } + + return { + pointsForBatchUpsert, + successfullyProcessedForUpsert, + processedCount: processedCountInBatch, + } + } + + /** + * Executes batch upsert operations against the vector store with retry logic. + */ + private async _executeBatchUpsertOperations( + pointsForBatchUpsert: PointStruct[], + successfullyProcessedForUpsert: Array<{ path: string; newHash?: string }>, + batchResults: FileProcessingResult[], + overallBatchError?: Error, + ): Promise { + if (pointsForBatchUpsert.length > 0 && this.vectorStore && !overallBatchError) { + try { + for (let i = 0; i < pointsForBatchUpsert.length; i += this.batchSegmentThreshold) { + const batch = pointsForBatchUpsert.slice(i, i + this.batchSegmentThreshold) + let retryCount = 0 + let upsertError: Error | undefined + + while (retryCount < this.maxBatchRetries) { + try { + await this.vectorStore.upsertPoints(batch) + break + } catch (error) { + upsertError = error as Error + retryCount++ + if (retryCount === this.maxBatchRetries) { + log.error("upsert retry exhausted", { + error: sanitizeErrorMessage(upsertError.message), + location: "upsertPoints", + errorType: "upsert_retry_exhausted", + retryCount: this.maxBatchRetries, + }) + this.emitError("file-watcher:upsert_retry_exhausted", upsertError, this.maxBatchRetries) + throw new Error(`Failed to upsert batch after ${this.maxBatchRetries} retries: ${upsertError.message}`) + } + this.emitRetry(retryCount, batch.length, upsertError) + await new Promise((resolve) => setTimeout(resolve, INITIAL_RETRY_DELAY_MS * Math.pow(2, retryCount - 1))) + } + } + } + + for (const { path, newHash } of successfullyProcessedForUpsert) { + if (newHash) { + this.cacheManager.updateHash(path, newHash) + } + batchResults.push({ path, status: "success" }) + } + } catch (error) { + const err = error as Error + overallBatchError = overallBatchError || err + this.emitError("file-watcher:batch_upsert_error", err) + log.error("batch upsert error", { + error: sanitizeErrorMessage(err.message), + location: "executeBatchUpsertOperations", + errorType: "batch_upsert_error", + affectedFiles: successfullyProcessedForUpsert.length, + }) + for (const { path } of successfullyProcessedForUpsert) { + batchResults.push({ path, status: "error", error: err }) + } + } + } else if (overallBatchError && pointsForBatchUpsert.length > 0) { + for (const { path } of successfullyProcessedForUpsert) { + batchResults.push({ path, status: "error", error: overallBatchError }) + } + } + + return overallBatchError + } + + /** + * Processes a batch of accumulated events through three phases: + * 1. Handle deletions (remove old points from vector store) + * 2. Process files and prepare upserts (parse, embed) + * 3. Execute batch upsert operations + */ + private async processBatch( + eventsToProcess: Map, + ): Promise { + const batchResults: FileProcessingResult[] = [] + let processedCountInBatch = 0 + const totalFilesInBatch = eventsToProcess.size + let overallBatchError: Error | undefined + + // Initial progress update + this.onBatchProgressUpdate.fire({ + processedInBatch: 0, + totalInBatch: totalFilesInBatch, + currentFile: undefined, + }) + + // Categorize events + const pathsToExplicitlyDelete: string[] = [] + const filesToUpsertDetails: Array<{ path: string; originalType: "create" | "change" }> = [] + + for (const event of eventsToProcess.values()) { + if (event.type === "delete") { + pathsToExplicitlyDelete.push(event.path) + } else { + filesToUpsertDetails.push({ + path: event.path, + originalType: event.type, + }) + } + } + + log.info("processing file watcher batch", { + workspacePath: this.workspacePath, + batchSize: totalFilesInBatch, + deletes: pathsToExplicitlyDelete.length, + upserts: filesToUpsertDetails.length, + }) + + // Phase 1: Handle deletions + const { overallBatchError: deletionError, processedCount: deletionCount } = await this._handleBatchDeletions( + batchResults, + processedCountInBatch, + totalFilesInBatch, + pathsToExplicitlyDelete, + filesToUpsertDetails, + ) + overallBatchError = deletionError + processedCountInBatch = deletionCount + + // Phase 2: Process files and prepare upserts + const { + pointsForBatchUpsert, + successfullyProcessedForUpsert, + processedCount: upsertCount, + } = await this._processFilesAndPrepareUpserts( + filesToUpsertDetails, + batchResults, + processedCountInBatch, + totalFilesInBatch, + pathsToExplicitlyDelete, + ) + processedCountInBatch = upsertCount + + // Phase 3: Execute batch upsert + overallBatchError = await this._executeBatchUpsertOperations( + pointsForBatchUpsert, + successfullyProcessedForUpsert, + batchResults, + overallBatchError, + ) + + // Finalize + this.onDidFinishBatchProcessing.fire({ + processedFiles: batchResults, + batchError: overallBatchError, + }) + + const successCount = batchResults.filter((item) => item.status === "success").length + const skippedCount = batchResults.filter((item) => item.status === "skipped").length + const errorCount = batchResults.filter((item) => item.status === "error" || item.status === "local_error").length + + log.info("completed file watcher batch", { + workspacePath: this.workspacePath, + batchSize: totalFilesInBatch, + successCount, + skippedCount, + errorCount, + hasBatchError: !!overallBatchError, + }) + + this.onBatchProgressUpdate.fire({ + processedInBatch: totalFilesInBatch, + totalInBatch: totalFilesInBatch, + }) + + if (this.accumulatedEvents.size === 0) { + this.onBatchProgressUpdate.fire({ + processedInBatch: 0, + totalInBatch: 0, + currentFile: undefined, + }) + } + } + + /** + * Processes a single file: checks ignore rules, reads content, computes hash, + * parses code blocks, creates embeddings, and returns points for batch upsert. + */ + async processFile(filePath: string): Promise { + try { + // Check if file is in an ignored directory + const relativeFilePath = generateRelativeIgnorePath(filePath, this.workspacePath) + if (!relativeFilePath) { + return { + path: filePath, + status: "skipped" as const, + reason: "File path is outside workspace", + } + } + + if (FileIgnore.match(relativeFilePath)) { + return { + path: filePath, + status: "skipped" as const, + reason: "File is in an ignored directory", + } + } + + // Check if file should be ignored by root .gitignore / .kilocodeignore rules. + if (this.ignoreInstance && this.ignoreInstance.ignores(relativeFilePath)) { + return { + path: filePath, + status: "skipped" as const, + reason: "File is ignored by .gitignore or .kilocodeignore", + } + } + + // Check file size + const fileStat = await stat(filePath) + if (fileStat.size > MAX_FILE_SIZE_BYTES) { + return { + path: filePath, + status: "skipped" as const, + reason: "File is too large", + } + } + + // Read file content + const content = await readFile(filePath, "utf-8") + + // Calculate hash + const newHash = createHash("sha256").update(content).digest("hex") + + // Check if file has changed + if (this.cacheManager.getHash(filePath) === newHash) { + return { + path: filePath, + status: "skipped" as const, + reason: "File has not changed", + } + } + + // Parse file + const blocks = await codeParser.parseFile(filePath, { content, fileHash: newHash }) + + // Prepare points for batch processing + let pointsToUpsert: PointStruct[] = [] + if (this.embedder && blocks.length > 0) { + const texts = blocks.map((block) => block.content) + const { embeddings } = await this.embedder.createEmbeddings(texts) + if (embeddings.length !== blocks.length) { + return { + path: filePath, + status: "local_error" as const, + error: new Error( + `Embedding count mismatch for ${filePath}: expected ${blocks.length}, got ${embeddings.length}`, + ), + } + } + + pointsToUpsert = blocks.map((block, index) => { + const vector = embeddings[index]! + const normalizedAbsolutePath = generateNormalizedAbsolutePath(block.file_path, this.workspacePath) + const pointId = uuidv5(block.segmentHash, QDRANT_CODE_BLOCK_NAMESPACE) + + return { + id: pointId, + vector, + payload: { + filePath: generateRelativeFilePath(normalizedAbsolutePath, this.workspacePath), + codeChunk: block.content, + startLine: block.start_line, + endLine: block.end_line, + segmentHash: block.segmentHash, + }, + } + }) + } + + return { + path: filePath, + status: "processed_for_batching" as const, + newHash, + pointsToUpsert, + } + } catch (error) { + return { + path: filePath, + status: "local_error" as const, + error: error as Error, + } + } + } +} diff --git a/packages/kilo-indexing/src/indexing/processors/index.ts b/packages/kilo-indexing/src/indexing/processors/index.ts new file mode 100644 index 00000000000..c244d9b8753 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/processors/index.ts @@ -0,0 +1,3 @@ +export * from "./parser" +export * from "./scanner" +export * from "./file-watcher" diff --git a/packages/kilo-indexing/src/indexing/processors/parser.ts b/packages/kilo-indexing/src/indexing/processors/parser.ts new file mode 100644 index 00000000000..50a3ea2f9ca --- /dev/null +++ b/packages/kilo-indexing/src/indexing/processors/parser.ts @@ -0,0 +1,557 @@ +import { readFile } from "fs/promises" +import { createHash } from "crypto" +import * as path from "path" +import { Node } from "web-tree-sitter" +import { type LanguageParser, loadRequiredLanguageParsers } from "../../tree-sitter/languageParser" +import { parseMarkdown } from "../../tree-sitter/markdownParser" +import type { ICodeParser, CodeBlock } from "../interfaces" +import { scannerExtensions, shouldUseFallbackChunking } from "../shared/supported-extensions" +import { MAX_BLOCK_CHARS, MIN_BLOCK_CHARS, MIN_CHUNK_REMAINDER_CHARS, MAX_CHARS_TOLERANCE_FACTOR } from "../constants" +import { Log } from "../../util/log" +import { sanitizeErrorMessage } from "../shared/validation-helpers" + +const log = Log.create({ service: "indexing-parser" }) + +/** + * Implementation of the code parser interface + */ +export class CodeParser implements ICodeParser { + private loadedParsers: LanguageParser = {} + private pendingLoads: Map> = new Map() + private failedParsers: Set = new Set() + private parserFallbackNotified: Set = new Set() + // Markdown files are now supported using the custom markdown parser + // which extracts headers and sections for semantic indexing + + private _fallbackForUnavailableParser( + ext: string, + filePath: string, + content: string, + fileHash: string, + seenSegmentHashes: Set, + error?: unknown, + ): CodeBlock[] { + this.failedParsers.add(ext) + + if (!this.parserFallbackNotified.has(ext)) { + this.parserFallbackNotified.add(ext) + log.warn("tree-sitter parser unavailable, using fallback chunking", { + ext, + filePath, + reason: sanitizeErrorMessage(error instanceof Error ? error.message : String(error ?? "parser unavailable")), + }) + } + + return this._performFallbackChunking(filePath, content, fileHash, seenSegmentHashes) + } + + /** + * Parses a code file into code blocks + * @param filePath Path to the file to parse + * @param options Optional parsing options + * @returns Promise resolving to array of code blocks + */ + async parseFile( + filePath: string, + options?: { + content?: string + fileHash?: string + }, + ): Promise { + // Get file extension + const ext = path.extname(filePath).toLowerCase() + + // Skip if not a supported language + if (!this.isSupportedLanguage(ext)) { + return [] + } + + // Get file content + let content: string + let fileHash: string + + if (options?.content) { + content = options.content + fileHash = options.fileHash || this.createFileHash(content) + } else { + try { + content = await readFile(filePath, "utf8") + fileHash = this.createFileHash(content) + } catch (error) { + log.error(`error reading file ${filePath}`, { err: error }) + return [] + } + } + + // Parse the file + return this.parseContent(filePath, content, fileHash) + } + + /** + * Checks if a language is supported + * @param extension File extension + * @returns Boolean indicating if the language is supported + */ + private isSupportedLanguage(extension: string): boolean { + return scannerExtensions.includes(extension) + } + + /** + * Creates a hash for a file + * @param content File content + * @returns Hash string + */ + private createFileHash(content: string): string { + return createHash("sha256").update(content).digest("hex") + } + + /** + * Parses file content into code blocks + * @param filePath Path to the file + * @param content File content + * @param fileHash File hash + * @returns Array of code blocks + */ + private async parseContent(filePath: string, content: string, fileHash: string): Promise { + const ext = path.extname(filePath).slice(1).toLowerCase() + const seenSegmentHashes = new Set() + + // Handle markdown files specially + if (ext === "md" || ext === "markdown") { + return this.parseMarkdownContent(filePath, content, fileHash, seenSegmentHashes) + } + + // Check if this extension should use fallback chunking + if (shouldUseFallbackChunking(`.${ext}`)) { + return this._performFallbackChunking(filePath, content, fileHash, seenSegmentHashes) + } + + if (this.failedParsers.has(ext)) { + return this._performFallbackChunking(filePath, content, fileHash, seenSegmentHashes) + } + + // Check if we already have the parser loaded + if (!this.loadedParsers[ext]) { + const pendingLoad = this.pendingLoads.get(ext) + if (pendingLoad) { + try { + await pendingLoad + } catch (error) { + return this._fallbackForUnavailableParser(ext, filePath, content, fileHash, seenSegmentHashes, error) + } + } else { + const loadPromise = loadRequiredLanguageParsers([filePath]) + this.pendingLoads.set(ext, loadPromise) + try { + const newParsers = await loadPromise + if (newParsers) { + this.loadedParsers = { ...this.loadedParsers, ...newParsers } + } + } catch (error) { + return this._fallbackForUnavailableParser(ext, filePath, content, fileHash, seenSegmentHashes, error) + } finally { + this.pendingLoads.delete(ext) + } + } + } + + const language = this.loadedParsers[ext] + if (!language) { + return this._fallbackForUnavailableParser(ext, filePath, content, fileHash, seenSegmentHashes) + } + + const tree = language.parser.parse(content) + + // We don't need to get the query string from languageQueries since it's already loaded + // in the language object + const captures = tree ? language.query.captures(tree.rootNode) : [] + + // Check if captures are empty + if (captures.length === 0) { + if (content.length >= MIN_BLOCK_CHARS) { + // Perform fallback chunking if content is large enough + const blocks = this._performFallbackChunking(filePath, content, fileHash, seenSegmentHashes) + return blocks + } else { + // Return empty if content is too small for fallback + return [] + } + } + + const results: CodeBlock[] = [] + + // Process captures if not empty + const queue: Node[] = Array.from(captures).map((capture: any) => capture.node) + + while (queue.length > 0) { + const currentNode = queue.shift()! + // const lineSpan = currentNode.endPosition.row - currentNode.startPosition.row + 1 // Removed as per lint error + + // Check if the node meets the minimum character requirement + if (currentNode.text.length >= MIN_BLOCK_CHARS) { + // If it also exceeds the maximum character limit, try to break it down + if (currentNode.text.length > MAX_BLOCK_CHARS * MAX_CHARS_TOLERANCE_FACTOR) { + if (currentNode.children.filter((child) => child !== null).length > 0) { + // If it has children, process them instead + queue.push(...currentNode.children.filter((child) => child !== null)) + } else { + // If it's a leaf node, chunk it + const chunkedBlocks = this._chunkLeafNodeByLines(currentNode, filePath, fileHash, seenSegmentHashes) + results.push(...chunkedBlocks) + } + } else { + // Node meets min chars and is within max chars, create a block + const identifier = + currentNode.childForFieldName("name")?.text || + currentNode.children.find((c) => c?.type === "identifier")?.text || + null + const type = currentNode.type + const start_line = currentNode.startPosition.row + 1 + const end_line = currentNode.endPosition.row + 1 + const content = currentNode.text + const contentPreview = content.slice(0, 100) + const segmentHash = createHash("sha256") + .update(`${filePath}-${start_line}-${end_line}-${content.length}-${contentPreview}`) + .digest("hex") + + if (!seenSegmentHashes.has(segmentHash)) { + seenSegmentHashes.add(segmentHash) + results.push({ + file_path: filePath, + identifier, + type, + start_line, + end_line, + content, + segmentHash, + fileHash, + }) + } + } + } + // Nodes smaller than minBlockChars are ignored + } + + return results + } + + /** + * Common helper function to chunk text by lines, avoiding tiny remainders. + */ + private _chunkTextByLines( + lines: string[], + filePath: string, + fileHash: string, + chunkType: string, + seenSegmentHashes: Set, + baseStartLine: number = 1, // 1-based start line of the *first* line in the `lines` array + ): CodeBlock[] { + const chunks: CodeBlock[] = [] + let currentChunkLines: string[] = [] + let currentChunkLength = 0 + let chunkStartLineIndex = 0 // 0-based index within the `lines` array + const effectiveMaxChars = MAX_BLOCK_CHARS * MAX_CHARS_TOLERANCE_FACTOR + + const finalizeChunk = (endLineIndex: number) => { + if (currentChunkLength >= MIN_BLOCK_CHARS && currentChunkLines.length > 0) { + const chunkContent = currentChunkLines.join("\n") + const startLine = baseStartLine + chunkStartLineIndex + const endLine = baseStartLine + endLineIndex + const contentPreview = chunkContent.slice(0, 100) + const segmentHash = createHash("sha256") + .update(`${filePath}-${startLine}-${endLine}-${chunkContent.length}-${contentPreview}`) + .digest("hex") + + if (!seenSegmentHashes.has(segmentHash)) { + seenSegmentHashes.add(segmentHash) + chunks.push({ + file_path: filePath, + identifier: null, + type: chunkType, + start_line: startLine, + end_line: endLine, + content: chunkContent, + segmentHash, + fileHash, + }) + } + } + currentChunkLines = [] + currentChunkLength = 0 + chunkStartLineIndex = endLineIndex + 1 + } + + const createSegmentBlock = (segment: string, originalLineNumber: number, startCharIndex: number) => { + const segmentPreview = segment.slice(0, 100) + const segmentHash = createHash("sha256") + .update( + `${filePath}-${originalLineNumber}-${originalLineNumber}-${startCharIndex}-${segment.length}-${segmentPreview}`, + ) + .digest("hex") + + if (!seenSegmentHashes.has(segmentHash)) { + seenSegmentHashes.add(segmentHash) + chunks.push({ + file_path: filePath, + identifier: null, + type: `${chunkType}_segment`, + start_line: originalLineNumber, + end_line: originalLineNumber, + content: segment, + segmentHash, + fileHash, + }) + } + } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const lineLength = line.length + (i < lines.length - 1 ? 1 : 0) // +1 for newline, except last line + const originalLineNumber = baseStartLine + i + + // Handle oversized lines (longer than effectiveMaxChars) + if (lineLength > effectiveMaxChars) { + // Finalize any existing normal chunk before processing the oversized line + if (currentChunkLines.length > 0) { + finalizeChunk(i - 1) + } + + // Split the oversized line into segments + let remainingLineContent = line + let currentSegmentStartChar = 0 + while (remainingLineContent.length > 0) { + const segment = remainingLineContent.substring(0, MAX_BLOCK_CHARS) + remainingLineContent = remainingLineContent.substring(MAX_BLOCK_CHARS) + createSegmentBlock(segment, originalLineNumber, currentSegmentStartChar) + currentSegmentStartChar += MAX_BLOCK_CHARS + } + // Update chunkStartLineIndex to continue processing from the next line + chunkStartLineIndex = i + 1 + continue + } + + // Handle normally sized lines + if (currentChunkLength > 0 && currentChunkLength + lineLength > effectiveMaxChars) { + // Re-balancing Logic + let splitIndex = i - 1 + let remainderLength = 0 + for (let j = i; j < lines.length; j++) { + remainderLength += lines[j].length + (j < lines.length - 1 ? 1 : 0) + } + + if ( + currentChunkLength >= MIN_BLOCK_CHARS && + remainderLength < MIN_CHUNK_REMAINDER_CHARS && + currentChunkLines.length > 1 + ) { + for (let k = i - 2; k >= chunkStartLineIndex; k--) { + const potentialChunkLines = lines.slice(chunkStartLineIndex, k + 1) + const potentialChunkLength = potentialChunkLines.join("\n").length + 1 + const potentialNextChunkLines = lines.slice(k + 1) + const potentialNextChunkLength = potentialNextChunkLines.join("\n").length + 1 + + if (potentialChunkLength >= MIN_BLOCK_CHARS && potentialNextChunkLength >= MIN_CHUNK_REMAINDER_CHARS) { + splitIndex = k + break + } + } + } + + finalizeChunk(splitIndex) + + if (i >= chunkStartLineIndex) { + currentChunkLines.push(line) + currentChunkLength += lineLength + } else { + i = chunkStartLineIndex - 1 + continue + } + } else { + currentChunkLines.push(line) + currentChunkLength += lineLength + } + } + + // Process the last remaining chunk + if (currentChunkLines.length > 0) { + finalizeChunk(lines.length - 1) + } + + return chunks + } + + private _performFallbackChunking( + filePath: string, + content: string, + fileHash: string, + seenSegmentHashes: Set, + ): CodeBlock[] { + const lines = content.split("\n") + return this._chunkTextByLines(lines, filePath, fileHash, "fallback_chunk", seenSegmentHashes) + } + + private _chunkLeafNodeByLines( + node: Node, + filePath: string, + fileHash: string, + seenSegmentHashes: Set, + ): CodeBlock[] { + const lines = node.text.split("\n") + const baseStartLine = node.startPosition.row + 1 + return this._chunkTextByLines( + lines, + filePath, + fileHash, + node.type, // Use the node's type + seenSegmentHashes, + baseStartLine, + ) + } + + /** + * Helper method to process markdown content sections with consistent chunking logic + */ + private processMarkdownSection( + lines: string[], + filePath: string, + fileHash: string, + type: string, + seenSegmentHashes: Set, + startLine: number, + identifier: string | null = null, + ): CodeBlock[] { + const content = lines.join("\n") + + if (content.trim().length < MIN_BLOCK_CHARS) { + return [] + } + + // Check if content needs chunking (either total size or individual line size) + const needsChunking = + content.length > MAX_BLOCK_CHARS * MAX_CHARS_TOLERANCE_FACTOR || + lines.some((line) => line.length > MAX_BLOCK_CHARS * MAX_CHARS_TOLERANCE_FACTOR) + + if (needsChunking) { + // Apply chunking for large content or oversized lines + const chunks = this._chunkTextByLines(lines, filePath, fileHash, type, seenSegmentHashes, startLine) + // Preserve identifier in all chunks if provided + if (identifier) { + chunks.forEach((chunk) => { + chunk.identifier = identifier + }) + } + return chunks + } + + // Create a single block for normal-sized content with no oversized lines + const endLine = startLine + lines.length - 1 + const contentPreview = content.slice(0, 100) + const segmentHash = createHash("sha256") + .update(`${filePath}-${startLine}-${endLine}-${content.length}-${contentPreview}`) + .digest("hex") + + if (!seenSegmentHashes.has(segmentHash)) { + seenSegmentHashes.add(segmentHash) + return [ + { + file_path: filePath, + identifier, + type, + start_line: startLine, + end_line: endLine, + content, + segmentHash, + fileHash, + }, + ] + } + + return [] + } + + private parseMarkdownContent( + filePath: string, + content: string, + fileHash: string, + seenSegmentHashes: Set, + ): CodeBlock[] { + const lines = content.split("\n") + const markdownCaptures = parseMarkdown(content) || [] + + if (markdownCaptures.length === 0) { + // No headers found, process entire content + return this.processMarkdownSection(lines, filePath, fileHash, "markdown_content", seenSegmentHashes, 1) + } + + const results: CodeBlock[] = [] + let lastProcessedLine = 0 + + // Process content before the first header + if (markdownCaptures.length > 0) { + const firstHeaderLine = markdownCaptures[0].node.startPosition.row + if (firstHeaderLine > 0) { + const preHeaderLines = lines.slice(0, firstHeaderLine) + const preHeaderBlocks = this.processMarkdownSection( + preHeaderLines, + filePath, + fileHash, + "markdown_content", + seenSegmentHashes, + 1, + ) + results.push(...preHeaderBlocks) + } + } + + // Process markdown captures (headers and sections) + for (let i = 0; i < markdownCaptures.length; i += 2) { + const nameCapture = markdownCaptures[i] + // Ensure we don't go out of bounds when accessing the next capture + if (i + 1 >= markdownCaptures.length) break + const definitionCapture = markdownCaptures[i + 1] + + if (!definitionCapture) continue + + const startLine = definitionCapture.node.startPosition.row + 1 + const endLine = definitionCapture.node.endPosition.row + 1 + const sectionLines = lines.slice(startLine - 1, endLine) + + // Extract header level for type classification + const headerMatch = nameCapture.name.match(/\.h(\d)$/) + const headerLevel = headerMatch ? parseInt(headerMatch[1]) : 1 + const headerText = nameCapture.node.text + + const sectionBlocks = this.processMarkdownSection( + sectionLines, + filePath, + fileHash, + `markdown_header_h${headerLevel}`, + seenSegmentHashes, + startLine, + headerText, + ) + results.push(...sectionBlocks) + + lastProcessedLine = endLine + } + + // Process any remaining content after the last header section + if (lastProcessedLine < lines.length) { + const remainingLines = lines.slice(lastProcessedLine) + const remainingBlocks = this.processMarkdownSection( + remainingLines, + filePath, + fileHash, + "markdown_content", + seenSegmentHashes, + lastProcessedLine + 1, + ) + results.push(...remainingBlocks) + } + + return results + } +} + +// Export a singleton instance for convenience +export const codeParser = new CodeParser() diff --git a/packages/kilo-indexing/src/indexing/processors/scanner.ts b/packages/kilo-indexing/src/indexing/processors/scanner.ts new file mode 100644 index 00000000000..0c137589689 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/processors/scanner.ts @@ -0,0 +1,662 @@ +import type { Ignore } from "ignore" +import { stat, readFile } from "fs/promises" +import path from "path" +import { glob } from "glob" +import { + generateNormalizedAbsolutePath, + generateRelativeFilePath, + generateRelativeIgnorePath, +} from "../shared/get-relative-path" +import { scannerExtensions } from "../shared/supported-extensions" +import type { CodeBlock, ICodeParser, IEmbedder, IVectorStore, IDirectoryScanner } from "../interfaces" +import { createHash } from "crypto" +import { v5 as uuidv5 } from "uuid" +import pLimit from "p-limit" +import { Mutex } from "async-mutex" +import { CacheManager } from "../cache-manager" +import { + QDRANT_CODE_BLOCK_NAMESPACE, + MAX_FILE_SIZE_BYTES, + BATCH_SEGMENT_THRESHOLD, + MAX_BATCH_RETRIES, + INITIAL_RETRY_DELAY_MS, + PARSING_CONCURRENCY, + BATCH_PROCESSING_CONCURRENCY, + MAX_PENDING_BATCHES, +} from "../constants" +import { FileIgnore } from "../../file/ignore" +import { Log } from "../../util/log" +import { sanitizeErrorMessage } from "../shared/validation-helpers" +import type { IndexingTelemetryMeta, IndexingTelemetryMode, IndexingTelemetryReporter } from "../interfaces/telemetry" + +const log = Log.create({ service: "indexing-scanner" }) + +export class DirectoryScanner implements IDirectoryScanner { + private _cancelled = false + private batchSegmentThreshold: number + private maxBatchRetries: number + + constructor( + private readonly embedder: IEmbedder, + private readonly vectorStore: IVectorStore, + private readonly codeParser: ICodeParser, + private readonly cacheManager: CacheManager, + private readonly ignoreInstance: Ignore, + batchSegmentThreshold?: number, + maxBatchRetries?: number, + private readonly onTelemetry?: IndexingTelemetryReporter, + private readonly telemetryMeta?: IndexingTelemetryMeta, + ) { + this.batchSegmentThreshold = batchSegmentThreshold ?? BATCH_SEGMENT_THRESHOLD + this.maxBatchRetries = maxBatchRetries ?? MAX_BATCH_RETRIES + } + + private emitFileCount(mode: IndexingTelemetryMode, discovered: number, candidate: number): void { + if (!this.onTelemetry || !this.telemetryMeta) { + return + } + this.onTelemetry({ + ...this.telemetryMeta, + type: "file_count", + source: "scan", + mode, + discovered, + candidate, + }) + } + + private emitRetry(mode: IndexingTelemetryMode, attempt: number, batchSize: number, err: unknown): void { + if (!this.onTelemetry || !this.telemetryMeta) { + return + } + const msg = err instanceof Error ? err.message : String(err) + this.onTelemetry({ + ...this.telemetryMeta, + type: "batch_retry", + source: "scan", + mode, + attempt, + maxRetries: this.maxBatchRetries, + batchSize, + error: sanitizeErrorMessage(msg), + }) + } + + private emitError(mode: IndexingTelemetryMode, location: string, err: unknown, retryCount?: number): void { + if (!this.onTelemetry || !this.telemetryMeta) { + return + } + const msg = err instanceof Error ? err.message : String(err) + this.onTelemetry({ + ...this.telemetryMeta, + type: "error", + source: "scan", + mode, + location, + error: sanitizeErrorMessage(msg), + retryCount, + maxRetries: this.maxBatchRetries, + }) + } + + /** + * Request cooperative cancellation of any in-flight scanning work. + * The scanDirectory and batch operations periodically check this flag + * and will exit as soon as practical. + */ + public cancel(): void { + this._cancelled = true + } + + public get isCancelled(): boolean { + return this._cancelled + } + + /** + * Updates the batch segment threshold + * @param newThreshold New batch segment threshold value + */ + public updateBatchSegmentThreshold(newThreshold: number): void { + this.batchSegmentThreshold = newThreshold + } + + /** + * Recursively scans a directory for code blocks in supported files. + * @param directoryPath The directory to scan + * @param onError Optional error handler callback + * @param onBlocksIndexed Optional callback when blocks are indexed + * @param onFileParsed Optional callback when a file is parsed + * @returns Promise with processing stats and total block count + */ + public async scanDirectory( + directory: string, + onError?: (error: Error) => void, + onFilesIndexed?: (indexedCount: number) => void, + onFileParsed?: () => void, + mode: IndexingTelemetryMode = "full", + ): Promise<{ stats: { processed: number; skipped: number }; totalBlockCount: number }> { + // reset cooperative cancel flag on new full scan + this._cancelled = false + + const directoryPath = directory + // Use the directory path directly as the workspace root + const scanWorkspace = directoryPath + log.info("starting directory scan", { workspacePath: scanWorkspace }) + + // Get all files recursively, filtering out ignored directories via glob + const allPaths = await glob("**/*", { + cwd: directoryPath, + absolute: true, + nodir: true, + dot: false, + ignore: FileIgnore.PATTERNS, + maxDepth: Infinity, + }) + + // Filter by supported extensions, ignore patterns, and excluded directories + const supportedPaths = allPaths.filter((filePath) => { + const ext = path.extname(filePath).toLowerCase() + const relativeFilePath = generateRelativeIgnorePath(filePath, scanWorkspace) + if (!relativeFilePath) { + return false + } + + // Check if file is in an ignored directory using FileIgnore + if (FileIgnore.match(relativeFilePath)) { + return false + } + + return scannerExtensions.includes(ext) && !this.ignoreInstance.ignores(relativeFilePath) + }) + log.info("discovered candidate files for indexing", { + workspacePath: scanWorkspace, + discoveredFiles: allPaths.length, + supportedFiles: supportedPaths.length, + }) + this.emitFileCount(mode, allPaths.length, supportedPaths.length) + + // Initialize tracking variables + const processedFiles = new Set() + let processedCount = 0 + let skippedCount = 0 + + // Initialize parallel processing tools + const parseLimiter = pLimit(PARSING_CONCURRENCY) // Concurrency for file parsing + const batchLimiter = pLimit(BATCH_PROCESSING_CONCURRENCY) // Concurrency for batch processing + const mutex = new Mutex() + + // Shared batch accumulators (protected by mutex) + let currentBatchBlocks: CodeBlock[] = [] + let currentBatchTexts: string[] = [] + let currentBatchFileInfos: { filePath: string; fileHash: string; isNew: boolean }[] = [] + const batched = new Map() + let failed = false + const activeBatchPromises = new Set>() + let pendingBatchCount = 0 + + // Initialize block counter + let totalBlockCount = 0 + + const queueBatch = async ( + batchBlocks: CodeBlock[], + batchTexts: string[], + batchFileInfos: { filePath: string; fileHash: string; isNew: boolean }[], + ): Promise => { + while (!this._cancelled) { + const release = await mutex.acquire() + let wait: Promise | null = null + + try { + if (pendingBatchCount < MAX_PENDING_BATCHES) { + pendingBatchCount++ + + const batchPromise = batchLimiter(() => + this.processBatch( + batchBlocks, + batchTexts, + batchFileInfos, + scanWorkspace, + mode, + onError, + onFilesIndexed, + () => { + failed = true + }, + ), + ) + activeBatchPromises.add(batchPromise) + + // Clean up completed promises to prevent memory accumulation + batchPromise.finally(() => { + activeBatchPromises.delete(batchPromise) + pendingBatchCount-- + }) + + return + } + + wait = activeBatchPromises.size > 0 ? Promise.race(activeBatchPromises) : Promise.resolve() + } finally { + release() + } + + await wait + } + } + + // Process all files in parallel with concurrency control + const parsePromises = supportedPaths.map((filePath) => + parseLimiter(async () => { + // Early exit if cancellation requested + if (this._cancelled) { + return + } + + try { + // Check file size + const stats = await stat(filePath) + if (this._cancelled) { + return + } + + if (stats.size > MAX_FILE_SIZE_BYTES) { + skippedCount++ // Skip large files + return + } + + // Read file content using fs/promises + const content = await readFile(filePath, "utf-8") + + if (this._cancelled) { + return + } + + // Calculate current hash + const currentFileHash = createHash("sha256").update(content).digest("hex") + processedFiles.add(filePath) + + // Check against cache + const cachedFileHash = this.cacheManager.getHash(filePath) + const isNewFile = !cachedFileHash + if (cachedFileHash === currentFileHash) { + // File is unchanged + skippedCount++ + return + } + + // File is new or changed - parse it using the injected parser function + const blocks = await this.codeParser.parseFile(filePath, { content, fileHash: currentFileHash }) + + if (this._cancelled) { + return + } + + const fileBlockCount = blocks.length + onFileParsed?.() + processedCount++ + + // Process embeddings if configured + if (this.embedder && this.vectorStore && blocks.length > 0) { + // Add to batch accumulators + let addedBlocksFromFile = false + let queued = false + const info = { + filePath, + fileHash: currentFileHash, + isNew: isNewFile, + } + for (const block of blocks) { + if (this._cancelled) break + const trimmedContent = block.content.trim() + if (trimmedContent) { + const nextBatch = await (async () => { + const release = await mutex.acquire() + try { + if (this._cancelled) { + // Abort adding more items if cancelled + return null + } + + currentBatchBlocks.push(block) + currentBatchTexts.push(trimmedContent) + addedBlocksFromFile = true + + // Check if batch threshold is met + if (currentBatchBlocks.length < this.batchSegmentThreshold) { + return null + } + + // Copy current batch data and clear accumulators + const batchBlocks = [...currentBatchBlocks] + const batchTexts = [...currentBatchTexts] + // RATIONALE: Include the current file metadata before the flush snapshot + // so threshold-triggered batches still run delete updates for this file. + const batchFileInfos = queued ? [...currentBatchFileInfos] : [...currentBatchFileInfos, info] + queued = true + currentBatchBlocks = [] + currentBatchTexts = [] + currentBatchFileInfos = [] + + return { + batchBlocks, + batchTexts, + batchFileInfos, + } + } finally { + release() + } + })() + + if (!nextBatch) { + continue + } + + await queueBatch(nextBatch.batchBlocks, nextBatch.batchTexts, nextBatch.batchFileInfos) + } + } + + // Add file info once per file (outside the block loop) + if (addedBlocksFromFile) { + const release = await mutex.acquire() + try { + totalBlockCount += fileBlockCount + batched.set(filePath, currentFileHash) + if (!queued) { + currentBatchFileInfos.push(info) + queued = true + } + } finally { + release() + } + } + } else { + // Only update hash if not being processed in a batch + this.cacheManager.updateHash(filePath, currentFileHash) + } + } catch (error) { + log.error(`Error processing file ${filePath} in workspace ${scanWorkspace}`, { + error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, + location: "scanDirectory:processFile", + }) + if (onError) { + onError( + error instanceof Error + ? new Error(`${error.message} (Workspace: ${scanWorkspace}, File: ${filePath})`) + : new Error(`Unknown error processing file ${filePath} (Workspace: ${scanWorkspace})`), + ) + } + } + }), + ) + + // Wait for all parsing to complete + await Promise.all(parsePromises) + log.info("finished parsing scan candidates", { + workspacePath: scanWorkspace, + processedCount, + skippedCount, + pendingBatches: pendingBatchCount, + cancelled: this._cancelled, + }) + + // Process any remaining items in batch + const finalBatch = await (async () => { + const release = await mutex.acquire() + try { + if (this._cancelled || currentBatchBlocks.length === 0) { + return null + } + + // Copy current batch data and clear accumulators + const batchBlocks = [...currentBatchBlocks] + const batchTexts = [...currentBatchTexts] + const batchFileInfos = [...currentBatchFileInfos] + currentBatchBlocks = [] + currentBatchTexts = [] + currentBatchFileInfos = [] + + return { + batchBlocks, + batchTexts, + batchFileInfos, + } + } finally { + release() + } + })() + + if (finalBatch) { + await queueBatch(finalBatch.batchBlocks, finalBatch.batchTexts, finalBatch.batchFileInfos) + } + + // Short-circuit if cancelled before handling deletions + if (this._cancelled) { + log.info("directory scan cancelled", { + workspacePath: scanWorkspace, + processedCount, + skippedCount, + totalBlockCount, + }) + return { + stats: { + processed: processedCount, + skipped: skippedCount, + }, + totalBlockCount, + } + } else { + await Promise.all(activeBatchPromises) + } + + if (!failed) { + for (const [filePath, fileHash] of batched.entries()) { + this.cacheManager.updateHash(filePath, fileHash) + } + } + + if (failed && batched.size > 0) { + log.warn("skipping cache hash updates due failed batch", { + workspacePath: scanWorkspace, + affectedFiles: batched.size, + }) + } + + // Handle deleted files + const oldHashes = this.cacheManager.getAllHashes() + for (const cachedFilePath of Object.keys(oldHashes)) { + if (!processedFiles.has(cachedFilePath)) { + // File was deleted or is no longer supported/indexed + if (this.vectorStore) { + try { + await this.vectorStore.deletePointsByFilePath(cachedFilePath) + this.cacheManager.deleteHash(cachedFilePath) + } catch (error: any) { + const errorStatus = error?.status || error?.response?.status || error?.statusCode + const errorMessage = error instanceof Error ? error.message : String(error) + + log.error(`Failed to delete points for ${cachedFilePath} in workspace ${scanWorkspace}`, { + error: sanitizeErrorMessage(errorMessage), + stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, + location: "scanDirectory:deleteRemovedFiles", + errorStatus, + }) + + if (onError) { + // Report error to error handler + onError( + error instanceof Error + ? new Error(`${error.message} (Workspace: ${scanWorkspace}, File: ${cachedFilePath})`) + : new Error(`Unknown error deleting points for ${cachedFilePath} (Workspace: ${scanWorkspace})`), + ) + } + } + } + } + } + + log.info("directory scan complete", { + workspacePath: scanWorkspace, + processedCount, + skippedCount, + totalBlockCount, + }) + + return { + stats: { + processed: processedCount, + skipped: skippedCount, + }, + totalBlockCount, + } + } + + private async processBatch( + batchBlocks: CodeBlock[], + batchTexts: string[], + batchFileInfos: { filePath: string; fileHash: string; isNew: boolean }[], + scanWorkspace: string, + mode: IndexingTelemetryMode, + onError?: (error: Error) => void, + onFilesIndexed?: (indexedCount: number) => void, + onBatchFailed?: () => void, + ): Promise { + // Respect cooperative cancellation + if (this._cancelled || batchBlocks.length === 0) return + + if (batchBlocks.length === 0) { + log.debug("Skipping empty batch processing") + return + } + + log.debug(`Starting to process batch of ${batchBlocks.length} blocks in workspace ${scanWorkspace}`) + + let attempts = 0 + let success = false + let lastError: Error | null = null + + while (attempts < this.maxBatchRetries && !success) { + attempts++ + + if (this._cancelled) return + + log.debug(`Processing batch attempt ${attempts}/${this.maxBatchRetries} for ${batchBlocks.length} blocks`) + + try { + // --- Deletion Step --- + log.debug("Starting deletion step for modified files") + const uniqueFilePaths = [ + ...new Set( + batchFileInfos + .filter((info) => !info.isNew) // Only modified files (not new) + .map((info) => info.filePath), + ), + ] + log.debug(`Identified ${uniqueFilePaths.length} modified files to delete points for`) + + if (uniqueFilePaths.length > 0) { + try { + await this.vectorStore.deletePointsByMultipleFilePaths(uniqueFilePaths) + log.debug(`Successfully deleted points for ${uniqueFilePaths.length} files`) + } catch (deleteError: any) { + const errorStatus = deleteError?.status || deleteError?.response?.status || deleteError?.statusCode + const errorMessage = deleteError instanceof Error ? deleteError.message : String(deleteError) + + log.error( + `Failed to delete points for ${uniqueFilePaths.length} files before upsert in workspace ${scanWorkspace}`, + { + error: sanitizeErrorMessage(errorMessage), + stack: deleteError instanceof Error ? sanitizeErrorMessage(deleteError.stack || "") : undefined, + location: "processBatch:deletePointsByMultipleFilePaths", + fileCount: uniqueFilePaths.length, + errorStatus, + }, + ) + + // Re-throw with workspace context + throw new Error( + `Failed to delete points for ${uniqueFilePaths.length} files. Workspace: ${scanWorkspace}. ${errorMessage}`, + { cause: deleteError }, + ) + } + } + // --- End Deletion Step --- + + // Create embeddings for batch + if (this._cancelled) return + + log.debug(`Creating embeddings for ${batchTexts.length} texts`) + + const { embeddings } = await this.embedder.createEmbeddings(batchTexts) + log.debug(`Successfully created ${embeddings.length} embeddings`) + + // Prepare points for Qdrant + log.debug("Preparing points for Qdrant upsert") + const points = batchBlocks.map((block, index) => { + const vector = embeddings[index] + if (!vector) { + throw new Error(`Missing embedding for block at index ${index}`) + } + + const normalizedAbsolutePath = generateNormalizedAbsolutePath(block.file_path, scanWorkspace) + + // Use segmentHash for unique ID generation to handle multiple segments from same line + const pointId = uuidv5(block.segmentHash, QDRANT_CODE_BLOCK_NAMESPACE) + + return { + id: pointId, + vector, + payload: { + filePath: generateRelativeFilePath(normalizedAbsolutePath, scanWorkspace), + codeChunk: block.content, + startLine: block.start_line, + endLine: block.end_line, + segmentHash: block.segmentHash, + }, + } + }) + log.debug(`Prepared ${points.length} points for Qdrant`) + + // Upsert points to Qdrant + if (this._cancelled) return + + log.debug("Starting Qdrant upsert") + + await this.vectorStore.upsertPoints(points) + log.debug("Completed Qdrant upsert") + onFilesIndexed?.(batchFileInfos.length) + + success = true + log.debug(`Successfully processed batch of ${batchBlocks.length} blocks after ${attempts} attempt(s)`) + } catch (error) { + lastError = error as Error + log.error(`Error processing batch (attempt ${attempts}) in workspace ${scanWorkspace}`, { + error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), + stack: error instanceof Error ? sanitizeErrorMessage(error.stack || "") : undefined, + location: "processBatch:retry", + attemptNumber: attempts, + batchSize: batchBlocks.length, + }) + + if (attempts < this.maxBatchRetries) { + this.emitRetry(mode, attempts, batchBlocks.length, error) + const delay = INITIAL_RETRY_DELAY_MS * Math.pow(2, attempts - 1) + log.debug(`Retrying batch in ${delay}ms`) + await new Promise((resolve) => setTimeout(resolve, delay)) + } + } + } + + if (!success && lastError) { + log.error(`Failed to process batch after ${this.maxBatchRetries} attempts`) + this.emitError(mode, "scanner:processBatch", lastError, this.maxBatchRetries) + onBatchFailed?.() + if (onError) { + // Preserve the original error message from embedders which now have detailed messages + const errorMessage = lastError.message || "Unknown error" + + onError(new Error(`Failed to process batch after ${this.maxBatchRetries} retries: ${errorMessage}`)) + } + } + } +} diff --git a/packages/kilo-indexing/src/indexing/runtime.ts b/packages/kilo-indexing/src/indexing/runtime.ts new file mode 100644 index 00000000000..df81af4ed2f --- /dev/null +++ b/packages/kilo-indexing/src/indexing/runtime.ts @@ -0,0 +1,33 @@ +/** + * Runtime adapter interfaces for the indexing package. + * + * RATIONALE: Decouple the indexing engine from any host environment (VS Code, CLI, etc.) + * by expressing all external capabilities as injectable contracts. + */ + +/** + * Minimal typed event emitter that replaces vscode.EventEmitter. + * Consumers subscribe via `on()` and receive a dispose function. + */ +export class Emitter { + private listeners = new Set<(value: T) => void>() + + on(listener: (value: T) => void): Disposable { + this.listeners.add(listener) + return { dispose: () => this.listeners.delete(listener) } + } + + fire(value: T): void { + for (const listener of this.listeners) { + listener(value) + } + } + + dispose(): void { + this.listeners.clear() + } +} + +export interface Disposable { + dispose(): void +} diff --git a/packages/kilo-indexing/src/indexing/search-service.ts b/packages/kilo-indexing/src/indexing/search-service.ts new file mode 100644 index 00000000000..954ba7e0ea7 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/search-service.ts @@ -0,0 +1,46 @@ +import path from "path" +import type { VectorStoreSearchResult } from "./interfaces" +import type { IEmbedder } from "./interfaces/embedder" +import type { IVectorStore } from "./interfaces/vector-store" +import type { CodeIndexConfigManager } from "./config-manager" +import type { CodeIndexStateManager } from "./state-manager" +import { Log } from "../util/log" + +const log = Log.create({ service: "indexing-search" }) + +export class CodeIndexSearchService { + constructor( + private readonly configManager: CodeIndexConfigManager, + private readonly stateManager: CodeIndexStateManager, + private readonly embedder: IEmbedder, + private readonly vectorStore: IVectorStore, + ) {} + + public async searchIndex(query: string, directoryPrefix?: string): Promise { + if (!this.configManager.isFeatureEnabled || !this.configManager.isFeatureConfigured) { + throw new Error("Code index feature is disabled or not configured.") + } + + const minScore = this.configManager.currentSearchMinScore + const maxResults = this.configManager.currentSearchMaxResults + + const currentState = this.stateManager.getCurrentStatus().systemStatus + if (currentState !== "Indexed" && currentState !== "Indexing") { + throw new Error(`Code index is not ready for search. Current state: ${currentState}`) + } + + try { + const embeddingResponse = await this.embedder.createEmbeddings([query]) + const vector = embeddingResponse?.embeddings[0] + if (!vector) { + throw new Error("Failed to generate embedding for query.") + } + + const normalizedPrefix = directoryPrefix ? path.normalize(directoryPrefix) : undefined + return await this.vectorStore.search(vector, normalizedPrefix, minScore, maxResults) + } catch (err) { + log.error("search failed", { err }) + throw err + } + } +} diff --git a/packages/kilo-indexing/src/indexing/service-factory.ts b/packages/kilo-indexing/src/indexing/service-factory.ts new file mode 100644 index 00000000000..d0c523ec7e6 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/service-factory.ts @@ -0,0 +1,273 @@ +import type { Ignore } from "ignore" +import path from "path" + +import { getDefaultModelId } from "./model-registry" +import { resolveEmbeddingProfile } from "./embedding-profile" + +import { OpenAiEmbedder } from "./embedders/openai" +import { CodeIndexOllamaEmbedder } from "./embedders/ollama" +import { OpenAICompatibleEmbedder } from "./embedders/openai-compatible" +import { GeminiEmbedder } from "./embedders/gemini" +import { MistralEmbedder } from "./embedders/mistral" +import { VercelAiGatewayEmbedder } from "./embedders/vercel-ai-gateway" +import { BedrockEmbedder } from "./embedders/bedrock" +import { OpenRouterEmbedder } from "./embedders/openrouter" +import { VoyageEmbedder } from "./embedders/voyage" +import { QdrantVectorStore } from "./vector-store/qdrant-client" +import { LanceDBVectorStore } from "./vector-store/lancedb-vector-store" +import { codeParser, DirectoryScanner, FileWatcher } from "./processors" +import type { ICodeParser, IEmbedder, IFileWatcher, IVectorStore } from "./interfaces" +import type { CodeIndexConfigManager } from "./config-manager" +import type { CacheManager } from "./cache-manager" +import type { IndexingTelemetryMeta, IndexingTelemetryReporter } from "./interfaces/telemetry" +import { + BATCH_SEGMENT_THRESHOLD, + OLLAMA_EMBEDDER_REQUEST_TIMEOUT_MS, + REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, +} from "./constants" +import { Log } from "../util/log" + +const log = Log.create({ service: "indexing-factory" }) + +function timeout(provider: string): number { + if (provider === "ollama") return OLLAMA_EMBEDDER_REQUEST_TIMEOUT_MS + return REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS +} + +/** + * Factory class responsible for creating and configuring code indexing service dependencies. + * + * RATIONALE: Removed vscode.ExtensionContext, Package, RooIgnoreController, and + * LanceDBManager inputs. All batch sizing, retry counts, vector-store selection, + * and model selection now come from the injected CodeIndexConfigManager. + */ +export class CodeIndexServiceFactory { + constructor( + private readonly configManager: CodeIndexConfigManager, + private readonly workspacePath: string, + private readonly cacheManager: CacheManager, + private readonly cacheDirectory: string, + private readonly onTelemetry?: IndexingTelemetryReporter, + ) {} + + private getTelemetryMeta(): IndexingTelemetryMeta { + const cfg = this.configManager.getConfig() + return { + provider: cfg.embedderProvider, + vectorStore: cfg.vectorStoreProvider ?? "qdrant", + modelId: cfg.modelId, + } + } + + public createEmbedder(): IEmbedder { + const config = this.configManager.getConfig() + const provider = config.embedderProvider + + if (provider === "openai") { + if (!config.openAiOptions?.apiKey) throw new Error("OpenAI API key is required for embedding.") + return new OpenAiEmbedder(config.openAiOptions.apiKey, config.modelId) + } + if (provider === "ollama") { + if (!config.ollamaOptions?.baseUrl) throw new Error("Ollama base URL is required for embedding.") + return new CodeIndexOllamaEmbedder(config.ollamaOptions.baseUrl, config.modelId, config.modelDimension) + } + if (provider === "openai-compatible") { + if (!config.openAiCompatibleOptions?.baseUrl || !config.openAiCompatibleOptions?.apiKey) + throw new Error("OpenAI-compatible base URL and API key are required.") + return new OpenAICompatibleEmbedder( + config.openAiCompatibleOptions.baseUrl, + config.openAiCompatibleOptions.apiKey, + config.modelId, + ) + } + if (provider === "gemini") { + if (!config.geminiOptions?.apiKey) throw new Error("Gemini API key is required for embedding.") + return new GeminiEmbedder(config.geminiOptions.apiKey, config.modelId) + } + if (provider === "mistral") { + if (!config.mistralOptions?.apiKey) throw new Error("Mistral API key is required for embedding.") + return new MistralEmbedder(config.mistralOptions.apiKey, config.modelId) + } + if (provider === "vercel-ai-gateway") { + if (!config.vercelAiGatewayOptions?.apiKey) + throw new Error("Vercel AI Gateway API key is required for embedding.") + return new VercelAiGatewayEmbedder(config.vercelAiGatewayOptions.apiKey, config.modelId) + } + if (provider === "bedrock") { + if (!config.bedrockOptions?.region) throw new Error("Bedrock region is required for embedding.") + return new BedrockEmbedder(config.bedrockOptions.region, config.bedrockOptions.profile, config.modelId) + } + if (provider === "openrouter") { + if (!config.openRouterOptions?.apiKey) throw new Error("OpenRouter API key is required for embedding.") + return new OpenRouterEmbedder( + config.openRouterOptions.apiKey, + config.modelId, + undefined, + config.openRouterOptions.specificProvider, + config.modelDimension, + ) + } + if (provider === "voyage") { + if (!config.voyageOptions?.apiKey) throw new Error("Voyage API key is required for embedding.") + return new VoyageEmbedder(config.voyageOptions.apiKey, config.modelId) + } + + throw new Error(`Unsupported embedder provider: ${provider}`) + } + + public async validateEmbedder(embedder: IEmbedder): Promise<{ valid: boolean; error?: string }> { + const ms = timeout(embedder.embedderInfo.name) + let timer: ReturnType | undefined + const wait = embedder.validateConfiguration() + const fail = new Promise<{ valid: boolean; error?: string }>((resolve) => { + timer = setTimeout( + () => + resolve({ + valid: false, + error: + embedder.embedderInfo.name === "ollama" + ? "Connection to embedding service failed (timeout)" + : "Connection failed. Please check the endpoint URL and network connectivity.", + }), + ms, + ) + }) + + try { + log.info("validating embedder", { provider: embedder.embedderInfo.name }) + const result = await Promise.race([wait, fail]) + if (result.valid) { + log.info("embedder validation succeeded", { provider: embedder.embedderInfo.name }) + } + if (!result.valid) { + log.warn("embedder validation failed", { + provider: embedder.embedderInfo.name, + error: result.error, + }) + } + return result + } catch (err) { + log.error("embedder validation failed", { err }) + return { + valid: false, + error: err instanceof Error ? err.message : "Configuration validation error", + } + } finally { + if (timer) clearTimeout(timer) + } + } + + public createVectorStore(): IVectorStore { + const config = this.configManager.getConfig() + const profile = resolveEmbeddingProfile(config.embedderProvider, config.modelId, config.modelDimension) + + if (!profile || profile.dimension <= 0) { + throw new Error( + `Cannot determine vector dimension for model "${config.modelId ?? getDefaultModelId(config.embedderProvider)}" with provider "${config.embedderProvider}". ` + + (config.embedderProvider === "openai-compatible" + ? "Please set the model dimension explicitly." + : "Check your model configuration."), + ) + } + + if (config.vectorStoreProvider === "lancedb") { + const dbDir = config.lancedbVectorStoreDirectoryPlaceholder ?? path.join(this.cacheDirectory, "lancedb") + log.info("creating vector store", { + provider: config.embedderProvider, + vectorStore: "lancedb", + model: profile.modelId, + vectorSize: profile.dimension, + dbDir, + }) + return new LanceDBVectorStore(this.workspacePath, profile.dimension, dbDir, profile) + } + + if (!config.qdrantUrl) throw new Error("Qdrant URL is required.") + log.info("creating vector store", { + provider: config.embedderProvider, + vectorStore: "qdrant", + model: profile.modelId, + vectorSize: profile.dimension, + }) + return new QdrantVectorStore(this.workspacePath, config.qdrantUrl, profile.dimension, config.qdrantApiKey, profile) + } + + public createDirectoryScanner( + embedder: IEmbedder, + vectorStore: IVectorStore, + parser: ICodeParser, + ignoreInstance: Ignore, + ): DirectoryScanner { + const config = this.configManager.getConfig() + const meta = this.getTelemetryMeta() + return new DirectoryScanner( + embedder, + vectorStore, + parser, + this.cacheManager, + ignoreInstance, + config.embeddingBatchSize, + config.scannerMaxBatchRetries, + this.onTelemetry, + meta, + ) + } + + public createFileWatcher( + embedder: IEmbedder, + vectorStore: IVectorStore, + cacheManager: CacheManager, + ignoreInstance: Ignore, + ): IFileWatcher { + const config = this.configManager.getConfig() + const meta = this.getTelemetryMeta() + return new FileWatcher( + this.workspacePath, + cacheManager, + embedder, + vectorStore, + ignoreInstance, + config.embeddingBatchSize, + config.scannerMaxBatchRetries, + this.onTelemetry, + meta, + ) + } + + public createServices( + cacheManager: CacheManager, + ignoreInstance: Ignore, + ): { + embedder: IEmbedder + vectorStore: IVectorStore + parser: ICodeParser + scanner: DirectoryScanner + fileWatcher: IFileWatcher + } { + if (!this.configManager.isFeatureConfigured) { + throw new Error("Code indexing is not configured. Save your settings to start indexing.") + } + + const config = this.configManager.getConfig() + log.info("creating indexing services", { + workspacePath: this.workspacePath, + provider: config.embedderProvider, + vectorStore: config.vectorStoreProvider, + model: config.modelId ?? getDefaultModelId(config.embedderProvider), + configured: config.isConfigured, + }) + + const embedder = this.createEmbedder() + const vectorStore = this.createVectorStore() + const parser = codeParser + const scanner = this.createDirectoryScanner(embedder, vectorStore, parser, ignoreInstance) + const fileWatcher = this.createFileWatcher(embedder, vectorStore, cacheManager, ignoreInstance) + + log.info("indexing services created", { + workspacePath: this.workspacePath, + provider: embedder.embedderInfo.name, + }) + + return { embedder, vectorStore, parser, scanner, fileWatcher } + } +} diff --git a/packages/kilo-indexing/src/indexing/shared/get-relative-path.ts b/packages/kilo-indexing/src/indexing/shared/get-relative-path.ts new file mode 100644 index 00000000000..1156d24f07f --- /dev/null +++ b/packages/kilo-indexing/src/indexing/shared/get-relative-path.ts @@ -0,0 +1,46 @@ +import path from "path" + +/** + * Generates a normalized absolute path from a given file path and workspace root. + * Handles path resolution and normalization to ensure consistent absolute paths. + * + * @param filePath - The file path to normalize (can be relative or absolute) + * @param workspaceRoot - The root directory of the workspace (required) + * @returns The normalized absolute path + */ +export function generateNormalizedAbsolutePath(filePath: string, workspaceRoot: string): string { + // Resolve the path to make it absolute if it's relative + const resolvedPath = path.resolve(workspaceRoot, filePath) + // Normalize to handle any . or .. segments and duplicate slashes + return path.normalize(resolvedPath) +} + +/** + * Generates a relative file path from a normalized absolute path and workspace root. + * Ensures consistent relative path generation across different platforms. + * + * @param normalizedAbsolutePath - The normalized absolute path to convert + * @param workspaceRoot - The root directory of the workspace (required) + * @returns The relative path from workspaceRoot to the file + */ +export function generateRelativeFilePath(normalizedAbsolutePath: string, workspaceRoot: string): string { + // Generate the relative path + const relativePath = path.relative(workspaceRoot, normalizedAbsolutePath) + // Normalize to ensure consistent path separators + return path.normalize(relativePath) +} + +/** + * Generates a relative path that is safe to pass to ignore().ignores(). + * + * ignore() only accepts paths that are strictly relative to workspaceRoot. + * It rejects ".", empty, absolute, and parent-prefixed paths. + */ +export function generateRelativeIgnorePath(normalizedAbsolutePath: string, workspaceRoot: string): string | undefined { + const relativePath = generateRelativeFilePath(normalizedAbsolutePath, workspaceRoot) + if (!relativePath || relativePath === ".") return + if (path.isAbsolute(relativePath)) return + if (relativePath === "..") return + if (relativePath.startsWith(`..${path.sep}`)) return + return relativePath +} diff --git a/packages/kilo-indexing/src/indexing/shared/load-ignore.ts b/packages/kilo-indexing/src/indexing/shared/load-ignore.ts new file mode 100644 index 00000000000..77d123645a2 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/shared/load-ignore.ts @@ -0,0 +1,37 @@ +import fs from "fs/promises" +import ignore, { type Ignore } from "ignore" +import path from "path" + +const files = [".gitignore", ".kilocodeignore"] as const + +function notFound(err: unknown): boolean { + if (!err || typeof err !== "object") { + return false + } + return "code" in err && err.code === "ENOENT" +} + +async function read(root: string, name: string): Promise { + return fs.readFile(path.join(root, name), "utf8").catch((err) => { + if (notFound(err)) { + return undefined + } + throw err + }) +} + +export async function loadIgnore(root: string): Promise { + const ig = ignore() + + for (const name of files) { + const txt = await read(root, name) + if (!txt?.trim()) { + continue + } + + ig.add(txt) + ig.add(name) + } + + return ig +} diff --git a/packages/kilo-indexing/src/indexing/shared/supported-extensions.ts b/packages/kilo-indexing/src/indexing/shared/supported-extensions.ts new file mode 100644 index 00000000000..1a85092e7a1 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/shared/supported-extensions.ts @@ -0,0 +1,56 @@ +import { extensions as allExtensions } from "../../tree-sitter" + +// Include all extensions including markdown for the scanner +export const scannerExtensions = allExtensions + +/** + * Extensions that should always use fallback chunking instead of tree-sitter parsing. + * + * These are either formats with no query/parser wiring yet, text-like build/doc files, + * or languages where AST chunking is intentionally disabled for indexing stability. + * + * NOTE: Any extension listed here must also appear in `src/tree-sitter/index.ts`. + * Keep this list explicit so broad-support formats do not rely on parser-load failures + * to become indexable. + */ +export const fallbackExtensions = [ + // Shell and build scripts + ".bash", + ".bazel", + ".bzl", + ".build", + ".gradle", + ".ninja", + ".sh", + ".zsh", + + // Languages with no query or parser wiring yet + ".dart", + ".elm", + ".m", + ".mm", + ".ql", + ".r", + ".res", + ".resi", + ".sql", + ".vb", + ".yaml", + ".yml", + + // Documentation and query formats + ".rst", + + // Known unstable or intentionally disabled AST chunking + ".scala", + ".swift", +] + +/** + * Check if a file extension should use fallback chunking + * @param extension File extension (including the dot) + * @returns true if the extension should use fallback chunking + */ +export function shouldUseFallbackChunking(extension: string): boolean { + return fallbackExtensions.includes(extension.toLowerCase()) +} diff --git a/packages/kilo-indexing/src/indexing/shared/validation-helpers.ts b/packages/kilo-indexing/src/indexing/shared/validation-helpers.ts new file mode 100644 index 00000000000..1302fd0fd28 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/shared/validation-helpers.ts @@ -0,0 +1,215 @@ +import { Log } from "../../util/log" + +const log = Log.create({ service: "indexing-validation" }) + +/** + * Sanitizes error messages by removing sensitive information like file paths and URLs + * @param errorMessage The error message to sanitize + * @returns The sanitized error message + */ +export function sanitizeErrorMessage(errorMessage: string): string { + if (!errorMessage || typeof errorMessage !== "string") { + return String(errorMessage) + } + + let sanitized = errorMessage + + // Replace URLs first (http, https, ftp, file protocols) + // This needs to be done before file paths to avoid partial replacements + sanitized = sanitized.replace( + /(?:https?|ftp|file):\/\/(?:localhost|[\w\-\.]+)(?::\d+)?(?:\/[\w\-\.\/\?\&\=\#]*)?/gi, + "[REDACTED_URL]", + ) + + // Replace email addresses + sanitized = sanitized.replace(/[\w\-\.]+@[\w\-\.]+\.\w+/g, "[REDACTED_EMAIL]") + + // Replace IP addresses (IPv4) + sanitized = sanitized.replace(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g, "[REDACTED_IP]") + + // Replace file paths in quotes (handles paths with spaces) + sanitized = sanitized.replace(/"[^"]*(?:\/|\\)[^"]*"/g, '"[REDACTED_PATH]"') + + // Replace file paths (Unix and Windows style) + // Matches paths like /Users/username/path, C:\Users\path, ./relative/path, ../relative/path + sanitized = sanitized.replace( + /(?:\/[\w\-\.]+)+(?:\/[\w\-\.\s]*)*|(?:[A-Za-z]:\\[\w\-\.\\]+)|(?:\.{1,2}\/[\w\-\.\/]+)/g, + "[REDACTED_PATH]", + ) + + // Replace port numbers that appear after colons (e.g., :11434, :8080) + // Do this after URLs to avoid double replacement + sanitized = sanitized.replace(/(?= 400 && status < 600) { + return "Configuration error. Please verify your embedder settings." + } + return undefined + } +} + +/** + * Extracts status code from various error formats + */ +export function extractStatusCode(error: any): number | undefined { + // Direct status property + if (error?.status) return error.status + + // Response status property + if (error?.response?.status) return error.response.status + + // Extract from error message (e.g., "HTTP 404: Not Found") + if (error?.message) { + const match = error.message.match(/HTTP (\d+):/) + if (match) { + return parseInt(match[1], 10) + } + } + + return undefined +} + +/** + * Extracts error message from various error formats + */ +export function extractErrorMessage(error: any): string { + if (error?.message) { + return error.message + } + + if (typeof error === "string") { + return error + } + + if (error && typeof error === "object" && "toString" in error) { + try { + return String(error) + } catch { + return "Unknown error" + } + } + + return "Unknown error" +} + +/** + * Standard validation error handler for embedder configuration validation + * Returns a consistent error response based on the error type + */ +export function handleValidationError( + error: any, + embedderType: string, + customHandlers?: { + beforeStandardHandling?: (error: any) => { valid: boolean; error: string } | undefined + }, +): { valid: boolean; error: string } { + // Allow custom handling first + if (customHandlers?.beforeStandardHandling) { + const customResult = customHandlers.beforeStandardHandling(error) + if (customResult) return customResult + } + + const statusCode = extractStatusCode(error) + const errorMessage = extractErrorMessage(error) + + // Check for status-based errors first + const statusError = getErrorMessageForStatus(statusCode, embedderType) + if (statusError) { + return { valid: false, error: statusError } + } + + // Check for connection errors + if (errorMessage) { + if ( + errorMessage.includes("ENOTFOUND") || + errorMessage.includes("ECONNREFUSED") || + errorMessage.includes("ETIMEDOUT") || + errorMessage === "AbortError" || + errorMessage.includes("HTTP 0:") || + errorMessage === "No response" + ) { + return { valid: false, error: "Connection failed. Please check the endpoint URL and network connectivity." } + } + + if (errorMessage.includes("Failed to parse response JSON")) { + return { valid: false, error: "Received an invalid response from the embedding service." } + } + } + + // For generic errors, preserve the original error message if it's not a standard one + if (errorMessage && errorMessage !== "Unknown error") { + return { valid: false, error: errorMessage } + } + + // Fallback to generic error + return { valid: false, error: "Configuration error. Please verify your embedder settings." } +} + +/** + * Wraps an async validation function with standard error handling + */ +export async function withValidationErrorHandling( + validationFn: () => Promise, + embedderType: string, + customHandlers?: Parameters[2], +): Promise<{ valid: boolean; error?: string }> { + try { + return await validationFn() + } catch (error) { + return handleValidationError(error, embedderType, customHandlers) + } +} + +/** + * Formats an embedding error message based on the error type and context + */ +export function formatEmbeddingError(error: any, maxRetries: number): Error { + const errorMessage = extractErrorMessage(error) + const statusCode = extractStatusCode(error) + + if (statusCode === 401) { + return new Error("Authentication failed. Please check your API key.") + } + if (statusCode) { + return new Error(`Embedding request failed after ${maxRetries} attempts with status ${statusCode}: ${errorMessage}`) + } + return new Error(`Embedding request failed after ${maxRetries} attempts: ${errorMessage}`) +} diff --git a/packages/kilo-indexing/src/indexing/state-manager.ts b/packages/kilo-indexing/src/indexing/state-manager.ts new file mode 100644 index 00000000000..7235970108d --- /dev/null +++ b/packages/kilo-indexing/src/indexing/state-manager.ts @@ -0,0 +1,104 @@ +import { Emitter } from "./runtime" + +export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" + +export class CodeIndexStateManager { + private _systemStatus: IndexingState = "Standby" + private _statusMessage = "" + private _processedFiles = 0 + private _totalFiles = 0 + private _percent = 0 + private _gitBranch?: string + private _manifest?: { + totalFiles: number + totalChunks: number + lastUpdated: string + } + private _progressEmitter = new Emitter>() + + public readonly onProgressUpdate = this._progressEmitter + + public get state(): IndexingState { + return this._systemStatus + } + + public getCurrentStatus() { + return { + systemStatus: this._systemStatus, + message: this._statusMessage, + processedItems: this._processedFiles, + totalItems: this._totalFiles, + currentItemUnit: "files", + percent: this._percent, + gitBranch: this._gitBranch, + manifest: this._manifest, + } + } + + public setSystemState( + newState: IndexingState, + message?: string, + manifest?: { + totalFiles: number + totalChunks: number + lastUpdated: string + }, + gitBranch?: string, + ): void { + const stateChanged = newState !== this._systemStatus || (message !== undefined && message !== this._statusMessage) + + if (!stateChanged) return + + this._systemStatus = newState + if (message !== undefined) this._statusMessage = message + if (manifest !== undefined) this._manifest = manifest + if (gitBranch !== undefined) this._gitBranch = gitBranch + + if (newState !== "Indexing") { + this._percent = newState === "Indexed" ? 100 : 0 + if (newState === "Standby" && message === undefined) this._statusMessage = "Ready." + if (newState === "Indexed" && message === undefined) this._statusMessage = "Index up-to-date." + if (newState === "Error" && message === undefined) this._statusMessage = "An error occurred." + } + + if (newState !== "Indexed") { + this._manifest = undefined + } + + this._progressEmitter.fire(this.getCurrentStatus()) + } + + public reportFileProgress(processedFiles: number, totalFiles: number, currentFileBasename?: string): void { + const percent = totalFiles > 0 ? Math.min(100, Math.round((processedFiles / totalFiles) * 100)) : 0 + const progressChanged = + processedFiles !== this._processedFiles || totalFiles !== this._totalFiles || percent !== this._percent + + if (!progressChanged && this._systemStatus === "Indexing") return + + this._processedFiles = processedFiles + this._totalFiles = totalFiles + this._percent = percent + + const message = + totalFiles > 0 + ? `Indexed ${processedFiles} / ${totalFiles} files (${percent}%).${currentFileBasename ? ` Current: ${currentFileBasename}` : ""}` + : "Indexing files..." + const oldStatus = this._systemStatus + const oldMessage = this._statusMessage + + this._systemStatus = "Indexing" + this._statusMessage = message + + if (oldStatus !== this._systemStatus || oldMessage !== this._statusMessage || progressChanged) { + this._progressEmitter.fire(this.getCurrentStatus()) + } + } + + public reportFileQueueProgress(processedFiles: number, totalFiles: number, currentFileBasename?: string): void { + this.reportFileProgress(processedFiles, totalFiles, currentFileBasename) + } + + public dispose(): void { + this._progressEmitter.dispose() + } +} diff --git a/packages/kilo-indexing/src/indexing/vector-store/lancedb-loader.ts b/packages/kilo-indexing/src/indexing/vector-store/lancedb-loader.ts new file mode 100644 index 00000000000..780070c320e --- /dev/null +++ b/packages/kilo-indexing/src/indexing/vector-store/lancedb-loader.ts @@ -0,0 +1,9 @@ +const env = "KILO_LANCEDB_PATH" + +export function resolveLanceDBSpecifier() { + return process.env[env] || "@lancedb/lancedb" +} + +export async function loadLanceDB() { + return import(resolveLanceDBSpecifier()) +} diff --git a/packages/kilo-indexing/src/indexing/vector-store/lancedb-vector-store.ts b/packages/kilo-indexing/src/indexing/vector-store/lancedb-vector-store.ts new file mode 100644 index 00000000000..39bc14760dc --- /dev/null +++ b/packages/kilo-indexing/src/indexing/vector-store/lancedb-vector-store.ts @@ -0,0 +1,636 @@ +import { createHash } from "crypto" +import * as path from "path" +import type { Connection, Table, VectorQuery } from "@lancedb/lancedb" +import type { IVectorStore } from "../interfaces/vector-store" +import type { Payload, VectorStoreSearchResult } from "../interfaces" +import { DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_SEARCH_MIN_SCORE } from "../constants" +import fs from "fs" +import { Log } from "../../util/log" +import type { EmbeddingProfile } from "../embedding-profile" +import { loadLanceDB } from "./lancedb-loader" + +const log = Log.create({ service: "lancedb-store" }) + +const KEY = { + size: "vector_size", + complete: "indexing_complete", + provider: "embedding_provider", + model: "embedding_model_id", + dimension: "embedding_dimension", +} + +/** + * Local implementation of the vector store using LanceDB + */ +export class LanceDBVectorStore implements IVectorStore { + private readonly vectorSize: number + private readonly dbPath: string + private readonly workspacePath: string + private readonly profile: EmbeddingProfile + private db: Connection | null = null + private table: Table | null = null + private readonly vectorTableName = "vector" + private readonly metadataTableName = "metadata" + private lancedbModule: any = null + + constructor(workspacePath: string, vectorSize: number, dbDirectory: string, profile?: EmbeddingProfile) { + this.vectorSize = vectorSize + this.workspacePath = workspacePath + this.profile = + profile ?? + ({ + provider: "openai", + modelId: "", + dimension: vectorSize, + } as EmbeddingProfile) + const basename = path.basename(workspacePath) + // Generate database directory name from workspace path + const hash = createHash("sha256").update(workspacePath).digest("hex") + const dbName = `${basename}-${hash.substring(0, 16)}` + // Set up database path + this.dbPath = path.join(dbDirectory, dbName) + } + + /** + * Dynamically loads the LanceDB module. + * @returns The LanceDB module. + */ + private async loadLanceDBModule(): Promise { + if (this.lancedbModule) { + return this.lancedbModule + } + + try { + this.lancedbModule = await loadLanceDB() + return this.lancedbModule + } catch (error: unknown) { + log.error("Failed to load LanceDB module", { error }) + throw new Error(`Failed to load LanceDB module: ${(error as Error).message}`) + } + } + + /** + * Gets or connects to the LanceDB database. + * @returns The LanceDB connection. + */ + private async getDb(): Promise { + if (this.db) { + return this.db + } + + const lancedb = await this.loadLanceDBModule() + + // Create parent directory if needed + if (!fs.existsSync(this.dbPath)) { + fs.mkdirSync(this.dbPath, { recursive: true }) + } + + this.db = await lancedb.connect(this.dbPath) + return this.db as Connection + } + + /** + * Gets or opens the vector table. + * @returns The LanceDB table. + */ + private async getTable(): Promise { + if (this.table) { + return this.table + } + + const db = await this.getDb() + + try { + // Try to open existing table + const table = await db.openTable(this.vectorTableName) + this.table = table + return table + } catch (error) { + // Table doesn't exist, will be created in initialize() + throw new Error(`Table ${this.vectorTableName} does not exist`) + } + } + + /** + * Creates sample data for the vector table schema. + * @returns An array containing sample data. + */ + private _createSampleData() { + return [ + { + id: "sample", + vector: new Array(this.vectorSize).fill(0), + filePath: "sample", + codeChunk: "sample", + startLine: 0, + endLine: 0, + }, + ] + } + + /** + * Creates metadata for the vector size. + * @returns An array containing metadata. + */ + private _createMetadataData() { + return [ + { + key: KEY.size, + value: this.vectorSize, + }, + { + key: KEY.provider, + value: this.profile.provider, + }, + { + key: KEY.model, + value: this.profile.modelId, + }, + { + key: KEY.dimension, + value: this.profile.dimension, + }, + { + key: KEY.complete, + value: false, + }, + ] + } + + /** + * Creates the vector table and deletes the sample data. + * @param db The LanceDB connection. + */ + private async _createVectorTable(db: Connection): Promise { + this.table = await db.createTable(this.vectorTableName, this._createSampleData()) + if (this.table) { + await this.table.delete("id = 'sample'") + } + } + + /** + * Creates the metadata table. + * @param db The LanceDB connection. + */ + private async _createMetadataTable(db: Connection): Promise { + await db.createTable(this.metadataTableName, this._createMetadataData()) + } + + /** + * Drops a table if it exists. + * @param db The LanceDB connection. + * @param tableName The name of the table to drop. + */ + private async _dropTableIfExists(db: Connection, tableName: string): Promise { + const tableNames = await db.tableNames() + if (tableNames.includes(tableName)) { + await db.dropTable(tableName) + } + } + + /** + * Retrieves the stored vector size from the metadata table. + * @param db The LanceDB connection. + * @returns The stored vector size, or null if not found. + */ + private async _getStoredVectorSize(db: Connection): Promise { + try { + const value = await this._getMetadataValue(db, KEY.size) + if (value === undefined) return null + const dim = this._parseNumber(value) + return dim ?? null + } catch (error) { + log.warn("Failed to read metadata table", { error }) + return null + } + } + + private isValidMetadataKey(key: string): boolean { + return Object.values(KEY).includes(key as (typeof KEY)[keyof typeof KEY]) + } + + private _parseNumber(value: unknown): number | undefined { + const dim = Number(value) + if (!Number.isFinite(dim) || dim <= 0) return undefined + return dim + } + + private async _getMetadataValue(db: Connection, key: string): Promise { + if (!this.isValidMetadataKey(key)) { + throw new Error(`Invalid metadata key: ${key}`) + } + const metadataTable = await db.openTable(this.metadataTableName) + const rows = await metadataTable.query().where(`key = '${key}'`).toArray() + return rows.length > 0 ? rows[0].value : undefined + } + + private async _getStoredEmbeddingProfile(db: Connection): Promise { + try { + const provider = await this._getMetadataValue(db, KEY.provider) + const modelId = await this._getMetadataValue(db, KEY.model) + const dimension = await this._getMetadataValue(db, KEY.dimension) + if (typeof provider !== "string" || typeof modelId !== "string") return undefined + const dim = this._parseNumber(dimension) + if (!dim) return undefined + return { + provider: provider as EmbeddingProfile["provider"], + modelId, + dimension: dim, + } + } catch (error) { + log.warn("Failed to read embedding profile metadata", { error }) + return undefined + } + } + + private _isEmbeddingProfileMatch(profile: EmbeddingProfile): boolean { + return ( + profile.provider === this.profile.provider && + profile.modelId === this.profile.modelId && + profile.dimension === this.profile.dimension + ) + } + + async initialize(): Promise { + try { + await this.closeConnect() + const db = await this.getDb() + + const tableNames = await db.tableNames() + const vectorTableExists = tableNames.includes(this.vectorTableName) + const metadataTableExists = tableNames.includes(this.metadataTableName) + + let needsRecreation = false + + if (!vectorTableExists) { + await this._createVectorTable(db) + await this._createMetadataTable(db) + log.info("LanceDB store initialized", { + workspacePath: this.workspacePath, + dbPath: this.dbPath, + created: true, + vectorSize: this.vectorSize, + }) + return true + } + + this.table = await db.openTable(this.vectorTableName) + + const storedVectorSize = metadataTableExists ? await this._getStoredVectorSize(db) : null + const pointCount = await this.table.countRows() + + if (storedVectorSize === null || storedVectorSize !== this.vectorSize) { + needsRecreation = true + } + + if (!needsRecreation && pointCount > 0) { + const storedProfile = metadataTableExists ? await this._getStoredEmbeddingProfile(db) : undefined + if (!storedProfile || !this._isEmbeddingProfileMatch(storedProfile)) { + needsRecreation = true + } + } + + if (needsRecreation) { + await this._dropTableIfExists(db, this.vectorTableName) + await this._dropTableIfExists(db, this.metadataTableName) + await this._createVectorTable(db) + await this._createMetadataTable(db) + this.optimizeTable() + + log.info("LanceDB store reinitialized for embedding profile change", { + workspacePath: this.workspacePath, + dbPath: this.dbPath, + created: true, + vectorSize: this.vectorSize, + }) + + return true + } + this.optimizeTable() + log.info("LanceDB store initialized", { + workspacePath: this.workspacePath, + dbPath: this.dbPath, + created: false, + vectorSize: this.vectorSize, + }) + return false + } catch (error) { + log.error("Failed to initialize LanceDB store", { error }) + throw new Error(`Failed to initialize LanceDB store: ${(error as Error).message}`, { cause: error }) + } + } + + async upsertPoints( + points: Array<{ + id: string + vector: number[] + payload: Record + }>, + ): Promise { + if (points.length === 0) { + return + } + + const table = await this.getTable() + const valids = points.filter((point) => this.isPayloadValid(point.payload)) + + if (valids.length === 0) { + return + } + + try { + // Convert points to LanceDB format + const lanceData = valids.map((point) => ({ + id: point.id, + vector: point.vector, + filePath: point.payload.filePath, + codeChunk: point.payload.codeChunk, + startLine: point.payload.startLine, + endLine: point.payload.endLine, + })) + + // Delete existing points with same IDs first + const existingIds = lanceData.map((d) => d.id) + if (existingIds.length > 0) { + const bad = existingIds.find((id) => !this.isValidId(id)) + if (bad) { + throw new Error(`Invalid point id format: ${bad}`) + } + const escapedIds = existingIds.map((id) => `'${this.escapeSqlString(id)}'`).join(", ") + const idFilter = `id IN (${escapedIds})` + await table.delete(idFilter) + } + + // Insert new data + await table.add(lanceData) + } catch (error) { + log.error("Failed to upsert points", { error }) + throw error + } + } + + // Temporary till lancedb implements parameter support + // https://github.com/lance-format/lance/issues/2160 + private escapeSqlString(value: string): string { + return value.replace(/'/g, "''") + } + + private isValidId(id: string): boolean { + // ASSUMPTION: Point IDs are uuidv5 values produced by scanner and file watcher. + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) + } + + private escapeSqlLikePattern(pattern: string): string { + let escaped = this.escapeSqlString(pattern) + escaped = escaped.replace(/\\/g, "\\\\") + escaped = escaped.replace(/%/g, "\\%").replace(/_/g, "\\_") + + return escaped + } + + private isPayloadValid(payload: Record | null | undefined): payload is Payload { + if (!payload) { + return false + } + const validKeys = ["filePath", "codeChunk", "startLine", "endLine"] + const hasValidKeys = validKeys.every((key) => key in payload) + return hasValidKeys + } + + async search( + queryVector: number[], + directoryPrefix?: string, + minScore?: number, + maxResults?: number, + ): Promise { + try { + const table = await this.getTable() + const actualMinScore = minScore ?? DEFAULT_SEARCH_MIN_SCORE + const actualMaxResults = maxResults ?? DEFAULT_MAX_SEARCH_RESULTS + + // Build filter condition + let filter = "" + if (directoryPrefix) { + const escapedPrefix = this.escapeSqlLikePattern(directoryPrefix) + filter = `\`filePath\` LIKE '${escapedPrefix}%'` + } + + // Perform vector search with distance range filtering + let searchQuery = (await table.search(queryVector)) as VectorQuery + if (filter !== "") { + searchQuery = searchQuery.where(filter) + } + searchQuery = searchQuery + .distanceType("cosine") + .distanceRange(0, 1 - actualMinScore) + .limit(actualMaxResults) + + const list = await searchQuery.toArray() + const results = list.map((result: any) => ({ + id: result.id, + score: 1 - result._distance, // Convert distance to similarity score + payload: { + filePath: result.filePath, + codeChunk: result.codeChunk, + startLine: result.startLine, + endLine: result.endLine, + } as Payload, + })) + + return results + } catch (error) { + log.error("Failed to search points", { error }) + throw error + } + } + + async deletePointsByFilePath(filePath: string): Promise { + return this.deletePointsByMultipleFilePaths([filePath]) + } + + async deletePointsByMultipleFilePaths(filePaths: string[]): Promise { + if (filePaths.length === 0) { + return + } + + try { + const table = await this.getTable() + const workspaceRoot = this.workspacePath + const normalizedPaths = filePaths.map((fp) => + path.normalize(path.isAbsolute(fp) ? path.relative(workspaceRoot, fp) : fp), + ) + + // Create filter condition for multiple file paths + const escapedPaths = normalizedPaths.map((fp) => `'${this.escapeSqlString(fp)}'`).join(", ") + const filterCondition = `\`filePath\` IN (${escapedPaths})` + await table.delete(filterCondition) + } catch (error) { + log.error("Failed to delete points by file paths", { error }) + throw error + } + } + + async deleteCollection(): Promise { + await this.closeConnect() + try { + if (fs.existsSync(this.dbPath)) { + fs.rmSync(this.dbPath, { recursive: true, force: true }) + } + } catch (error) { + // If file deletion fails, try to clear the collection and metadata table + try { + const db = await this.getDb() + await this._dropTableIfExists(db, this.vectorTableName) + await this._dropTableIfExists(db, this.metadataTableName) + } catch (clearError) { + log.error("Failed to clear collection and metadata", { error: clearError }) + } + throw error + } + } + + async clearCollection(): Promise { + try { + const table = await this.getTable() + // Delete all records from the table + await table.delete("true") // Delete all records + + // Also clear metadata table + try { + const db = await this.getDb() + const tableNames = await db.tableNames() + + if (tableNames.includes(this.metadataTableName)) { + const metadataTable = await db.openTable(this.metadataTableName) + await metadataTable.delete("true") + } + } catch (metadataError) { + log.warn("Failed to clear metadata table", { error: metadataError }) + } + + // Run optimization to clean up disk space after clearing + await this.optimizeTable() + } catch (error) { + log.error("Failed to clear collection", { error }) + throw error + } + } + + async collectionExists(): Promise { + try { + const db = await this.getDb() + const tableNames = await db.tableNames() + return tableNames.includes(this.vectorTableName) + } catch (error) { + return false + } + } + + private async closeConnect(): Promise { + if (this.table) { + this.table = null + } + if (this.db) { + await this.db.close() + this.db = null + } + } + + /** + * Optimizes the table to reduce disk space usage and improve performance. + * This method performs compaction, pruning of old versions, and index optimization. + * Should be called periodically to prevent unbounded disk space growth. + */ + async optimizeTable(): Promise { + try { + const table = await this.getTable() + + await table.optimize({ + cleanupOlderThan: new Date(), + deleteUnverified: false, + }) + } catch (error) { + log.error("Failed to optimize table", { error }) + } + } + + /** + * Checks if the collection exists and has indexed points + * @returns Promise resolving to boolean indicating if the collection exists and has points + */ + async hasIndexedData(): Promise { + try { + const db = await this.getDb() + const table = await this.getTable() + const pointCount = await table.countRows() + if (pointCount === 0) { + log.info("LanceDB has no indexed data", { + workspacePath: this.workspacePath, + reason: "points_zero", + }) + return false + } + const metadataTable = await db.openTable(this.metadataTableName) + const metadataResults = await metadataTable.query().where(`key = '${KEY.complete}'`).toArray() + const indexed = metadataResults.length > 0 ? metadataResults[0].value : false + log.info("LanceDB indexing metadata evaluated", { + workspacePath: this.workspacePath, + pointCount, + indexed, + }) + return indexed + } catch (error) { + log.warn("Failed to check if collection has data", { error }) + return false + } + } + + private async _upsertMetadata(metadataTable: Table, key: string, value: unknown): Promise { + if (!this.isValidMetadataKey(key)) { + throw new Error(`Invalid metadata key: ${key}`) + } + await metadataTable.delete(`key = '${key}'`) + await metadataTable.add([{ key, value }]) + } + + private async _persistEmbeddingProfile(metadataTable: Table): Promise { + await this._upsertMetadata(metadataTable, KEY.provider, this.profile.provider) + await this._upsertMetadata(metadataTable, KEY.model, this.profile.modelId) + await this._upsertMetadata(metadataTable, KEY.dimension, this.profile.dimension) + await this._upsertMetadata(metadataTable, KEY.size, this.vectorSize) + } + + /** + * Marks the indexing process as complete by storing metadata + * Should be called after a successful full workspace scan or incremental scan + */ + async markIndexingComplete(): Promise { + try { + const db = await this.getDb() + const metadataTable = await db.openTable(this.metadataTableName) + await this._persistEmbeddingProfile(metadataTable) + await this._upsertMetadata(metadataTable, KEY.complete, true) + log.info("Marked indexing as complete") + } catch (error) { + log.error("Failed to mark indexing as complete", { error }) + throw error + } + } + + /** + * Marks the indexing process as incomplete by storing metadata + * Should be called at the start of indexing to indicate work in progress + */ + async markIndexingIncomplete(): Promise { + try { + const db = await this.getDb() + const metadataTable = await db.openTable(this.metadataTableName) + await this._persistEmbeddingProfile(metadataTable) + await this._upsertMetadata(metadataTable, KEY.complete, false) + log.info("Marked indexing as incomplete (in progress)") + } catch (error) { + log.error("Failed to mark indexing as incomplete", { error }) + throw error + } + } +} diff --git a/packages/kilo-indexing/src/indexing/vector-store/qdrant-client.ts b/packages/kilo-indexing/src/indexing/vector-store/qdrant-client.ts new file mode 100644 index 00000000000..44b45240966 --- /dev/null +++ b/packages/kilo-indexing/src/indexing/vector-store/qdrant-client.ts @@ -0,0 +1,759 @@ +import { QdrantClient, type Schemas } from "@qdrant/js-client-rest" +import { createHash } from "crypto" +import * as path from "path" +import type { IVectorStore } from "../interfaces/vector-store" +import type { Payload, VectorStoreSearchResult } from "../interfaces" +import { DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_SEARCH_MIN_SCORE } from "../constants" +import { Log } from "../../util/log" +import type { EmbeddingProfile } from "../embedding-profile" + +const log = Log.create({ service: "qdrant-store" }) + +const KEY = { + complete: "indexing_complete", + provider: "embedding_provider", + model: "embedding_model_id", + dimension: "embedding_dimension", +} + +const METADATA_ID = "f946a536-9af4-4f1f-9f95-7d6efb4647d5" + +/** + * Qdrant implementation of the vector store interface + */ +export class QdrantVectorStore implements IVectorStore { + private readonly vectorSize!: number + private readonly DISTANCE_METRIC = "Cosine" + + private client: QdrantClient + private readonly collectionName: string + private readonly qdrantUrl: string = "http://localhost:6333" + private readonly workspacePath: string + private readonly profile: EmbeddingProfile + + /** + * Creates a new Qdrant vector store + * @param workspacePath Path to the workspace + * @param url Optional URL to the Qdrant server + */ + constructor(workspacePath: string, url: string, vectorSize: number, apiKey?: string, profile?: EmbeddingProfile) { + // Parse the URL to determine the appropriate QdrantClient configuration + const parsedUrl = this.parseQdrantUrl(url) + + // Store the resolved URL for our property + this.qdrantUrl = parsedUrl + this.workspacePath = workspacePath + + try { + const urlObj = new URL(parsedUrl) + + // Always use host-based configuration with explicit ports to avoid QdrantClient defaults + let port: number + let useHttps: boolean + + if (urlObj.port) { + // Explicit port specified - use it and determine protocol + port = Number(urlObj.port) + useHttps = urlObj.protocol === "https:" + } else { + // No explicit port - use protocol defaults + if (urlObj.protocol === "https:") { + port = 443 + useHttps = true + } else { + // http: or other protocols default to port 80 + port = 80 + useHttps = false + } + } + + this.client = new QdrantClient({ + host: urlObj.hostname, + https: useHttps, + port: port, + prefix: urlObj.pathname === "/" ? undefined : urlObj.pathname.replace(/\/+$/, ""), + apiKey, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + } catch (urlError) { + // If URL parsing fails, fall back to URL-based config + // Note: This fallback won't correctly handle prefixes, but it's a last resort for malformed URLs. + this.client = new QdrantClient({ + url: parsedUrl, + apiKey, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + } + + // Generate collection name from workspace path + const hash = createHash("sha256").update(workspacePath).digest("hex") + this.vectorSize = vectorSize + this.profile = + profile ?? + ({ + provider: "openai", + modelId: "", + dimension: vectorSize, + } as EmbeddingProfile) + this.collectionName = `ws-${hash.substring(0, 16)}` + } + + /** + * Parses and normalizes Qdrant server URLs to handle various input formats + * @param url Raw URL input from user + * @returns Properly formatted URL for QdrantClient + */ + private parseQdrantUrl(url: string | undefined): string { + // Handle undefined/null/empty cases + if (!url || url.trim() === "") { + return "http://localhost:6333" + } + + const trimmedUrl = url.trim() + + // Check if it starts with a protocol + if (!trimmedUrl.startsWith("http://") && !trimmedUrl.startsWith("https://") && !trimmedUrl.includes("://")) { + // No protocol - treat as hostname + return this.parseHostname(trimmedUrl) + } + + try { + // Attempt to parse as complete URL - return as-is, let constructor handle ports + const parsedUrl = new URL(trimmedUrl) + return trimmedUrl + } catch { + // Failed to parse as URL - treat as hostname + return this.parseHostname(trimmedUrl) + } + } + + /** + * Handles hostname-only inputs + * @param hostname Raw hostname input + * @returns Properly formatted URL with http:// prefix + */ + private parseHostname(hostname: string): string { + if (hostname.includes(":")) { + // Has port - add http:// prefix if missing + return hostname.startsWith("http") ? hostname : `http://${hostname}` + } else { + // No port - add http:// prefix without port (let constructor handle port assignment) + return `http://${hostname}` + } + } + + private async getCollectionInfo(): Promise { + try { + const collectionInfo = await this.client.getCollection(this.collectionName) + return collectionInfo + } catch (error: unknown) { + if (error instanceof Error) { + log.warn( + `Warning during getCollectionInfo for "${this.collectionName}". Collection may not exist or another error occurred:`, + { error: error.message }, + ) + } + return null + } + } + + private metadataId(): string { + return METADATA_ID + } + + private parseDimension(value: unknown): number | undefined { + const dim = Number(value) + if (!Number.isFinite(dim) || dim <= 0) return undefined + return dim + } + + private async getMetadataPayload(): Promise | undefined> { + const metadataPoints = await this.client.retrieve(this.collectionName, { + ids: [this.metadataId()], + }) + if (metadataPoints.length === 0) return undefined + const first = metadataPoints[0] + if (!first) return undefined + const payload = first.payload + if (!payload || typeof payload !== "object") return undefined + return payload as Record + } + + private getStoredProfile(payload?: Record): EmbeddingProfile | undefined { + if (!payload) return undefined + const provider = payload[KEY.provider] + const modelId = payload[KEY.model] + const dimension = payload[KEY.dimension] + if (typeof provider !== "string" || typeof modelId !== "string") return undefined + const dim = this.parseDimension(dimension) + if (!dim) return undefined + return { + provider: provider as EmbeddingProfile["provider"], + modelId, + dimension: dim, + } + } + + private isProfileMatch(profile: EmbeddingProfile): boolean { + return ( + profile.provider === this.profile.provider && + profile.modelId === this.profile.modelId && + profile.dimension === this.profile.dimension + ) + } + + private async createCollection(): Promise { + await this.client.createCollection(this.collectionName, { + vectors: { + size: this.vectorSize, + distance: this.DISTANCE_METRIC, + on_disk: true, + }, + hnsw_config: { + m: 64, + ef_construct: 512, + on_disk: true, + }, + }) + } + + private async recreateCollectionForProfile(stored?: EmbeddingProfile): Promise { + const from = stored + ? `${stored.provider}:${stored.modelId}:${stored.dimension}` + : "missing embedding metadata on populated collection" + const to = `${this.profile.provider}:${this.profile.modelId}:${this.profile.dimension}` + log.warn(`Collection ${this.collectionName} embedding profile changed (${from} -> ${to}). Recreating collection.`) + + await this.client.deleteCollection(this.collectionName) + await new Promise((resolve) => setTimeout(resolve, 100)) + + const verificationInfo = await this.getCollectionInfo() + if (verificationInfo !== null) { + throw new Error("Embedding identity mismatch: collection still exists after deletion attempt") + } + + await this.createCollection() + return true + } + + /** + * Initializes the vector store + * @returns Promise resolving to boolean indicating if a new collection was created + */ + async initialize(): Promise { + let created = false + try { + const collectionInfo = await this.getCollectionInfo() + + if (collectionInfo === null) { + // Collection info not retrieved (assume not found or inaccessible), create it + await this.createCollection() + created = true + } else { + // Collection exists, check vector size + const vectorsConfig = collectionInfo.config?.params?.vectors + let existingVectorSize: number + + if (typeof vectorsConfig === "number") { + existingVectorSize = vectorsConfig + } else if ( + vectorsConfig && + typeof vectorsConfig === "object" && + "size" in vectorsConfig && + typeof vectorsConfig.size === "number" + ) { + existingVectorSize = vectorsConfig.size + } else { + existingVectorSize = 0 // Fallback for unknown configuration + } + + if (existingVectorSize === this.vectorSize) { + const pointCount = collectionInfo.points_count ?? 0 + if (pointCount === 0) { + created = false + } else { + const payload = await this.getMetadataPayload() + const profile = this.getStoredProfile(payload) + created = + !profile || !this.isProfileMatch(profile) ? await this.recreateCollectionForProfile(profile) : false + } + } else { + // Exists but wrong vector size, recreate with enhanced error handling + created = await this._recreateCollectionWithNewDimension(existingVectorSize) + } + } + + // Create payload indexes + await this._createPayloadIndexes() + log.info("Qdrant collection ready", { + collection: this.collectionName, + created, + vectorSize: this.vectorSize, + url: this.qdrantUrl, + }) + return created + } catch (error: any) { + const errorMessage = error?.message || error + log.error(`Failed to initialize Qdrant collection "${this.collectionName}"`, { error: errorMessage }) + + // If this is already a vector dimension mismatch error (identified by cause), re-throw it as-is + if (error instanceof Error && error.cause !== undefined) { + throw error + } + + // Otherwise, provide a more user-friendly error message that includes the original error + throw new Error(`Failed to connect to Qdrant at ${this.qdrantUrl}: ${errorMessage}`) + } + } + + /** + * Recreates the collection with a new vector dimension, handling failures gracefully. + * @param existingVectorSize The current vector size of the existing collection + * @returns Promise resolving to boolean indicating if a new collection was created + */ + private async _recreateCollectionWithNewDimension(existingVectorSize: number): Promise { + log.warn( + `Collection ${this.collectionName} exists with vector size ${existingVectorSize}, but expected ${this.vectorSize}. Recreating collection.`, + ) + + let deletionSucceeded = false + let recreationAttempted = false + + try { + // Step 1: Attempt to delete the existing collection + log.info(`Deleting existing collection ${this.collectionName}...`) + await this.client.deleteCollection(this.collectionName) + deletionSucceeded = true + log.info(`Successfully deleted collection ${this.collectionName}`) + + // Step 2: Wait a brief moment to ensure deletion is processed + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Step 3: Verify the collection is actually deleted + const verificationInfo = await this.getCollectionInfo() + if (verificationInfo !== null) { + throw new Error("Collection still exists after deletion attempt") + } + + // Step 4: Create the new collection with correct dimensions + log.info(`Creating new collection ${this.collectionName} with vector size ${this.vectorSize}...`) + recreationAttempted = true + await this.createCollection() + log.info(`Successfully created new collection ${this.collectionName}`) + return true + } catch (recreationError) { + const errorMessage = recreationError instanceof Error ? recreationError.message : String(recreationError) + + // Provide detailed error context based on what stage failed + let contextualErrorMessage: string + if (!deletionSucceeded) { + contextualErrorMessage = `Failed to delete existing collection with vector size ${existingVectorSize}. ${errorMessage}` + } else if (!recreationAttempted) { + contextualErrorMessage = `Deleted existing collection but failed verification step. ${errorMessage}` + } else { + contextualErrorMessage = `Deleted existing collection but failed to create new collection with vector size ${this.vectorSize}. ${errorMessage}` + } + + log.error( + `CRITICAL: Failed to recreate collection ${this.collectionName} for dimension change (${existingVectorSize} -> ${this.vectorSize}). ${contextualErrorMessage}`, + ) + + // Create a comprehensive error message for the user + throw new Error(`Vector dimension mismatch: ${contextualErrorMessage}`, { cause: recreationError }) + } + } + + /** + * Creates payload indexes for the collection, handling errors gracefully. + */ + private async _createPayloadIndexes(): Promise { + // Create index for the 'type' field to enable metadata filtering + try { + await this.client.createPayloadIndex(this.collectionName, { + field_name: "type", + field_schema: "keyword", + }) + } catch (indexError: any) { + const errorMessage = (indexError?.message || "").toLowerCase() + if (!errorMessage.includes("already exists")) { + log.warn(`Could not create payload index for type on ${this.collectionName}`, { + details: indexError?.message || indexError, + }) + } + } + + // Create indexes for pathSegments fields + for (let i = 0; i <= 4; i++) { + try { + await this.client.createPayloadIndex(this.collectionName, { + field_name: `pathSegments.${i}`, + field_schema: "keyword", + }) + } catch (indexError: any) { + const errorMessage = (indexError?.message || "").toLowerCase() + if (!errorMessage.includes("already exists")) { + log.warn(`Could not create payload index for pathSegments.${i} on ${this.collectionName}`, { + details: indexError?.message || indexError, + }) + } + } + } + } + + /** + * Upserts points into the vector store + * @param points Array of points to upsert + */ + async upsertPoints( + points: Array<{ + id: string + vector: number[] + payload: Record + }>, + ): Promise { + try { + const processedPoints = points.map((point) => { + if (point.payload?.filePath) { + const segments = point.payload.filePath.split(path.sep).filter(Boolean) + const pathSegments = segments.reduce((acc: Record, segment: string, index: number) => { + acc[index.toString()] = segment + return acc + }, {}) + return { + ...point, + payload: { + ...point.payload, + pathSegments, + }, + } + } + return point + }) + + await this.client.upsert(this.collectionName, { + points: processedPoints, + wait: true, + }) + } catch (error) { + log.error("Failed to upsert points", { error }) + throw error + } + } + + /** + * Checks if a payload is valid + * @param payload Payload to check + * @returns Boolean indicating if the payload is valid + */ + private isPayloadValid(payload: Record | null | undefined): payload is Payload { + if (!payload) { + return false + } + const validKeys = ["filePath", "codeChunk", "startLine", "endLine"] + const hasValidKeys = validKeys.every((key) => key in payload) + return hasValidKeys + } + + /** + * Searches for similar vectors + * @param queryVector Vector to search for + * @param directoryPrefix Optional directory prefix to filter results + * @param minScore Optional minimum score threshold + * @param maxResults Optional maximum number of results to return + * @returns Promise resolving to search results + */ + async search( + queryVector: number[], + directoryPrefix?: string, + minScore?: number, + maxResults?: number, + ): Promise { + try { + let filter: + | { + must: Array<{ key: string; match: { value: string } }> + must_not?: Array<{ key: string; match: { value: string } }> + } + | undefined = undefined + + if (directoryPrefix) { + // Check if the path represents current directory + const normalizedPrefix = path.posix.normalize(directoryPrefix.replace(/\\/g, "/")) + // Note: path.posix.normalize("") returns ".", and normalize("./") returns "./" + if (normalizedPrefix === "." || normalizedPrefix === "./") { + // Don't create a filter - search entire workspace + filter = undefined + } else { + // Remove leading "./" from paths like "./src" to normalize them + const cleanedPrefix = path.posix.normalize( + normalizedPrefix.startsWith("./") ? normalizedPrefix.slice(2) : normalizedPrefix, + ) + const segments = cleanedPrefix.split("/").filter(Boolean) + if (segments.length > 0) { + filter = { + must: segments.map((segment, index) => ({ + key: `pathSegments.${index}`, + match: { value: segment }, + })), + } + } + } + } + + // Always exclude metadata points at query-time to avoid wasting top-k + const metadataExclusion = { + must_not: [{ key: "type", match: { value: "metadata" } }], + } + + const mergedFilter = filter + ? { ...filter, must_not: [...(filter.must_not || []), ...metadataExclusion.must_not] } + : metadataExclusion + + const searchRequest = { + query: queryVector, + filter: mergedFilter, + score_threshold: minScore ?? DEFAULT_SEARCH_MIN_SCORE, + limit: maxResults ?? DEFAULT_MAX_SEARCH_RESULTS, + params: { + hnsw_ef: 128, + exact: false, + }, + with_payload: { + include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + }, + } + + const operationResult = await this.client.query(this.collectionName, searchRequest) + const filteredPoints = operationResult.points.filter((p: any) => this.isPayloadValid(p.payload)) + + return filteredPoints as VectorStoreSearchResult[] + } catch (error) { + log.error("Failed to search points", { error }) + throw error + } + } + + /** + * Deletes points by file path + * @param filePath Path of the file to delete points for + */ + async deletePointsByFilePath(filePath: string): Promise { + return this.deletePointsByMultipleFilePaths([filePath]) + } + + async deletePointsByMultipleFilePaths(filePaths: string[]): Promise { + if (filePaths.length === 0) { + return + } + + try { + // First check if the collection exists + const collectionExists = await this.collectionExists() + if (!collectionExists) { + log.warn(`Skipping deletion - collection "${this.collectionName}" does not exist`) + return + } + + const workspaceRoot = this.workspacePath + + // Build filters using pathSegments to match the indexed fields + const filters = filePaths.map((filePath) => { + // IMPORTANT: Use the relative path to match what's stored in upsertPoints + // upsertPoints stores the relative filePath, not the absolute path + const relativePath = path.isAbsolute(filePath) ? path.relative(workspaceRoot, filePath) : filePath + + // Normalize the relative path + const normalizedRelativePath = path.normalize(relativePath) + + // Split the path into segments like we do in upsertPoints + const segments = normalizedRelativePath.split(path.sep).filter(Boolean) + + // Create a filter that matches all segments of the path + // This ensures we only delete points that match the exact file path + const mustConditions = segments.map((segment, index) => ({ + key: `pathSegments.${index}`, + match: { value: segment }, + })) + + return { must: mustConditions } + }) + + // Use 'should' to match any of the file paths (OR condition) + const filter = filters.length === 1 ? filters[0]! : { should: filters } + + await this.client.delete(this.collectionName, { + filter, + wait: true, + }) + } catch (error: any) { + // Extract more detailed error information + const errorMessage = error?.message || String(error) + const errorStatus = error?.status || error?.response?.status || error?.statusCode + const errorDetails = error?.response?.data || error?.data || "" + + log.error(`Failed to delete points by file paths`, { + error: errorMessage, + status: errorStatus, + details: errorDetails, + collection: this.collectionName, + fileCount: filePaths.length, + // Include first few file paths for debugging (avoid logging too many) + samplePaths: filePaths.slice(0, 3), + }) + throw error + } + } + + /** + * Deletes the entire collection. + */ + async deleteCollection(): Promise { + try { + // Check if collection exists before attempting deletion to avoid errors + if (await this.collectionExists()) { + await this.client.deleteCollection(this.collectionName) + } + } catch (error) { + log.error(`Failed to delete collection ${this.collectionName}`, { error }) + throw error // Re-throw to allow calling code to handle it + } + } + + /** + * Clears all points from the collection + */ + async clearCollection(): Promise { + try { + await this.client.delete(this.collectionName, { + filter: { + must: [], + }, + wait: true, + }) + } catch (error) { + log.error("Failed to clear collection", { error }) + throw error + } + } + + /** + * Checks if the collection exists + * @returns Promise resolving to boolean indicating if the collection exists + */ + async collectionExists(): Promise { + const collectionInfo = await this.getCollectionInfo() + return collectionInfo !== null + } + + /** + * Checks if the collection exists and has indexed points + * @returns Promise resolving to boolean indicating if the collection exists and has points + */ + async hasIndexedData(): Promise { + try { + const collectionInfo = await this.getCollectionInfo() + if (!collectionInfo) { + log.info("Qdrant collection has no indexed data", { + collection: this.collectionName, + reason: "collection_missing", + }) + return false + } + // Check if the collection has any points indexed + const pointsCount = collectionInfo.points_count ?? 0 + if (pointsCount === 0) { + log.info("Qdrant collection has no indexed data", { + collection: this.collectionName, + reason: "points_zero", + }) + return false + } + + // Check if the indexing completion marker exists + const payload = await this.getMetadataPayload() + + // If marker exists, use it to determine completion status + if (payload) { + const indexed = payload[KEY.complete] === true + log.info("Qdrant indexing metadata evaluated", { + collection: this.collectionName, + pointsCount, + indexed, + }) + return indexed + } + + // Backward compatibility: No marker exists (old index or pre-marker version) + // Fall back to old logic - assume complete if collection has points + log.info("No indexing metadata marker found. Using backward compatibility mode (checking points_count > 0).") + return pointsCount > 0 + } catch (error) { + log.warn("Failed to check if collection has data", { error }) + return false + } + } + + /** + * Marks the indexing process as complete by storing metadata + * Should be called after a successful full workspace scan or incremental scan + */ + async markIndexingComplete(): Promise { + try { + await this.client.upsert(this.collectionName, { + points: [ + { + id: this.metadataId(), + vector: new Array(this.vectorSize).fill(0), + payload: { + type: "metadata", + [KEY.complete]: true, + [KEY.provider]: this.profile.provider, + [KEY.model]: this.profile.modelId, + [KEY.dimension]: this.profile.dimension, + completed_at: Date.now(), + }, + }, + ], + wait: true, + }) + log.info("Marked indexing as complete") + } catch (error) { + log.error("Failed to mark indexing as complete", { error }) + throw error + } + } + + /** + * Marks the indexing process as incomplete by storing metadata + * Should be called at the start of indexing to indicate work in progress + */ + async markIndexingIncomplete(): Promise { + try { + await this.client.upsert(this.collectionName, { + points: [ + { + id: this.metadataId(), + vector: new Array(this.vectorSize).fill(0), + payload: { + type: "metadata", + [KEY.complete]: false, + [KEY.provider]: this.profile.provider, + [KEY.model]: this.profile.modelId, + [KEY.dimension]: this.profile.dimension, + started_at: Date.now(), + }, + }, + ], + wait: true, + }) + log.info("Marked indexing as incomplete (in progress)") + } catch (error) { + log.error("Failed to mark indexing as incomplete", { error }) + throw error + } + } +} diff --git a/packages/kilo-indexing/src/plugin.ts b/packages/kilo-indexing/src/plugin.ts new file mode 100644 index 00000000000..d51f87ab739 --- /dev/null +++ b/packages/kilo-indexing/src/plugin.ts @@ -0,0 +1,8 @@ +import type { Plugin } from "@kilocode/plugin" + +// RATIONALE: The host runtime owns lifecycle, routes, and native tool wiring. +// The plugin entry exists so workspaces can opt into indexing with a normal +// plugin specifier while keeping the engine and shims outside the plugin API. +export const KiloIndexingPlugin: Plugin = async () => ({}) + +export default KiloIndexingPlugin diff --git a/packages/kilo-indexing/src/server/routes.ts b/packages/kilo-indexing/src/server/routes.ts new file mode 100644 index 00000000000..33be0127a76 --- /dev/null +++ b/packages/kilo-indexing/src/server/routes.ts @@ -0,0 +1,27 @@ +import { Hono } from "hono" +import { describeRoute, resolver } from "hono-openapi" +import { IndexingStatus, type IndexingStatus as Status } from "../status" + +export function createIndexingRoutes(input: { current(): Promise }) { + return new Hono().get( + "/status", + describeRoute({ + summary: "Get indexing status", + description: "Retrieve the current code indexing status for the active project.", + operationId: "indexing.status", + responses: { + 200: { + description: "Indexing status", + content: { + "application/json": { + schema: resolver(IndexingStatus), + }, + }, + }, + }, + }), + async (c) => { + return c.json(await input.current()) + }, + ) +} diff --git a/packages/kilo-indexing/src/status.ts b/packages/kilo-indexing/src/status.ts new file mode 100644 index 00000000000..4b9315524c4 --- /dev/null +++ b/packages/kilo-indexing/src/status.ts @@ -0,0 +1,92 @@ +import z from "zod" +import type { IndexingState } from "./indexing/interfaces/manager" + +type StatusSource = { + readonly isFeatureEnabled: boolean + readonly isFeatureConfigured: boolean + getCurrentStatus(): { + systemStatus: IndexingState + message?: string + processedItems: number + totalItems: number + currentItemUnit: string + } +} + +export const INDEXING_STATUS_STATES = ["Disabled", "In Progress", "Complete", "Error", "Standby"] as const + +export const IndexingStatusState = z.enum(INDEXING_STATUS_STATES).meta({ ref: "IndexingStatusState" }) + +export type IndexingStatusState = z.infer + +export const IndexingStatus = z + .object({ + state: IndexingStatusState, + message: z.string(), + processedFiles: z.number().int().nonnegative(), + totalFiles: z.number().int().nonnegative(), + percent: z.number().int().min(0).max(100), + }) + .meta({ ref: "IndexingStatus" }) + +export type IndexingStatus = z.infer + +export function disabledIndexingStatus(message = "Indexing disabled."): IndexingStatus { + return { + state: "Disabled", + message, + processedFiles: 0, + totalFiles: 0, + percent: 0, + } +} + +export function normalizeIndexingStatus(manager: StatusSource): IndexingStatus { + const cfg = manager.getCurrentStatus() + const files = cfg.currentItemUnit === "files" + const processedFiles = files ? cfg.processedItems : 0 + const totalFiles = files ? cfg.totalItems : 0 + const percent = totalFiles > 0 ? Math.min(100, Math.max(0, Math.round((processedFiles / totalFiles) * 100))) : 0 + + if (!manager.isFeatureEnabled || !manager.isFeatureConfigured) { + return disabledIndexingStatus(cfg.message || "Indexing disabled.") + } + + if (cfg.systemStatus === "Error") { + return { + state: "Error", + message: cfg.message || "Indexing failed.", + processedFiles, + totalFiles, + percent, + } + } + + if (cfg.systemStatus === "Indexing") { + return { + state: "In Progress", + message: cfg.message || "Indexing in progress.", + processedFiles, + totalFiles, + percent, + } + } + + if (cfg.systemStatus === "Standby") { + return { + state: "Standby", + message: cfg.message || "Indexing paused.", + processedFiles, + totalFiles, + percent, + } + } + + return { + state: "Complete", + message: cfg.message || "Index up-to-date.", + processedFiles, + totalFiles, + percent: totalFiles > 0 ? percent : 100, + } +} diff --git a/packages/kilo-indexing/src/tree-sitter/index.ts b/packages/kilo-indexing/src/tree-sitter/index.ts new file mode 100644 index 00000000000..e6a88381e11 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/index.ts @@ -0,0 +1,263 @@ +import * as fs from "fs/promises" +import * as path from "path" +import type { LanguageParser } from "./languageParser" +import { loadRequiredLanguageParsers } from "./languageParser" +import { parseMarkdown } from "./markdownParser" +import type { QueryCapture } from "web-tree-sitter" +import { Log } from "../util/log" + +const log = Log.create({ service: "tree-sitter" }) + +const METHOD_CAPTURE = ["definition.method", "definition.method.start"] + +const DEFAULT_MIN_COMPONENT_LINES_VALUE = 4 + +let currentMinComponentLines = DEFAULT_MIN_COMPONENT_LINES_VALUE + +export function getMinComponentLines(): number { + return currentMinComponentLines +} + +export function setMinComponentLines(value: number): void { + currentMinComponentLines = value +} + +function shouldSkipMinLines(lineCount: number, capture: QueryCapture, _language: string) { + if (METHOD_CAPTURE.includes(capture.name)) { + // In OOP languages, method signatures are only one line and should not be ignored + return false + } + return lineCount < getMinComponentLines() +} + +const extensions = [ + // Shell and build systems + "bash", + "bazel", + "bzl", + "build", + "gradle", + "ninja", + "sh", + "zsh", + + // Web and frontend + "css", + "ejs", + "erb", + "htm", + "html", + "js", + "jsx", + "ts", + "tsx", + "vue", + + // Native and systems languages + "c", + "cpp", + "cs", + "go", + "h", + "hpp", + "m", + "mm", + "rs", + "swift", + "zig", + + // JVM and BEAM languages + "ex", + "exs", + "java", + "kt", + "kts", + "scala", + + // Scripting and application languages + "dart", + "el", + "elm", + "lua", + "php", + "py", + "r", + "rb", + "vb", + + // Functional and specialized languages + "ml", + "mli", + "ql", + "rdl", + "res", + "resi", + "sol", + "tla", + + // Data and documentation + "json", + "markdown", + "md", + "rst", + "sql", + "toml", + "yaml", + "yml", +].map((e) => `.${e}`) + +export { extensions } + +export async function parseSourceCodeDefinitionsForFile(filePath: string): Promise { + try { + await fs.access(path.resolve(filePath)) + } catch { + return "This file does not exist or you do not have permission to access it." + } + + const ext = path.extname(filePath).toLowerCase() + if (!extensions.includes(ext)) { + return undefined + } + + // Markdown files use a custom parser (no tree-sitter WASM needed) + if (ext === ".md" || ext === ".markdown") { + const fileContent = await fs.readFile(filePath, "utf8") + const lines = fileContent.split("\n") + const markdownCaptures = parseMarkdown(fileContent) + const markdownDefinitions = processCaptures(markdownCaptures, lines, "markdown") + + if (markdownDefinitions) { + return `# ${path.basename(filePath)}\n${markdownDefinitions}` + } + return undefined + } + + // For other file types, load parser and use tree-sitter + try { + const languageParsers = await loadRequiredLanguageParsers([filePath]) + + const definitions = await parseFile(filePath, languageParsers) + if (definitions) { + return `# ${path.basename(filePath)}\n${definitions}` + } + + return undefined + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + const isUnsupported = + msg.startsWith("Unsupported language:") || msg.startsWith("Missing tree-sitter language WASM:") + + if (!isUnsupported) { + throw error + } + + log.debug("skipping AST definition extraction for fallback-only extension", { + filePath, + ext, + err: msg, + }) + return undefined + } +} + +function processCaptures(captures: QueryCapture[], lines: string[], language: string): string | null { + const needsHtmlFiltering = ["jsx", "tsx"].includes(language) + + const isNotHtmlElement = (line: string): boolean => { + if (!needsHtmlFiltering) return true + const HTML_ELEMENTS = /^[^A-Z]*<\/?(?:div|span|button|input|h[1-6]|p|a|img|ul|li|form)\b/ + const trimmedLine = line.trim() + return !HTML_ELEMENTS.test(trimmedLine) + } + + if (captures.length === 0) { + return null + } + + let formattedOutput = "" + + captures.sort((a, b) => a.node.startPosition.row - b.node.startPosition.row) + + const processedLines = new Set() + + captures.forEach((capture) => { + const { node, name } = capture + + if (!name.includes("definition") && !name.includes("name")) { + return + } + + const definitionNode = name.includes("name") ? node.parent : node + if (!definitionNode) return + + const startLine = definitionNode.startPosition.row + const endLine = definitionNode.endPosition.row + const lineCount = endLine - startLine + 1 + + if (shouldSkipMinLines(lineCount, capture, language)) { + return + } + + const lineKey = `${startLine}-${endLine}` + + if (processedLines.has(lineKey)) { + return + } + + const startLineContent = lines[startLine]?.trim() ?? "" + + if (name.includes("name.definition")) { + const componentName = node.text + + if (!processedLines.has(lineKey) && componentName) { + formattedOutput += `${startLine + 1}--${endLine + 1} | ${lines[startLine]}\n` + processedLines.add(lineKey) + } + } else if (isNotHtmlElement(startLineContent)) { + formattedOutput += `${startLine + 1}--${endLine + 1} | ${lines[startLine]}\n` + processedLines.add(lineKey) + + if (node.parent && node.parent.lastChild) { + const contextEnd = node.parent.lastChild.endPosition.row + const contextSpan = contextEnd - node.parent.startPosition.row + 1 + + if (contextSpan >= getMinComponentLines()) { + const rangeKey = `${node.parent.startPosition.row}-${contextEnd}` + if (!processedLines.has(rangeKey)) { + formattedOutput += `${node.parent.startPosition.row + 1}--${contextEnd + 1} | ${lines[node.parent.startPosition.row]}\n` + processedLines.add(rangeKey) + } + } + } + } + }) + + if (formattedOutput.length > 0) { + return formattedOutput + } + + return null +} + +async function parseFile(filePath: string, languageParsers: LanguageParser): Promise { + const fileContent = await fs.readFile(filePath, "utf8") + const extLang = path.extname(filePath).toLowerCase().slice(1) + + const { parser, query } = languageParsers[extLang] || {} + if (!parser || !query) { + return `Unsupported file type: ${filePath}` + } + + try { + const tree = parser.parse(fileContent) + const captures = tree ? query.captures(tree.rootNode) : [] + const lines = fileContent.split("\n") + return processCaptures(captures, lines, extLang) + } catch (error) { + log.error(`Error parsing file: ${filePath}`, { + err: error instanceof Error ? error.message : String(error), + }) + return null + } +} diff --git a/packages/kilo-indexing/src/tree-sitter/languageParser.ts b/packages/kilo-indexing/src/tree-sitter/languageParser.ts new file mode 100644 index 00000000000..e7ba0c3f388 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/languageParser.ts @@ -0,0 +1,299 @@ +import * as path from "path" +import { existsSync } from "fs" +import type { Parser as ParserT, Language as LanguageT, Query as QueryT } from "web-tree-sitter" +import { + javascriptQuery, + typescriptQuery, + tsxQuery, + pythonQuery, + rustQuery, + goQuery, + cppQuery, + cQuery, + csharpQuery, + rubyQuery, + javaQuery, + phpQuery, + htmlQuery, + swiftQuery, + kotlinQuery, + cssQuery, + ocamlQuery, + solidityQuery, + tomlQuery, + vueQuery, + luaQuery, + systemrdlQuery, + tlaPlusQuery, + zigQuery, + embeddedTemplateQuery, + elispQuery, + elixirQuery, +} from "./queries" +import { Log } from "../util/log" + +const log = Log.create({ service: "tree-sitter-parser" }) + +export interface LanguageParser { + [key: string]: { + parser: ParserT + query: QueryT + } +} + +function resolveModulePath(specifier: string): string | undefined { + try { + return require.resolve(specifier) + } catch { + return + } +} + +function uniquePaths(paths: Array): string[] { + return [...new Set(paths.filter((item): item is string => !!item))] +} + +function wasmDirectories(sourceDirectory?: string): string[] { + const baseDir = sourceDirectory || __dirname + const execDir = path.dirname(process.execPath) + const envDir = process.env.KILO_TREE_SITTER_WASM_DIR + const wasmPkg = resolveModulePath("tree-sitter-wasms/package.json") + const wasmOutDir = wasmPkg ? path.join(path.dirname(wasmPkg), "out") : undefined + + return uniquePaths([ + baseDir, + path.join(baseDir, "tree-sitter"), + execDir, + path.join(execDir, "tree-sitter"), + envDir, + wasmOutDir, + ]) +} + +function resolveFromDirectories(file: string, dirs: string[]): string | undefined { + for (const dir of dirs) { + const candidate = path.join(dir, file) + if (!existsSync(candidate)) { + continue + } + return candidate + } +} + +function resolveCoreRuntimeWasmPath(sourceDirectory?: string): string | undefined { + const dirs = wasmDirectories(sourceDirectory) + const localPath = resolveFromDirectories("tree-sitter.wasm", dirs) + if (localPath) { + return localPath + } + return resolveModulePath("web-tree-sitter/tree-sitter.wasm") +} + +function resolveLanguageWasmPath(langName: string, sourceDirectory?: string) { + const dirs = wasmDirectories(sourceDirectory) + const fileName = `tree-sitter-${langName}.wasm` + const wasmPath = resolveFromDirectories(fileName, dirs) + const searchedPaths = dirs.map((dir) => path.join(dir, fileName)) + + return { + wasmPath, + searchedPaths, + } +} + +async function loadLanguage(langName: string, sourceDirectory?: string) { + const { Language } = require("web-tree-sitter") + const resolved = resolveLanguageWasmPath(langName, sourceDirectory) + + if (!resolved.wasmPath) { + log.error(`Failed to resolve language WASM: ${langName}`, { + searchedPaths: resolved.searchedPaths, + }) + throw new Error(`Missing tree-sitter language WASM: ${langName}`) + } + + try { + return await Language.load(resolved.wasmPath) + } catch (error) { + log.warn(`language WASM unavailable: ${langName}`, { + wasmPath: resolved.wasmPath, + err: error instanceof Error ? error.message : String(error), + }) + throw error + } +} + +let isParserInitialized = false + +/* +RATIONALE: Uses web-tree-sitter WASM modules instead of native node bindings +to avoid architecture-specific build issues. Each language's grammar is loaded +from a .wasm file on demand based on the file extensions being parsed. + +Sources: +- https://github.com/tree-sitter/tree-sitter/blob/master/lib/binding_web/README.md +- https://github.com/Gregoor/tree-sitter-wasms/blob/main/README.md +*/ +export async function loadRequiredLanguageParsers(filesToParse: string[], sourceDirectory?: string) { + const { Parser, Query } = require("web-tree-sitter") + + if (!isParserInitialized) { + try { + const runtimeWasmPath = resolveCoreRuntimeWasmPath(sourceDirectory) + await (runtimeWasmPath + ? Parser.init({ + locateFile() { + return runtimeWasmPath + }, + }) + : Parser.init()) + isParserInitialized = true + } catch (error) { + log.error("Failed to initialize tree-sitter parser", { + err: error instanceof Error ? error.message : String(error), + }) + throw error + } + } + + const extensionsToLoad = new Set(filesToParse.map((file) => path.extname(file).toLowerCase().slice(1))) + const parsers: LanguageParser = {} + + for (const ext of extensionsToLoad) { + let language: LanguageT + let query: QueryT + let parserKey = ext + + switch (ext) { + case "js": + case "jsx": + case "json": + language = await loadLanguage("javascript", sourceDirectory) + query = new Query(language, javascriptQuery) + break + case "ts": + language = await loadLanguage("typescript", sourceDirectory) + query = new Query(language, typescriptQuery) + break + case "tsx": + language = await loadLanguage("tsx", sourceDirectory) + query = new Query(language, tsxQuery) + break + case "py": + language = await loadLanguage("python", sourceDirectory) + query = new Query(language, pythonQuery) + break + case "rs": + language = await loadLanguage("rust", sourceDirectory) + query = new Query(language, rustQuery) + break + case "go": + language = await loadLanguage("go", sourceDirectory) + query = new Query(language, goQuery) + break + case "cpp": + case "hpp": + language = await loadLanguage("cpp", sourceDirectory) + query = new Query(language, cppQuery) + break + case "c": + case "h": + language = await loadLanguage("c", sourceDirectory) + query = new Query(language, cQuery) + break + case "cs": + language = await loadLanguage("c_sharp", sourceDirectory) + query = new Query(language, csharpQuery) + break + case "rb": + language = await loadLanguage("ruby", sourceDirectory) + query = new Query(language, rubyQuery) + break + case "java": + language = await loadLanguage("java", sourceDirectory) + query = new Query(language, javaQuery) + break + case "php": + language = await loadLanguage("php", sourceDirectory) + query = new Query(language, phpQuery) + break + case "swift": + language = await loadLanguage("swift", sourceDirectory) + query = new Query(language, swiftQuery) + break + case "kt": + case "kts": + language = await loadLanguage("kotlin", sourceDirectory) + query = new Query(language, kotlinQuery) + break + case "css": + language = await loadLanguage("css", sourceDirectory) + query = new Query(language, cssQuery) + break + case "html": + language = await loadLanguage("html", sourceDirectory) + query = new Query(language, htmlQuery) + break + case "ml": + case "mli": + language = await loadLanguage("ocaml", sourceDirectory) + query = new Query(language, ocamlQuery) + break + case "scala": + language = await loadLanguage("scala", sourceDirectory) + query = new Query(language, luaQuery) // COMPAT: Uses Lua query until Scala is implemented + break + case "sol": + language = await loadLanguage("solidity", sourceDirectory) + query = new Query(language, solidityQuery) + break + case "toml": + language = await loadLanguage("toml", sourceDirectory) + query = new Query(language, tomlQuery) + break + case "vue": + language = await loadLanguage("vue", sourceDirectory) + query = new Query(language, vueQuery) + break + case "lua": + language = await loadLanguage("lua", sourceDirectory) + query = new Query(language, luaQuery) + break + case "rdl": + language = await loadLanguage("systemrdl", sourceDirectory) + query = new Query(language, systemrdlQuery) + break + case "tla": + language = await loadLanguage("tlaplus", sourceDirectory) + query = new Query(language, tlaPlusQuery) + break + case "zig": + language = await loadLanguage("zig", sourceDirectory) + query = new Query(language, zigQuery) + break + case "ejs": + case "erb": + parserKey = "embedded_template" + language = await loadLanguage("embedded_template", sourceDirectory) + query = new Query(language, embeddedTemplateQuery) + break + case "el": + language = await loadLanguage("elisp", sourceDirectory) + query = new Query(language, elispQuery) + break + case "ex": + case "exs": + language = await loadLanguage("elixir", sourceDirectory) + query = new Query(language, elixirQuery) + break + default: + throw new Error(`Unsupported language: ${ext}`) + } + + const parser = new Parser() + parser.setLanguage(language) + parsers[parserKey] = { parser, query } + } + + return parsers +} diff --git a/packages/kilo-indexing/src/tree-sitter/markdownParser.ts b/packages/kilo-indexing/src/tree-sitter/markdownParser.ts new file mode 100644 index 00000000000..7f9e0a25daa --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/markdownParser.ts @@ -0,0 +1,180 @@ +import type { QueryCapture } from "web-tree-sitter" + +interface MockNode { + startPosition: { + row: number + } + endPosition: { + row: number + } + text: string + parent?: MockNode +} + +interface MockCapture { + node: MockNode + name: string + patternIndex: number +} + +/** + * Parse a markdown file and extract headers and section line ranges. + * Returns mock captures compatible with tree-sitter's QueryCapture format. + */ +export function parseMarkdown(content: string): QueryCapture[] { + if (!content || content.trim() === "") { + return [] + } + + const lines = content.split("\n") + const captures: MockCapture[] = [] + + const atxHeaderRegex = /^(#{1,6})\s+(.+)$/ + const setextH1Regex = /^={3,}\s*$/ + const setextH2Regex = /^-{3,}\s*$/ + const validSetextTextRegex = /^\s*[^#<>!\[\]`\t]+[^\n]$/ + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // ATX headers (# Header) + const atxMatch = line.match(atxHeaderRegex) + if (atxMatch) { + const level = atxMatch[1].length + const text = atxMatch[2].trim() + + const node: MockNode = { + startPosition: { row: i }, + endPosition: { row: i }, + text: text, + } + + captures.push({ + node, + name: `name.definition.header.h${level}`, + patternIndex: 0, + }) + + captures.push({ + node, + name: `definition.header.h${level}`, + patternIndex: 0, + }) + + continue + } + + // Setext headers (underlined) + if (i > 0) { + if (setextH1Regex.test(line) && validSetextTextRegex.test(lines[i - 1])) { + const text = lines[i - 1].trim() + + const node: MockNode = { + startPosition: { row: i - 1 }, + endPosition: { row: i }, + text: text, + } + + captures.push({ + node, + name: "name.definition.header.h1", + patternIndex: 0, + }) + + captures.push({ + node, + name: "definition.header.h1", + patternIndex: 0, + }) + + continue + } + + if (setextH2Regex.test(line) && validSetextTextRegex.test(lines[i - 1])) { + const text = lines[i - 1].trim() + + const node: MockNode = { + startPosition: { row: i - 1 }, + endPosition: { row: i }, + text: text, + } + + captures.push({ + node, + name: "name.definition.header.h2", + patternIndex: 0, + }) + + captures.push({ + node, + name: "definition.header.h2", + patternIndex: 0, + }) + + continue + } + } + } + + // Calculate section ranges + captures.sort((a, b) => a.node.startPosition.row - b.node.startPosition.row) + + const headerCaptures: MockCapture[][] = [] + for (let i = 0; i < captures.length; i += 2) { + if (i + 1 < captures.length) { + headerCaptures.push([captures[i], captures[i + 1]]) + } else { + headerCaptures.push([captures[i]]) + } + } + + // Update end positions for section ranges + for (let i = 0; i < headerCaptures.length; i++) { + const headerPair = headerCaptures[i] + + if (i < headerCaptures.length - 1) { + const nextHeaderStartRow = headerCaptures[i + 1][0].node.startPosition.row + headerPair.forEach((capture) => { + capture.node.endPosition.row = nextHeaderStartRow - 1 + }) + } else { + headerPair.forEach((capture) => { + capture.node.endPosition.row = lines.length - 1 + }) + } + } + + // Cast to QueryCapture[] — our MockCapture objects provide all properties + // that the consuming code uses (node.startPosition, node.endPosition, node.text, node.parent, name) + return headerCaptures.flat() as QueryCapture[] +} + +export function formatMarkdownCaptures(captures: QueryCapture[], minSectionLines: number = 4): string | null { + if (captures.length === 0) { + return null + } + + let formattedOutput = "" + + for (let i = 1; i < captures.length; i += 2) { + const capture = captures[i] + const startLine = capture.node.startPosition.row + const endLine = capture.node.endPosition.row + + const sectionLength = endLine - startLine + 1 + if (sectionLength >= minSectionLines) { + let headerLevel = 1 + + const headerMatch = capture.name.match(/\.h(\d)$/) + if (headerMatch && headerMatch[1]) { + headerLevel = parseInt(headerMatch[1]) + } + + const headerPrefix = "#".repeat(headerLevel) + + formattedOutput += `${startLine}--${endLine} | ${headerPrefix} ${capture.node.text}\n` + } + } + + return formattedOutput.length > 0 ? formattedOutput : null +} diff --git a/packages/kilo-indexing/src/tree-sitter/queries/c-sharp.ts b/packages/kilo-indexing/src/tree-sitter/queries/c-sharp.ts new file mode 100644 index 00000000000..46f9651b369 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/c-sharp.ts @@ -0,0 +1,65 @@ +/* +C# Tree-Sitter Query Patterns +*/ +export default ` +; Using directives +(using_directive) @definition.using + +; Namespace declarations (including file-scoped) +; Support both simple names (TestNamespace) and qualified names (My.Company.Module) +(namespace_declaration + name: (qualified_name) @name) @definition.namespace +(namespace_declaration + name: (identifier) @name) @definition.namespace +(file_scoped_namespace_declaration + name: (qualified_name) @name) @definition.namespace +(file_scoped_namespace_declaration + name: (identifier) @name) @definition.namespace + +; Class declarations (including generic, static, abstract, partial, nested) +(class_declaration + name: (identifier) @name) @definition.class + +; Interface declarations +(interface_declaration + name: (identifier) @name) @definition.interface + +; Struct declarations +(struct_declaration + name: (identifier) @name) @definition.struct + +; Enum declarations +(enum_declaration + name: (identifier) @name) @definition.enum + +; Record declarations +(record_declaration + name: (identifier) @name) @definition.record + +; Method declarations (including async, static, generic) +(method_declaration + name: (identifier) @name) @definition.method + +; Property declarations +(property_declaration + name: (identifier) @name) @definition.property + +; Event declarations +(event_declaration + name: (identifier) @name) @definition.event + +; Delegate declarations +(delegate_declaration + name: (identifier) @name) @definition.delegate + +; Attribute declarations +(attribute + name: (identifier) @name) @definition.attribute + +; Generic type parameters +(type_parameter + name: (identifier) @name) @definition.type_parameter + +; LINQ expressions +(query_expression) @definition.linq_expression +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/c.ts b/packages/kilo-indexing/src/tree-sitter/queries/c.ts new file mode 100644 index 00000000000..17b1444a407 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/c.ts @@ -0,0 +1,85 @@ +/* +C Language Constructs Supported by Tree-Sitter Parser: + +1. Class-like Constructs: +- struct definitions (with fields) +- union definitions (with variants) +- enum definitions (with values) +- anonymous unions/structs +- aligned structs + +2. Function-related Constructs: +- function definitions (with parameters) +- function declarations (prototypes) +- static functions +- function pointers + +3. Type Definitions: +- typedef declarations (all types) +- function pointer typedefs +- struct/union typedefs + +4. Variable Declarations: +- global variables +- static variables +- array declarations +- pointer declarations + +5. Preprocessor Constructs: +- function-like macros +- object-like macros +- conditional compilation +*/ + +export default ` +; Function definitions and declarations +(function_definition + declarator: (function_declarator + declarator: (identifier) @name.definition.function)) + +(declaration + type: (_)? + declarator: (function_declarator + declarator: (identifier) @name.definition.function + parameters: (parameter_list)?)?) @definition.function + +(function_declarator + declarator: (identifier) @name.definition.function + parameters: (parameter_list)?) @definition.function + +; Struct definitions +(struct_specifier + name: (type_identifier) @name.definition.struct) @definition.struct + +; Union definitions +(union_specifier + name: (type_identifier) @name.definition.union) @definition.union + +; Enum definitions +(enum_specifier + name: (type_identifier) @name.definition.enum) @definition.enum + +; Typedef declarations +(type_definition + declarator: (type_identifier) @name.definition.type) @definition.type + +; Global variables +(declaration + (storage_class_specifier)? + type: (_) + declarator: (identifier) @name.definition.variable) @definition.variable + +(declaration + (storage_class_specifier)? + type: (_) + declarator: (init_declarator + declarator: (identifier) @name.definition.variable)) @definition.variable + +; Object-like macros +(preproc_def + name: (identifier) @name.definition.macro) @definition.macro + +; Function-like macros +(preproc_function_def + name: (identifier) @name.definition.macro) @definition.macro +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/cpp.ts b/packages/kilo-indexing/src/tree-sitter/queries/cpp.ts new file mode 100644 index 00000000000..13e43b18916 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/cpp.ts @@ -0,0 +1,96 @@ +/* +Supported C++ structures: +- struct/class/union declarations +- function/method declarations +- typedef declarations +- enum declarations +- namespace definitions +- template declarations +- macro definitions +- variable declarations +- constructors/destructors +- operator overloads +- friend declarations +- using declarations +*/ +export default ` +; Basic declarations +(struct_specifier + name: (type_identifier) @name.definition.class) @definition.class + +(union_specifier + name: (type_identifier) @name.definition.class) @definition.class + +; Function declarations (prototypes) +(declaration + type: (_) + declarator: (function_declarator + declarator: (identifier) @name.definition.function)) @definition.function + +; Function definitions (with body) +(function_definition + type: (_) + declarator: (function_declarator + declarator: (identifier) @name.definition.function)) @definition.function + +(function_definition + declarator: (function_declarator + declarator: (field_identifier) @name.definition.method)) @definition.method + +(type_definition + type: (_) + declarator: (type_identifier) @name.definition.type) @definition.type + +(class_specifier + name: (type_identifier) @name.definition.class) @definition.class + +; Enum declarations +(enum_specifier + name: (type_identifier) @name.definition.enum) @definition.enum + +; Namespace definitions +(namespace_definition + name: (namespace_identifier) @name.definition.namespace) @definition.namespace + +(namespace_definition + body: (declaration_list + (namespace_definition + name: (namespace_identifier) @name.definition.namespace))) @definition.namespace + +; Template declarations +(template_declaration + parameters: (template_parameter_list) + (class_specifier + name: (type_identifier) @name.definition.template.class)) @definition.template + +; Macro definitions +(preproc_function_def + name: (identifier) @name.definition.macro) @definition.macro + +; Variable declarations with initialization +(declaration + type: (_) + declarator: (init_declarator + declarator: (identifier) @name.definition.variable)) @definition.variable + +; Constructor declarations +(function_definition + declarator: (function_declarator + declarator: (identifier) @name.definition.constructor)) @definition.constructor + +; Destructor declarations +(function_definition + declarator: (function_declarator + declarator: (destructor_name) @name.definition.destructor)) @definition.destructor + +; Operator overloads +(function_definition + declarator: (function_declarator + declarator: (operator_name) @name.definition.operator)) @definition.operator + +; Friend declarations +(friend_declaration) @definition.friend + +; Using declarations +(using_declaration) @definition.using +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/css.ts b/packages/kilo-indexing/src/tree-sitter/queries/css.ts new file mode 100644 index 00000000000..229787ae995 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/css.ts @@ -0,0 +1,71 @@ +/* +CSS Tree-Sitter Query Patterns +*/ +const cssQuery = String.raw` +; CSS rulesets and selectors +(rule_set + (selectors + (class_selector + (class_name) @name.definition.ruleset)) @_rule + (#match? @name.definition.ruleset "test-ruleset-definition")) + +(rule_set + (selectors + (pseudo_class_selector + (class_selector + (class_name) @name.definition.selector))) @_selector + (#match? @name.definition.selector "test-selector-definition")) + +; Media queries +(media_statement + (block + (rule_set + (selectors + (class_selector + (class_name) @name.definition.media_query)))) @_media + (#match? @name.definition.media_query "test-media-query-definition-container")) + +; Keyframe animations +(keyframes_statement + (keyframes_name) @name.definition.keyframe) @_keyframe + (#match? @name.definition.keyframe "test-keyframe-definition-fade") + +; Animation related classes +(rule_set + (selectors + (class_selector + (class_name) @name.definition.animation)) @_animation + (#match? @name.definition.animation "test-animation-definition")) + +; Functions +(rule_set + (selectors + (class_selector + (class_name) @name.definition.function)) @_function + (#match? @name.definition.function "test-function-definition")) + +; Variables (CSS custom properties) +(declaration + (property_name) @name.definition.variable) @_variable + (#match? @name.definition.variable "^--test-variable-definition") + +; Import statements +(import_statement + (string_value) @name.definition.import) @_import + (#match? @name.definition.import "test-import-definition") + +; Nested rulesets +(rule_set + (selectors + (class_selector + (class_name) @name.definition.nested_ruleset)) @_nested + (#match? @name.definition.nested_ruleset "test-nested-ruleset-definition")) + +; Mixins (using CSS custom properties as a proxy) +(rule_set + (selectors + (class_selector + (class_name) @name.definition.mixin)) @_mixin + (#match? @name.definition.mixin "test-mixin-definition"))` + +export default cssQuery diff --git a/packages/kilo-indexing/src/tree-sitter/queries/elisp.ts b/packages/kilo-indexing/src/tree-sitter/queries/elisp.ts new file mode 100644 index 00000000000..de762826709 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/elisp.ts @@ -0,0 +1,40 @@ +// Query patterns for Emacs Lisp +export const elispQuery = ` +; Function definitions - capture only name and actual function node +((function_definition + name: (symbol) @name.definition.function) @_func + (#match? @name.definition.function "^[^;]")) + +; Macro definitions - capture only name and actual macro node +((macro_definition + name: (symbol) @name.definition.macro) @_macro + (#match? @name.definition.macro "^[^;]")) + +; Custom forms - match defcustom specifically and avoid comments +((list + . (symbol) @_def + . (symbol) @name.definition.custom) @_custom + (#eq? @_def "defcustom") + (#match? @name.definition.custom "^[^;]")) + +; Face definitions - match defface specifically and avoid comments +((list + . (symbol) @_def + . (symbol) @name.definition.face) @_face + (#eq? @_def "defface") + (#match? @name.definition.face "^[^;]")) + +; Group definitions - match defgroup specifically and avoid comments +((list + . (symbol) @_def + . (symbol) @name.definition.group) @_group + (#eq? @_def "defgroup") + (#match? @name.definition.group "^[^;]")) + +; Advice definitions - match defadvice specifically and avoid comments +((list + . (symbol) @_def + . (symbol) @name.definition.advice) @_advice + (#eq? @_def "defadvice") + (#match? @name.definition.advice "^[^;]")) +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/elixir.ts b/packages/kilo-indexing/src/tree-sitter/queries/elixir.ts new file mode 100644 index 00000000000..a3a8db074c6 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/elixir.ts @@ -0,0 +1,70 @@ +export default String.raw` +; Module, Protocol, and Implementation definitions +(call + target: (identifier) @function + (arguments) @args + (do_block)? + (#match? @function "^(defmodule|defprotocol|defimpl)$")) @definition.module + +; Function definitions +(call + target: (identifier) @function + (arguments) @args + (do_block)? + (#eq? @function "def")) @definition.function + +; Macro definitions +(call + target: (identifier) @function + (arguments) @args + (do_block)? + (#eq? @function "defmacro")) @definition.macro + +; Struct definitions +(call + target: (identifier) @function + (arguments (list)) + (#eq? @function "defstruct")) @definition.struct + +; Guard definitions +(call + target: (identifier) @function + (arguments) @args + (#eq? @function "defguard")) @definition.guard + +; Behaviour callback definitions +(call + target: (identifier) @function + (arguments) @args + (#eq? @function "@callback")) @definition.behaviour + +; Sigils +(sigil + (sigil_name) + (quoted_content)) @definition.sigil + +; Module attributes +(unary_operator + operator: "@" + operand: (call)) @definition.attribute + +; Test definitions with string name and map args +(call + target: (identifier) @function + (arguments + (string) + (map)) + (#eq? @function "test")) @definition.test + +; Pipeline operator usage +(binary_operator + operator: "|>" + left: (_) @left + right: (_) @right) @definition.pipeline + +; For comprehensions with generator and filter clauses +(call + target: (identifier) @function + (arguments) @args + (do_block)? + (#eq? @function "for")) @definition.for_comprehension` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/embedded_template.ts b/packages/kilo-indexing/src/tree-sitter/queries/embedded_template.ts new file mode 100644 index 00000000000..5f66ae5d5fb --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/embedded_template.ts @@ -0,0 +1,19 @@ +/* +Supported Embedded Template structures: +- Code blocks (class, module, method definitions) +- Output blocks (expressions) +- Comments +*/ +export default ` +; Code blocks - class, module, method definitions +(directive + (code) @name.definition.code) @definition.directive + +; Output blocks - expressions +(output_directive + (code) @output.content) @output + +; Comments - documentation and section markers +(comment_directive + (comment) @name.definition.comment) @definition.comment +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/go.ts b/packages/kilo-indexing/src/tree-sitter/queries/go.ts new file mode 100644 index 00000000000..3a80fdeb10a --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/go.ts @@ -0,0 +1,26 @@ +/* +Go Tree-Sitter Query Patterns +Updated to capture full declarations instead of just identifiers +*/ +export default ` +; Function declarations - capture the entire declaration +(function_declaration) @name.definition.function + +; Method declarations - capture the entire declaration +(method_declaration) @name.definition.method + +; Type declarations (interfaces, structs, type aliases) - capture the entire declaration +(type_declaration) @name.definition.type + +; Variable declarations - capture the entire declaration +(var_declaration) @name.definition.var + +; Constant declarations - capture the entire declaration +(const_declaration) @name.definition.const + +; Package clause +(package_clause) @name.definition.package + +; Import declarations - capture the entire import block +(import_declaration) @name.definition.import +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/html.ts b/packages/kilo-indexing/src/tree-sitter/queries/html.ts new file mode 100644 index 00000000000..e5f121a80fa --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/html.ts @@ -0,0 +1,51 @@ +export default ` +; Document structure +(document) @definition.document + +; Elements with content +(element + (start_tag + (tag_name) @name.definition) + (#not-eq? @name.definition "script") + (#not-eq? @name.definition "style")) @definition.element + +; Script elements +(script_element + (start_tag + (tag_name) @name.definition)) @definition.script + +; Style elements +(style_element + (start_tag + (tag_name) @name.definition)) @definition.style + +; Attributes +(attribute + (attribute_name) @name.definition) @definition.attribute + +; Comments +(comment) @definition.comment + +; Text content +(text) @definition.text + +; Raw text content +(raw_text) @definition.raw_text + +; Void elements (self-closing) +(element + (start_tag + (tag_name) @name.definition) + (#match? @name.definition "^(area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)$")) @definition.void_element + +; Self-closing tags +(self_closing_tag + (tag_name) @name.definition) @definition.self_closing_tag + +; Doctype declarations +(doctype) @definition.doctype + +; Multiple elements (parent with children) +(element + (element)+) @definition.nested_elements +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/index.ts b/packages/kilo-indexing/src/tree-sitter/queries/index.ts new file mode 100644 index 00000000000..de9b9cafa3b --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/index.ts @@ -0,0 +1,28 @@ +export { solidityQuery } from "./solidity" +export { default as phpQuery } from "./php" +export { vueQuery } from "./vue" +export { default as typescriptQuery } from "./typescript" +export { default as tsxQuery } from "./tsx" +export { default as pythonQuery } from "./python" +export { default as javascriptQuery } from "./javascript" +export { default as javaQuery } from "./java" +export { default as rustQuery } from "./rust" +export { default as rubyQuery } from "./ruby" +export { default as cppQuery } from "./cpp" +export { default as cQuery } from "./c" +export { default as csharpQuery } from "./c-sharp" +export { default as goQuery } from "./go" +export { default as swiftQuery } from "./swift" +export { default as kotlinQuery } from "./kotlin" +export { default as cssQuery } from "./css" +export { default as elixirQuery } from "./elixir" +export { default as htmlQuery } from "./html" +export { default as luaQuery } from "./lua" +export { ocamlQuery } from "./ocaml" +export { tomlQuery } from "./toml" +export { default as systemrdlQuery } from "./systemrdl" +export { default as tlaPlusQuery } from "./tlaplus" +export { zigQuery } from "./zig" +export { default as embeddedTemplateQuery } from "./embedded_template" +export { elispQuery } from "./elisp" +export { scalaQuery } from "./scala" diff --git a/packages/kilo-indexing/src/tree-sitter/queries/java.ts b/packages/kilo-indexing/src/tree-sitter/queries/java.ts new file mode 100644 index 00000000000..7d8b1112cc1 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/java.ts @@ -0,0 +1,74 @@ +/* +Query patterns for Java language structures +*/ +export default ` +; Module declarations +(module_declaration + name: (scoped_identifier) @name.definition.module) @definition.module + +; Package declarations +((package_declaration + (scoped_identifier)) @name.definition.package) @definition.package + +; Line comments +(line_comment) @definition.comment + +; Class declarations +(class_declaration + name: (identifier) @name.definition.class) @definition.class + +; Interface declarations +(interface_declaration + name: (identifier) @name.definition.interface) @definition.interface + +; Enum declarations +(enum_declaration + name: (identifier) @name.definition.enum) @definition.enum + +; Record declarations +(record_declaration + name: (identifier) @name.definition.record) @definition.record + +; Annotation declarations +(annotation_type_declaration + name: (identifier) @name.definition.annotation) @definition.annotation + +; Constructor declarations +(constructor_declaration + name: (identifier) @name.definition.constructor) @definition.constructor + +; Method declarations +(method_declaration + type: (_) @definition.method.start ; kilocode_change + name: (identifier) @name.definition.method) @definition.method + +; Inner class declarations +(class_declaration + (class_body + (class_declaration + name: (identifier) @name.definition.inner_class))) @definition.inner_class + +; Static nested class declarations +(class_declaration + (class_body + (class_declaration + name: (identifier) @name.definition.static_nested_class))) @definition.static_nested_class + +; Lambda expressions +(lambda_expression) @definition.lambda + +; Field declarations +(field_declaration + (modifiers)? + type: (_) + declarator: (variable_declarator + name: (identifier) @name.definition.field)) @definition.field + +; Import declarations +(import_declaration + (scoped_identifier) @name.definition.import) @definition.import + +; Type parameters +(type_parameters + (type_parameter) @name.definition.type_parameter) @definition.type_parameter +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/javascript.ts b/packages/kilo-indexing/src/tree-sitter/queries/javascript.ts new file mode 100644 index 00000000000..fa8c24f7780 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/javascript.ts @@ -0,0 +1,123 @@ +/* +- class definitions +- method definitions (including decorated methods) +- named function declarations +- arrow functions and function expressions assigned to variables +- JSON object and array definitions (for JSON files) +- decorators and decorated elements +*/ +export default ` +( + (comment)* @doc + . + (method_definition + name: (property_identifier) @name) @definition.method + (#not-eq? @name "constructor") + (#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$") + (#select-adjacent! @doc @definition.method) +) + +( + (comment)* @doc + . + [ + (class + name: (_) @name) + (class_declaration + name: (_) @name) + ] @definition.class + (#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$") + (#select-adjacent! @doc @definition.class) +) + +( + (comment)* @doc + . + [ + (function_declaration + name: (identifier) @name) + (generator_function_declaration + name: (identifier) @name) + ] @definition.function + (#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$") + (#select-adjacent! @doc @definition.function) +) + +( + (comment)* @doc + . + (lexical_declaration + (variable_declarator + name: (identifier) @name + value: [(arrow_function) (function_expression)]) @definition.function) + (#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$") + (#select-adjacent! @doc @definition.function) +) + +( + (comment)* @doc + . + (variable_declaration + (variable_declarator + name: (identifier) @name + value: [(arrow_function) (function_expression)]) @definition.function) + (#strip! @doc "^[\\s\\*/]+|^[\\s\\*/]$") + (#select-adjacent! @doc @definition.function) +) + +; JSON object definitions +(object) @object.definition + +; JSON object key-value pairs +(pair + key: (string) @property.name.definition + value: [ + (object) @object.value + (array) @array.value + (string) @string.value + (number) @number.value + (true) @boolean.value + (false) @boolean.value + (null) @null.value + ] +) @property.definition + +; JSON array definitions +(array) @array.definition +; Decorated method definitions +( + [ + (method_definition + decorator: (decorator) + name: (property_identifier) @name) @definition.method + (method_definition + decorator: (decorator + (call_expression + function: (identifier) @decorator_name)) + name: (property_identifier) @name) @definition.method + ] + (#not-eq? @name "constructor") +) + +; Decorated class definitions +( + [ + (class + decorator: (decorator) + name: (_) @name) @definition.class + (class_declaration + decorator: (decorator) + name: (_) @name) @definition.class + ] +) + +; Capture method names in decorated classes +( + (class_declaration + decorator: (decorator) + body: (class_body + (method_definition + name: (property_identifier) @name) @definition.method)) + (#not-eq? @name "constructor") +) +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/kotlin.ts b/packages/kilo-indexing/src/tree-sitter/queries/kotlin.ts new file mode 100644 index 00000000000..fd70f1891ed --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/kotlin.ts @@ -0,0 +1,110 @@ +/* +- class declarations (regular, data, abstract, sealed, enum, annotation) +- interface declarations +- function declarations (regular, suspend, extension) +- object declarations (including companion objects) +- property declarations and accessors +- type aliases and constructors +*/ +export default ` +; Type alias declarations +(type_alias + (type_identifier) @name.definition.type_alias +) @definition.type_alias + +; Regular class declarations +(class_declaration + (type_identifier) @name.definition.class +) @definition.class + +; Data class declarations +(class_declaration + (modifiers + (class_modifier) @_modifier (#eq? @_modifier "data")) + (type_identifier) @name.definition.data_class +) @definition.data_class + +; Abstract class declarations +(class_declaration + (modifiers + (inheritance_modifier) @_modifier (#eq? @_modifier "abstract")) + (type_identifier) @name.definition.abstract_class +) @definition.abstract_class + +; Sealed class declarations +(class_declaration + (modifiers + (class_modifier) @_modifier (#eq? @_modifier "sealed")) + (type_identifier) @name.definition.sealed_class +) @definition.sealed_class + +; Enum class declarations +(class_declaration + (type_identifier) + (enum_class_body) +) @definition.enum_class + +; Interface declarations +(class_declaration + (type_identifier) @name.definition.interface +) @definition.interface + +; Regular function declarations +(function_declaration + (simple_identifier) @name.definition.function +) @definition.function + + +; Suspend function declarations +(function_declaration + (modifiers + (function_modifier) @_modifier (#eq? @_modifier "suspend")) + (simple_identifier) @name.definition.suspend_function +) @definition.suspend_function + +; Object declarations +(object_declaration + (type_identifier) @name.definition.object +) @definition.object + +; Companion object declarations +(companion_object) @definition.companion_object + + + +; Annotation class declarations +(class_declaration + (modifiers + (class_modifier) @_modifier (#eq? @_modifier "annotation")) + (type_identifier) @name.definition.annotation_class +) @definition.annotation_class +; Extension function declarations +(function_declaration + (modifiers + (function_modifier) @_modifier (#eq? @_modifier "extension")) + (simple_identifier) @name.definition.extension_function +) @definition.extension_function + +; Primary constructor declarations +(class_declaration + (primary_constructor) @definition.primary_constructor +) + +; Secondary constructor declarations +(secondary_constructor) @definition.secondary_constructor + +; Property declarations +(property_declaration + (variable_declaration + (simple_identifier) @name.definition.property) +) @definition.property + +; Property declarations with accessors +(property_declaration + (variable_declaration + (simple_identifier) @name.definition.property) + (getter)? @definition.getter + (setter)? @definition.setter +) @definition.property_with_accessors + +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/lua.ts b/packages/kilo-indexing/src/tree-sitter/queries/lua.ts new file mode 100644 index 00000000000..99ad21b64ec --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/lua.ts @@ -0,0 +1,37 @@ +/* +Supported Lua structures: +- function definitions (global, local, and method) +- table constructors +- variable declarations +- class-like structures +*/ +export default String.raw` +; Function definitions +(function_definition_statement + name: (identifier) @name.definition.function) @definition.function + +(function_definition_statement + name: (variable + table: (identifier) + field: (identifier) @name.definition.method)) @definition.method + +(local_function_definition_statement + name: (identifier) @name.definition.function) @definition.function + +; Table constructors (class-like structures) +(local_variable_declaration + (variable_list + (variable name: (identifier) @name.definition.table)) + (expression_list + value: (table))) @definition.table + +; Variable declarations +(variable_assignment + (variable_list + (variable name: (identifier) @name.definition.variable))) @definition.variable + +; Local variable declarations +(local_variable_declaration + (variable_list + (variable name: (identifier) @name.definition.variable))) @definition.variable +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/ocaml.ts b/packages/kilo-indexing/src/tree-sitter/queries/ocaml.ts new file mode 100644 index 00000000000..050bdb83351 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/ocaml.ts @@ -0,0 +1,31 @@ +export const ocamlQuery = ` +; Captures module definitions +(module_definition + (module_binding + name: (module_name) @name.definition)) @definition.module + +; Captures type definitions +(type_definition + (type_binding + name: (type_constructor) @name.definition)) @definition.type + +; Captures function definitions +(value_definition + (let_binding + pattern: (value_name) @name.definition + (parameter))) @definition.function + +; Captures class definitions +(class_definition + (class_binding + name: (class_name) @name.definition)) @definition.class + +; Captures method definitions +(method_definition + name: (method_name) @name.definition) @definition.method + +; Captures value bindings +(value_definition + (let_binding + pattern: (value_name) @name.definition)) @definition.value +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/php.ts b/packages/kilo-indexing/src/tree-sitter/queries/php.ts new file mode 100644 index 00000000000..3771428e649 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/php.ts @@ -0,0 +1,172 @@ +/* +PHP Tree-sitter Query - Standardized Version + +This query file captures PHP language constructs for code navigation and analysis. +Each query pattern is organized by construct type and includes clear comments. + +SUPPORTED LANGUAGE CONSTRUCTS: +------------------------------ +1. CLASS DEFINITIONS + - Regular classes + - Abstract classes + - Final classes + - Readonly classes (PHP 8.2+) + +2. INTERFACE & TRAIT DEFINITIONS + - Interfaces + - Traits + - Enums (PHP 8.1+) + +3. FUNCTION & METHOD DEFINITIONS + - Global functions + - Class methods + - Static methods + - Abstract methods + - Final methods + - Arrow functions (PHP 7.4+) + +4. PROPERTY DEFINITIONS + - Regular properties + - Static properties + - Readonly properties (PHP 8.1+) + - Constructor property promotion (PHP 8.0+) + +5. OTHER LANGUAGE CONSTRUCTS + - Constants + - Namespaces + - Use statements (imports) + - Anonymous classes + - Attributes (PHP 8.0+) + - Match expressions (PHP 8.0+) + - Heredoc and nowdoc syntax +*/ +export default ` +;-------------------------- +; 1. CLASS DEFINITIONS +;-------------------------- +; Regular classes +(class_declaration + name: (name) @name.definition.class) @definition.class + +; Abstract classes +(class_declaration + (abstract_modifier) + name: (name) @name.definition.abstract_class) @definition.abstract_class + +; Final classes +(class_declaration + (final_modifier) + name: (name) @name.definition.final_class) @definition.final_class + +; Readonly classes (PHP 8.2+) +(class_declaration + (readonly_modifier) + name: (name) @name.definition.readonly_class) @definition.readonly_class + +;-------------------------- +; 2. INTERFACE & TRAIT DEFINITIONS +;-------------------------- +; Interfaces +(interface_declaration + name: (name) @name.definition.interface) @definition.interface + +; Traits +(trait_declaration + name: (name) @name.definition.trait) @definition.trait + +; Enums (PHP 8.1+) +(enum_declaration + name: (name) @name.definition.enum) @definition.enum + +;-------------------------- +; 3. FUNCTION & METHOD DEFINITIONS +;-------------------------- +; Global functions +(function_definition + name: (name) @name.definition.function) @definition.function + +; Regular methods +(method_declaration + name: (name) @name.definition.method) @definition.method + +; Static methods +(method_declaration + (static_modifier) + name: (name) @name.definition.static_method) @definition.static_method + +; Abstract methods +(method_declaration + (abstract_modifier) + name: (name) @name.definition.abstract_method) @definition.abstract_method + +; Final methods +(method_declaration + (final_modifier) + name: (name) @name.definition.final_method) @definition.final_method + +; Arrow functions (PHP 7.4+) +(arrow_function) @definition.arrow_function + +;-------------------------- +; 4. PROPERTY DEFINITIONS +;-------------------------- +; Regular properties +(property_declaration + (property_element + (variable_name + (name) @name.definition.property))) @definition.property + +; Static properties +(property_declaration + (static_modifier) + (property_element + (variable_name + (name) @name.definition.static_property))) @definition.static_property + +; Readonly properties (PHP 8.1+) +(property_declaration + (readonly_modifier) + (property_element + (variable_name + (name) @name.definition.readonly_property))) @definition.readonly_property + +; Constructor property promotion (PHP 8.0+) +(property_promotion_parameter + name: (variable_name + (name) @name.definition.promoted_property)) @definition.promoted_property + +;-------------------------- +; 5. OTHER LANGUAGE CONSTRUCTS +;-------------------------- +; Constants +(const_declaration + (const_element + (name) @name.definition.constant)) @definition.constant + +; Namespaces +(namespace_definition + name: (namespace_name) @name.definition.namespace) @definition.namespace + +; Use statements (imports) +(namespace_use_declaration + (namespace_use_clause + (qualified_name) @name.definition.use)) @definition.use + +; Anonymous classes +(object_creation_expression + (declaration_list)) @definition.anonymous_class + +; Attributes (PHP 8.0+) +(attribute_group + (attribute + (name) @name.definition.attribute)) @definition.attribute + +; Match expressions (PHP 8.0+) +(match_expression) @definition.match_expression + +; Heredoc syntax +(heredoc) @definition.heredoc + +; Nowdoc syntax +(nowdoc) @definition.nowdoc +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/python.ts b/packages/kilo-indexing/src/tree-sitter/queries/python.ts new file mode 100644 index 00000000000..7bf0f0874bc --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/python.ts @@ -0,0 +1,73 @@ +/* +Python Tree-sitter Query Patterns +*/ +export default ` +; Class definitions (including decorated) +(class_definition + name: (identifier) @name.definition.class) @definition.class + +(decorated_definition + definition: (class_definition + name: (identifier) @name.definition.class)) @definition.class + +; Function and method definitions (including async and decorated) +(function_definition + name: (identifier) @name.definition.function) @definition.function + +(decorated_definition + definition: (function_definition + name: (identifier) @name.definition.function)) @definition.function + +; Lambda expressions +(expression_statement + (assignment + left: (identifier) @name.definition.lambda + right: (parenthesized_expression + (lambda)))) @definition.lambda + +; Generator functions (functions containing yield) +(function_definition + name: (identifier) @name.definition.generator + body: (block + (expression_statement + (yield)))) @definition.generator + +; Comprehensions +(expression_statement + (assignment + left: (identifier) @name.definition.comprehension + right: [ + (list_comprehension) + (dictionary_comprehension) + (set_comprehension) + ])) @definition.comprehension + +; With statements +(with_statement) @definition.with_statement + +; Try statements +(try_statement) @definition.try_statement + +; Import statements +(import_from_statement) @definition.import +(import_statement) @definition.import + +; Global/Nonlocal statements +(function_definition + body: (block + [(global_statement) (nonlocal_statement)])) @definition.scope + +; Match case statements +(function_definition + body: (block + (match_statement))) @definition.match_case + +; Type annotations +(typed_parameter + type: (type)) @definition.type_annotation + +(expression_statement + (assignment + left: (identifier) @name.definition.type + type: (type))) @definition.type_annotation +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/ruby.ts b/packages/kilo-indexing/src/tree-sitter/queries/ruby.ts new file mode 100644 index 00000000000..bb322f7ac4b --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/ruby.ts @@ -0,0 +1,204 @@ +/* +- method definitions (including singleton methods and aliases, with associated comments) +- class definitions (including singleton classes, with associated comments) +- module definitions +- constants +- global variables +- instance variables +- class variables +- symbols +- blocks, procs, and lambdas +- mixins (include, extend, prepend) +- metaprogramming constructs (define_method, method_missing) +- attribute accessors (attr_reader, attr_writer, attr_accessor) +- class macros (has_many, belongs_to, etc. in Rails-like code) +- exception handling (begin/rescue/ensure) +- keyword arguments +- splat operators +- hash rocket and JSON-style hashes +- string interpolation +- regular expressions +- Ruby 2.7+ pattern matching +- Ruby 3.0+ endless methods +- Ruby 3.1+ pin operator and shorthand hash syntax +*/ +export default ` +; Method definitions +(method + name: (identifier) @name.definition.method) @definition.method + +; Singleton methods +(singleton_method + object: (_) + name: (identifier) @name.definition.method) @definition.method + +; Method aliases +(alias + name: (_) @name.definition.method) @definition.method + +; Class definitions +(class + name: [ + (constant) @name.definition.class + (scope_resolution + name: (_) @name.definition.class) + ]) @definition.class + +; Singleton classes +(singleton_class + value: [ + (constant) @name.definition.class + (scope_resolution + name: (_) @name.definition.class) + ]) @definition.class + +; Module definitions +(module + name: [ + (constant) @name.definition.module + (scope_resolution + name: (_) @name.definition.module) + ]) @definition.module + +; Constants +(assignment + left: (constant) @name.definition.constant) @definition.constant + +; Global variables +(global_variable) @definition.global_variable + +; Instance variables +(instance_variable) @definition.instance_variable + +; Class variables +(class_variable) @definition.class_variable + +; Symbols +(simple_symbol) @definition.symbol +(hash_key_symbol) @definition.symbol + +; Blocks +(block) @definition.block +(do_block) @definition.block + +; Basic mixin statements - capture all include/extend/prepend calls +(call + method: (identifier) @_mixin_method + arguments: (argument_list + (constant) @name.definition.mixin) + (#match? @_mixin_method "^(include|extend|prepend)$")) @definition.mixin + +; Mixin module definition +(module + name: (constant) @name.definition.mixin_module + (#match? @name.definition.mixin_module ".*Module$")) @definition.mixin_module + +; Mixin-related methods +(method + name: (identifier) @name.definition.mixin_method + (#match? @name.definition.mixin_method "(included|extended|prepended)_method")) @definition.mixin_method + +; Singleton class blocks +(singleton_class) @definition.singleton_class + +; Class methods in singleton context +(singleton_method + object: (self) + name: (identifier) @name.definition.singleton_method) @definition.singleton_method + +; Attribute accessors +(call + method: (identifier) @_attr_accessor + arguments: (argument_list + (_) @name.definition.attr_accessor) + (#eq? @_attr_accessor "attr_accessor")) @definition.attr_accessor + +(call + method: (identifier) @_attr_reader + arguments: (argument_list + (_) @name.definition.attr_reader) + (#eq? @_attr_reader "attr_reader")) @definition.attr_reader + +(call + method: (identifier) @_attr_writer + arguments: (argument_list + (_) @name.definition.attr_writer) + (#eq? @_attr_writer "attr_writer")) @definition.attr_writer + +; Class macros (Rails-like) +(call + method: (identifier) @_macro_name + arguments: (argument_list + (_) @name.definition.class_macro) + (#match? @_macro_name "^(has_many|belongs_to|has_one|validates|scope|before_action|after_action)$")) @definition.class_macro + +; Exception handling +(begin) @definition.begin +(rescue) @definition.rescue +(ensure) @definition.ensure + +; Keyword arguments +(keyword_parameter + name: (identifier) @name.definition.keyword_parameter) @definition.keyword_parameter + +; Splat operators +(splat_parameter) @definition.splat_parameter +(splat_argument) @definition.splat_argument + +; Hash syntax variants +(pair + key: (_) @name.definition.hash_key) @definition.hash_pair + +; String interpolation - capture the string with interpolation and surrounding context +(assignment + left: (identifier) @name.definition.string_var + right: (string + (interpolation))) @definition.string_interpolation + +; Regular expressions - capture the regex pattern and assignment +(assignment + left: (identifier) @name.definition.regex_var + right: (regex)) @definition.regex_assignment + +; Pattern matching - capture the entire case_match structure +(case_match) @definition.case_match + +; Pattern matching - capture in_clause with hash pattern +(in_clause + pattern: (hash_pattern)) @definition.hash_pattern_clause + +; Endless methods - capture the method definition with name and surrounding context +(comment) @_endless_method_comment +(#match? @_endless_method_comment "Ruby 3.0\\+ endless method") +(method + name: (identifier) @name.definition.endless_method + body: (binary + operator: "=")) @definition.endless_method + +; Pin operator - capture the entire in_clause with variable_reference_pattern +(in_clause + pattern: (variable_reference_pattern)) @definition.pin_pattern_clause + +; Shorthand hash syntax - capture the method containing shorthand hash +(comment) @_shorthand_hash_comment +(#match? @_shorthand_hash_comment "Ruby 3.1\\+ shorthand hash syntax") +(method + name: (identifier) @name.definition.shorthand_method) @definition.shorthand_method + +; Shorthand hash syntax - capture the hash with shorthand syntax +(hash + (pair + (hash_key_symbol) + ":")) @definition.shorthand_hash + +; Capture larger contexts for features that need at least 4 lines + +; Capture the entire program to include all comments and code +(program) @definition.program + +; Capture all comments +(comment) @definition.comment + +; Capture all method definitions +(method) @definition.method_all +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/rust.ts b/packages/kilo-indexing/src/tree-sitter/queries/rust.ts new file mode 100644 index 00000000000..fc1b4ec261e --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/rust.ts @@ -0,0 +1,80 @@ +/* +Rust language structures for tree-sitter parsing +Captures all required constructs for tests +*/ +export default ` +; Function definitions (all types) +(function_item + name: (identifier) @name.definition.function) @definition.function + +; Struct definitions (all types - standard, tuple, unit) +(struct_item + name: (type_identifier) @name.definition.struct) @definition.struct + +; Enum definitions with variants +(enum_item + name: (type_identifier) @name.definition.enum) @definition.enum + +; Trait definitions +(trait_item + name: (type_identifier) @name.definition.trait) @definition.trait + +; Impl blocks (inherent implementation) +(impl_item + type: (type_identifier) @name.definition.impl) @definition.impl + +; Trait implementations +(impl_item + trait: (type_identifier) @name.definition.impl_trait + type: (type_identifier) @name.definition.impl_for) @definition.impl_trait + +; Module definitions +(mod_item + name: (identifier) @name.definition.module) @definition.module + +; Macro definitions +(macro_definition + name: (identifier) @name.definition.macro) @definition.macro + +; Attribute macros (for #[derive(...)] etc.) +(attribute_item + (attribute) @name.definition.attribute) @definition.attribute + +; Type aliases +(type_item + name: (type_identifier) @name.definition.type_alias) @definition.type_alias + +; Constants +(const_item + name: (identifier) @name.definition.constant) @definition.constant + +; Static items +(static_item + name: (identifier) @name.definition.static) @definition.static + +; Methods inside impl blocks +(impl_item + body: (declaration_list + (function_item + name: (identifier) @name.definition.method))) @definition.method_container + +; Use declarations +(use_declaration) @definition.use_declaration + +; Lifetime definitions +(lifetime + "'" @punctuation.lifetime + (identifier) @name.definition.lifetime) @definition.lifetime + +; Where clauses +(where_clause + (where_predicate)*) @definition.where_clause + +; Match expressions +(match_expression + value: (_) @match.value + body: (match_block)) @definition.match + +; Unsafe blocks +(unsafe_block) @definition.unsafe_block +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/scala.ts b/packages/kilo-indexing/src/tree-sitter/queries/scala.ts new file mode 100644 index 00000000000..d56d8c88320 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/scala.ts @@ -0,0 +1,44 @@ +export const scalaQuery = ` +; Classes +(class_definition + name: (identifier) @name.definition) @definition.class + +(class_definition + (modifiers) + name: (identifier) @name.definition) @definition.class + +; Objects +(object_definition + name: (identifier) @name.definition) @definition.object + +(object_definition + name: (identifier) @name.definition + extend: (extends_clause)?) @definition.object + +; Traits +(trait_definition + name: (identifier) @name.definition) @definition.trait + +; Methods +(function_definition + name: (identifier) @name.definition) @definition.method + +; Values and Variables +(val_definition + pattern: (identifier) @name.definition) @definition.variable + +(var_definition + pattern: (identifier) @name.definition) @definition.variable + +(val_definition + (modifiers) + pattern: (identifier) @name.definition) @definition.variable + +; Types +(type_definition + name: (type_identifier) @name.definition) @definition.type + +; Package declarations +(package_clause + name: (package_identifier) @name.definition) @definition.namespace +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/solidity.ts b/packages/kilo-indexing/src/tree-sitter/queries/solidity.ts new file mode 100644 index 00000000000..61a632850e3 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/solidity.ts @@ -0,0 +1,44 @@ +export const solidityQuery = ` +; Contract declarations +(contract_declaration + name: (identifier) @name.definition.contract) @definition.contract + +(interface_declaration + name: (identifier) @name.definition.interface) @definition.interface + +(library_declaration + name: (identifier) @name.definition.library) @definition.library + +; Function declarations +(function_definition + name: (identifier) @name.definition.function) @definition.function + +(modifier_definition + name: (identifier) @name.definition.modifier) @definition.modifier + +(constructor_definition) @definition.constructor + +(fallback_receive_definition + (visibility) + (state_mutability)) @definition.fallback + +; Type declarations +(struct_declaration + name: (identifier) @name.definition.struct) @definition.struct + +(enum_declaration + name: (identifier) @name.definition.enum) @definition.enum + +(event_definition + name: (identifier) @name.definition.event) @definition.event + +(error_declaration + name: (identifier) @name.definition.error) @definition.error + +; Variable declarations +(state_variable_declaration + name: (identifier) @name.definition.variable) @definition.variable + +; Using directives +(using_directive + (type_alias) @name.definition.using) @definition.using` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/swift.ts b/packages/kilo-indexing/src/tree-sitter/queries/swift.ts new file mode 100644 index 00000000000..2c1a1e14b24 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/swift.ts @@ -0,0 +1,74 @@ +/* +Swift Tree-Sitter Query Patterns + +This file contains query patterns for Swift language constructs: +- class declarations - Captures standard, final, and open class definitions +- struct declarations - Captures standard and generic struct definitions +- protocol declarations - Captures protocol definitions with requirements +- extension declarations - Captures extensions for classes, structs, and protocols +- method declarations - Captures instance and type methods +- property declarations - Captures stored and computed properties +- initializer declarations - Captures designated and convenience initializers +- deinitializer declarations - Captures deinit methods +- subscript declarations - Captures subscript methods +- type alias declarations - Captures type alias definitions + +Each query pattern is mapped to a specific test in parseSourceCodeDefinitions.swift.test.ts +*/ +export default ` +; Class declarations - captures standard, final, and open classes +(class_declaration + name: (type_identifier) @name) @definition.class + +; Protocol declarations - captures protocols with requirements +(protocol_declaration + name: (type_identifier) @name) @definition.interface + +; Method declarations in classes/structs/enums/extensions +(function_declaration + name: (simple_identifier) @name) @definition.method + +; Static/class method declarations +(function_declaration + (modifiers + (property_modifier)) + name: (simple_identifier) @name) @definition.static_method + +; Initializers - captures designated initializers +(init_declaration + "init" @name) @definition.initializer + +; Convenience initializers +(init_declaration + (modifiers (member_modifier)) + "init" @name) @definition.convenience_initializer + +; Deinitializers +(deinit_declaration + "deinit" @name) @definition.deinitializer + +; Subscript declarations +(subscript_declaration + (parameter) @name) @definition.subscript + +; Property declarations - captures stored properties +(property_declaration + (pattern) @name) @definition.property + +; Computed property declarations with accessors +(property_declaration + (pattern) + (computed_property)) @definition.computed_property + +; Type aliases +(typealias_declaration + name: (type_identifier) @name) @definition.type_alias + +; Protocol property requirements +(protocol_property_declaration + name: (pattern) @name) @definition.protocol_property + +; Protocol method requirements +(protocol_function_declaration + name: (simple_identifier) @name) @definition.protocol_method +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/systemrdl.ts b/packages/kilo-indexing/src/tree-sitter/queries/systemrdl.ts new file mode 100644 index 00000000000..bb49254eb3d --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/systemrdl.ts @@ -0,0 +1,33 @@ +/* +Supported SystemRDL structures: +- component declarations +- field declarations +- property assignments +- parameter declarations +- enum declarations +*/ +export default ` +; Component declarations +(component_named_def + type: (component_type) + id: (id) @name.definition.component) @definition.component + +; Field declarations +(component_anon_def + type: (component_type (component_primary_type)) + body: (component_body + (component_body_elem + (property_assignment)))) @definition.field + +; Property declarations +(property_definition + (id) @name.definition.property) @definition.property + +; Parameter declarations +(component_inst + id: (id) @name.definition.parameter) @definition.parameter + +; Enum declarations +(enum_def + (id) @name.definition.enum) @definition.enum +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/tlaplus.ts b/packages/kilo-indexing/src/tree-sitter/queries/tlaplus.ts new file mode 100644 index 00000000000..70a4f44156b --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/tlaplus.ts @@ -0,0 +1,32 @@ +/* +Supported TLA+ structures: +- modules with header, extends, constants, variables +- operator definitions with parameters and bodies +- function definitions with quantifier bounds +- let expressions with operator definitions +- case expressions with multiple arms +- variable and constant declarations +*/ +export default ` +; Module declarations +(module + name: (identifier) @name.definition.module) @definition.module + +; Operator definitions with optional parameters +(operator_definition + name: (identifier) @name.definition.operator + parameter: (identifier)?) @definition.operator + +; Function definitions with bounds +(function_definition + name: (identifier) @name.definition.function + (quantifier_bound)?) @definition.function + +; Variable declarations +(variable_declaration + (identifier) @name.definition.variable) @definition.variable + +; Constant declarations +(constant_declaration + (identifier) @name.definition.constant) @definition.constant +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/toml.ts b/packages/kilo-indexing/src/tree-sitter/queries/toml.ts new file mode 100644 index 00000000000..6f582b117e7 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/toml.ts @@ -0,0 +1,24 @@ +// Query patterns for TOML syntax elements +export const tomlQuery = ` +; Tables - capture the entire table node +(table) @definition + +; Array tables - capture the entire array table node +(table_array_element) @definition + +; Key-value pairs - capture the entire pair +(pair) @definition + +; Arrays and inline tables +(array) @definition +(inline_table) @definition + +; Basic values +(string) @definition +(integer) @definition +(float) @definition +(boolean) @definition +(offset_date_time) @definition +(local_date) @definition +(local_time) @definition +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/tsx.ts b/packages/kilo-indexing/src/tree-sitter/queries/tsx.ts new file mode 100644 index 00000000000..2c60a9c88fb --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/tsx.ts @@ -0,0 +1,87 @@ +import typescriptQuery from "./typescript" + +/** + * Tree-sitter Query for TSX Files + * + * This query captures React component definitions in TSX files: + * - Function Components + * - Class Components + * - Higher Order Components + * - Type Definitions + * - Props Interfaces + * - State Definitions + * - Generic Components + */ + +export default `${typescriptQuery} + +; Function Components - Both function declarations and arrow functions +(function_declaration + name: (identifier) @name) @definition.component + +; Arrow Function Components +(variable_declaration + (variable_declarator + name: (identifier) @name + value: (arrow_function))) @definition.component + +; Export Statement Components +(export_statement + (variable_declaration + (variable_declarator + name: (identifier) @name + value: (arrow_function)))) @definition.component + +; Class Components +(class_declaration + name: (type_identifier) @name) @definition.class_component + +; Interface Declarations +(interface_declaration + name: (type_identifier) @name) @definition.interface + +; Type Alias Declarations +(type_alias_declaration + name: (type_identifier) @name) @definition.type + +; HOC Components +(variable_declaration + (variable_declarator + name: (identifier) @name + value: (call_expression + function: (identifier)))) @definition.component + +; JSX Component Usage - Capture all components in JSX +(jsx_element + open_tag: (jsx_opening_element + name: [(identifier) @component (member_expression) @component])) @definition.jsx_element + +; Self-closing JSX elements +(jsx_self_closing_element + name: [(identifier) @component (member_expression) @component]) @definition.jsx_self_closing_element + +; Capture all identifiers in JSX expressions that start with capital letters +(jsx_expression + (identifier) @jsx_component) @definition.jsx_component + +; Capture all member expressions in JSX +(member_expression + object: (identifier) @object + property: (property_identifier) @property) @definition.member_component + +; Capture components in conditional expressions +(ternary_expression + consequence: (parenthesized_expression + (jsx_element + open_tag: (jsx_opening_element + name: (identifier) @component)))) @definition.conditional_component + +(ternary_expression + alternative: (jsx_self_closing_element + name: (identifier) @component)) @definition.conditional_component + +; Generic Components +(function_declaration + name: (identifier) @name + type_parameters: (type_parameters)) @definition.generic_component +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/typescript.ts b/packages/kilo-indexing/src/tree-sitter/queries/typescript.ts new file mode 100644 index 00000000000..8373b7a047f --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/typescript.ts @@ -0,0 +1,123 @@ +/* +- function signatures and declarations +- method signatures and definitions +- abstract method signatures +- class declarations (including abstract classes) +- module declarations +- arrow functions (lambda functions) +- switch/case statements with complex case blocks +- enum declarations with members +- namespace declarations +- utility types +- class members and properties +- constructor methods +- getter/setter methods +- async functions and arrow functions +*/ +export default ` +(function_signature + name: (identifier) @name.definition.function) @definition.function + +(method_signature + name: (property_identifier) @name.definition.method) @definition.method + +(abstract_method_signature + name: (property_identifier) @name.definition.method) @definition.method + +(abstract_class_declaration + name: (type_identifier) @name.definition.class) @definition.class + +(module + name: (identifier) @name.definition.module) @definition.module + +(function_declaration + name: (identifier) @name.definition.function) @definition.function + +(method_definition + name: (property_identifier) @name.definition.method) @definition.method + +(class_declaration + name: (type_identifier) @name.definition.class) @definition.class + +(call_expression + function: (identifier) @func_name + arguments: (arguments + (string) @name + [(arrow_function) (function_expression)]) @definition.test) + (#match? @func_name "^(describe|test|it)$") + +(assignment_expression + left: (member_expression + object: (identifier) @obj + property: (property_identifier) @prop) + right: [(arrow_function) (function_expression)]) @definition.test + (#eq? @obj "exports") + (#eq? @prop "test") +(arrow_function) @definition.lambda + +; Switch statements and case clauses +(switch_statement) @definition.switch + +; Individual case clauses with their blocks +(switch_case) @definition.case + +; Default clause +(switch_default) @definition.default + +; Enum declarations +(enum_declaration + name: (identifier) @name.definition.enum) @definition.enum + +; Decorator definitions with decorated class +(export_statement + decorator: (decorator + (call_expression + function: (identifier) @name.definition.decorator)) + declaration: (class_declaration + name: (type_identifier) @name.definition.decorated_class)) @definition.decorated_class + +; Explicitly capture class name in decorated class +(class_declaration + name: (type_identifier) @name.definition.class) @definition.class + +; Namespace declarations +(internal_module + name: (identifier) @name.definition.namespace) @definition.namespace + +; Interface declarations with generic type parameters and constraints +(interface_declaration + name: (type_identifier) @name.definition.interface + type_parameters: (type_parameters)?) @definition.interface + +; Type alias declarations with generic type parameters and constraints +(type_alias_declaration + name: (type_identifier) @name.definition.type + type_parameters: (type_parameters)?) @definition.type + +; Utility Types +(type_alias_declaration + name: (type_identifier) @name.definition.utility_type) @definition.utility_type + +; Class Members and Properties +(public_field_definition + name: (property_identifier) @name.definition.property) @definition.property + +; Constructor +(method_definition + name: (property_identifier) @name.definition.constructor + (#eq? @name.definition.constructor "constructor")) @definition.constructor + +; Getter/Setter Methods +(method_definition + name: (property_identifier) @name.definition.accessor) @definition.accessor + +; Async Functions +(function_declaration + name: (identifier) @name.definition.async_function) @definition.async_function + +; Async Arrow Functions +(variable_declaration + (variable_declarator + name: (identifier) @name.definition.async_arrow + value: (arrow_function))) @definition.async_arrow +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/vue.ts b/packages/kilo-indexing/src/tree-sitter/queries/vue.ts new file mode 100644 index 00000000000..1e0f8b0551e --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/vue.ts @@ -0,0 +1,29 @@ +export const vueQuery = ` +; Top-level structure +(component) @component.definition + +; Template section +(template_element) @template.definition +(template_element + (element + (start_tag + (tag_name) @element.name.definition)) + (element + (start_tag + (attribute + (attribute_name) @attribute.name.definition))) + (element + (start_tag + (directive_attribute + (directive_name) @directive.name.definition)))) + +; Script section +(script_element) @script.definition +(script_element + (raw_text) @script.content.definition) + +; Style section +(style_element) @style.definition +(style_element + (raw_text) @style.content.definition) +` diff --git a/packages/kilo-indexing/src/tree-sitter/queries/zig.ts b/packages/kilo-indexing/src/tree-sitter/queries/zig.ts new file mode 100644 index 00000000000..30f40f1ec74 --- /dev/null +++ b/packages/kilo-indexing/src/tree-sitter/queries/zig.ts @@ -0,0 +1,21 @@ +export const zigQuery = ` +; Functions +(function_declaration) @function.definition + +; Structs and containers +(variable_declaration + (identifier) @name + (struct_declaration) +) @container.definition + +; Enums +(variable_declaration + (identifier) @name + (enum_declaration) +) @container.definition + +; Variables and constants +(variable_declaration + (identifier) @name +) @variable.definition +` diff --git a/packages/kilo-indexing/src/util/log.ts b/packages/kilo-indexing/src/util/log.ts new file mode 100644 index 00000000000..b9b30998f65 --- /dev/null +++ b/packages/kilo-indexing/src/util/log.ts @@ -0,0 +1,74 @@ +type Entry = { + debug(message?: unknown, extra?: Record): void + info(message?: unknown, extra?: Record): void + warn(message?: unknown, extra?: Record): void + error(message?: unknown, extra?: Record): void + tag(key: string, value: string): Entry + clone(): Entry + time( + message: string, + extra?: Record, + ): { + stop(): void + [Symbol.dispose](): void + } +} + +const enabled = process.env.KILO_INDEXING_LOG === "1" || process.env.KILO_INDEXING_LOG === "true" + +export namespace Log { + export type Logger = Entry + + export function create(input: Record = {}): Entry { + const tags = { ...input } + + function write(level: string, message?: unknown, extra?: Record) { + if (!enabled) return + + const line = JSON.stringify({ + level, + time: new Date().toISOString(), + message, + ...tags, + ...extra, + }) + console.error(line) + } + + const log: Entry = { + debug(message, extra) { + write("DEBUG", message, extra) + }, + info(message, extra) { + write("INFO", message, extra) + }, + warn(message, extra) { + write("WARN", message, extra) + }, + error(message, extra) { + write("ERROR", message, extra) + }, + tag(key, value) { + tags[key] = value + return log + }, + clone() { + return create(tags) + }, + time(message, extra) { + const start = Date.now() + const stop = () => { + write("INFO", message, { duration: Date.now() - start, ...extra }) + } + return { + stop, + [Symbol.dispose]() { + stop() + }, + } + }, + } + + return log + } +} diff --git a/packages/kilo-indexing/test/kilocode/indexing/config-manager.test.ts b/packages/kilo-indexing/test/kilocode/indexing/config-manager.test.ts new file mode 100644 index 00000000000..1656cf75b1c --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/config-manager.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test" +import { CodeIndexConfigManager, type IndexingConfigInput } from "../../../src/indexing/config-manager" + +function createInput(input: Partial = {}): IndexingConfigInput { + return { + enabled: true, + embedderProvider: "openai", + vectorStoreProvider: "lancedb", + openAiKey: "sk-test", + ...input, + } +} + +describe("CodeIndexConfigManager", () => { + test("uses default ollama base URL when omitted", () => { + const cfg = new CodeIndexConfigManager( + createInput({ + embedderProvider: "ollama", + openAiKey: undefined, + ollamaBaseUrl: undefined, + }), + ) + + expect(cfg.isFeatureConfigured).toBe(true) + expect(cfg.getConfig().ollamaOptions?.baseUrl).toBe("http://localhost:11434") + }) + + test("defaults vector store to qdrant when omitted", () => { + const cfg = new CodeIndexConfigManager(createInput({ vectorStoreProvider: undefined })) + + expect(cfg.getConfig().vectorStoreProvider).toBe("qdrant") + }) + + describe("loadConfiguration restart checks", () => { + test("requires restart when model changes with same dimension", () => { + const cfg = new CodeIndexConfigManager(createInput({ modelId: "text-embedding-3-small" })) + + const result = cfg.loadConfiguration(createInput({ modelId: "text-embedding-ada-002" })) + + expect(result.requiresRestart).toBe(true) + }) + + test("does not restart when default model is made explicit", () => { + const cfg = new CodeIndexConfigManager(createInput()) + + const result = cfg.loadConfiguration(createInput({ modelId: "text-embedding-3-small" })) + + expect(result.requiresRestart).toBe(false) + }) + + test("requires restart when provider changes with same dimension", () => { + const cfg = new CodeIndexConfigManager(createInput({ modelId: "text-embedding-3-small" })) + + const result = cfg.loadConfiguration( + createInput({ + embedderProvider: "vercel-ai-gateway", + vercelAiGatewayApiKey: "kg-test", + openAiKey: undefined, + modelId: "text-embedding-3-small", + }), + ) + + expect(result.requiresRestart).toBe(true) + }) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/detect.test.ts b/packages/kilo-indexing/test/kilocode/indexing/detect.test.ts new file mode 100644 index 00000000000..fed079a5132 --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/detect.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from "bun:test" +import { mkdtemp } from "node:fs/promises" +import { tmpdir } from "node:os" +import { hasIndexingPlugin, isIndexingPlugin, normalizePluginName } from "../../../src/detect" + +describe("indexing plugin detection", () => { + test("bundles detect module for browser targets", async () => { + const dir = await mkdtemp(`${tmpdir()}/kilo-indexing-detect-`) + const result = await Bun.build({ + entrypoints: [new URL("../../../src/detect.ts", import.meta.url).pathname], + minify: true, + outdir: dir, + target: "browser", + }) + + expect(result.success).toBe(true) + }) + + test("normalizes supported plugin forms", () => { + expect(normalizePluginName("kilo-indexing")).toBe("kilo-indexing") + expect(normalizePluginName("kilo-indexing@1.2.3")).toBe("kilo-indexing") + expect(normalizePluginName("@kilocode/kilo-indexing")).toBe("@kilocode/kilo-indexing") + expect(normalizePluginName("@kilocode/kilo-indexing@1.2.3")).toBe("@kilocode/kilo-indexing") + expect(normalizePluginName("../../packages/kilo-indexing")).toBe("@kilocode/kilo-indexing") + expect(normalizePluginName("file:///tmp/.opencode/plugin/kilo-indexing.js")).toBe("kilo-indexing") + expect(normalizePluginName("file:///tmp/node_modules/@kilocode/kilo-indexing/index.js")).toBe( + "@kilocode/kilo-indexing", + ) + expect(normalizePluginName("file:///tmp/repo/packages/kilo-indexing/src/index.ts")).toBe("@kilocode/kilo-indexing") + }) + + test("detects supported indexing plugin specifiers", () => { + const values = [ + "kilo-indexing", + "kilo-indexing@1.2.3", + "@kilocode/kilo-indexing", + "@kilocode/kilo-indexing@1.2.3", + "../../packages/kilo-indexing", + "file:///tmp/.opencode/plugin/kilo-indexing.js", + "file:///tmp/node_modules/@kilocode/kilo-indexing/index.js", + "file:///tmp/repo/packages/kilo-indexing/src/index.ts", + ] + + for (const value of values) { + expect(isIndexingPlugin(value)).toBe(true) + } + }) + + test("ignores unrelated plugin specifiers", () => { + expect(isIndexingPlugin("@kilocode/kilo-gateway")).toBe(false) + expect(isIndexingPlugin("file:///tmp/.opencode/plugin/index.js")).toBe(false) + expect(hasIndexingPlugin(["@kilocode/kilo-gateway", "foo@1.0.0"])).toBe(false) + }) + + test("detects indexing plugin in merged plugin lists", () => { + expect( + hasIndexingPlugin(["@kilocode/kilo-gateway", "file:///tmp/node_modules/@kilocode/kilo-indexing/index.js"]), + ).toBe(true) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/__helpers__/openai-mock.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/__helpers__/openai-mock.ts new file mode 100644 index 00000000000..9eae9dc499a --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/__helpers__/openai-mock.ts @@ -0,0 +1,33 @@ +// Shared mock state for the "openai" module used across multiple embedder test files. +// RATIONALE: mock.module() is process-wide in Bun - multiple test files calling +// mock.module("openai", ...) with separate mock functions causes cross-test interference. +// By sharing the mock function, whichever file's mock.module call wins, all tests +// still reference the same mockEmbeddingsCreate instance. +// +// Each test file must still call mock.module("openai", openAIMockFactory) directly +// because Bun only processes mock.module calls in the test file itself (not in imports). + +import { mock } from "bun:test" + +export const mockEmbeddingsCreate = mock() + +let _constructorHook: ((config: any) => void) | undefined + +export function setOpenAIConstructorHook(hook: ((config: any) => void) | undefined) { + _constructorHook = hook +} + +export function openAIMockFactory() { + return { + OpenAI: class { + config: any + embeddings = { create: mockEmbeddingsCreate } + constructor(config: any) { + this.config = config + if (_constructorHook) { + _constructorHook(config) + } + } + }, + } +} diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/bedrock.test.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/bedrock.test.ts new file mode 100644 index 00000000000..3fd601c09c8 --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/bedrock.test.ts @@ -0,0 +1,579 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" + +// Set up AWS SDK mocks BEFORE importing modules that use them +const mockSend = mock() +mock.module("@aws-sdk/client-bedrock-runtime", () => ({ + BedrockRuntimeClient: class { + send = mockSend + }, + InvokeModelCommand: class { + constructor(public input: any) {} + }, +})) +const mockFromEnv = mock(() => Promise.resolve({})) +const mockFromIni = mock(() => Promise.resolve({})) +mock.module("@aws-sdk/credential-provider-ini", () => ({ + fromIni: mockFromIni, +})) + +// Now import the module under test +import { BedrockEmbedder } from "../../../../src/indexing/embedders/bedrock" +import { MAX_ITEM_TOKENS } from "../../../../src/indexing/constants" + +describe("BedrockEmbedder", () => { + let embedder: BedrockEmbedder + + beforeEach(() => { + mockSend.mockReset() + embedder = new BedrockEmbedder("us-east-1", "test-profile", "amazon.titan-embed-text-v2:0") + }) + + describe("constructor", () => { + test("should initialize with provided region, profile and model", () => { + expect(embedder.embedderInfo.name).toBe("bedrock") + }) + + test("should require region", () => { + expect(() => new BedrockEmbedder("", "profile", "model")).toThrow("Region is required for AWS Bedrock embedder") + }) + + test("should use profile for credentials", () => { + mockFromEnv.mockReset() + mockFromIni.mockReset() + const inst = new BedrockEmbedder("us-west-2", "dev-profile") + expect(inst).toBeDefined() + expect(mockFromIni).toHaveBeenCalledWith({ profile: "dev-profile" }) + expect(mockFromEnv).not.toHaveBeenCalled() + }) + + test("should use default credential chain when profile is not provided", () => { + mockFromEnv.mockReset() + mockFromIni.mockReset() + + const inst = new BedrockEmbedder("us-west-2") + + expect(inst).toBeDefined() + expect(mockFromIni).not.toHaveBeenCalled() + expect(mockFromEnv).not.toHaveBeenCalled() + }) + }) + + describe("createEmbeddings", () => { + const testModelId = "amazon.titan-embed-text-v2:0" + + test("should create embeddings for a single text with Titan model", async () => { + const testTexts = ["Hello world"] + const mockResponse = { + body: new TextEncoder().encode( + JSON.stringify({ + embedding: [0.1, 0.2, 0.3], + inputTextTokenCount: 2, + }), + ), + } + mockSend.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(testTexts) + + expect(mockSend).toHaveBeenCalled() + const command = mockSend.mock.calls[0][0] as any + expect(command.input.modelId).toBe(testModelId) + const bodyStr = + typeof command.input.body === "string" + ? command.input.body + : new TextDecoder().decode(command.input.body as Uint8Array) + expect(JSON.parse(bodyStr || "{}")).toEqual({ + inputText: "Hello world", + }) + + expect(result).toEqual({ + embeddings: [[0.1, 0.2, 0.3]], + usage: { promptTokens: 2, totalTokens: 2 }, + }) + }) + + test("should create embeddings for multiple texts", async () => { + const testTexts = ["Hello world", "Another text"] + const mockResponses = [ + { + body: new TextEncoder().encode( + JSON.stringify({ + embedding: [0.1, 0.2, 0.3], + inputTextTokenCount: 2, + }), + ), + }, + { + body: new TextEncoder().encode( + JSON.stringify({ + embedding: [0.4, 0.5, 0.6], + inputTextTokenCount: 3, + }), + ), + }, + ] + + mockSend.mockResolvedValueOnce(mockResponses[0]).mockResolvedValueOnce(mockResponses[1]) + + const result = await embedder.createEmbeddings(testTexts) + + expect(mockSend).toHaveBeenCalledTimes(2) + expect(result).toEqual({ + embeddings: [ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + ], + usage: { promptTokens: 5, totalTokens: 5 }, + }) + }) + + test("should handle Cohere model format", async () => { + const cohereEmbedder = new BedrockEmbedder("us-east-1", "test-profile", "cohere.embed-english-v3") + const testTexts = ["Hello world"] + const mockResponse = { + body: new TextEncoder().encode( + JSON.stringify({ + embeddings: [[0.1, 0.2, 0.3]], + }), + ), + } + mockSend.mockResolvedValue(mockResponse) + + const result = await cohereEmbedder.createEmbeddings(testTexts) + + const command = mockSend.mock.calls[0][0] as any + const bodyStr = + typeof command.input.body === "string" + ? command.input.body + : new TextDecoder().decode(command.input.body as Uint8Array) + expect(JSON.parse(bodyStr || "{}")).toEqual({ + texts: ["Hello world"], + input_type: "search_document", + }) + + expect(result).toEqual({ + embeddings: [[0.1, 0.2, 0.3]], + usage: { promptTokens: 0, totalTokens: 0 }, + }) + }) + + test("should create embeddings with Nova multimodal model", async () => { + const novaMultimodalEmbedder = new BedrockEmbedder( + "us-east-1", + "test-profile", + "amazon.nova-2-multimodal-embeddings-v1:0", + ) + const testTexts = ["Hello world"] + const mockResponse = { + body: new TextEncoder().encode( + JSON.stringify({ + embeddings: [ + { + embedding: [0.1, 0.2, 0.3], + }, + ], + inputTextTokenCount: 2, + }), + ), + } + mockSend.mockResolvedValue(mockResponse) + + const result = await novaMultimodalEmbedder.createEmbeddings(testTexts) + + expect(mockSend).toHaveBeenCalled() + const command = mockSend.mock.calls[0][0] as any + expect(command.input.modelId).toBe("amazon.nova-2-multimodal-embeddings-v1:0") + const bodyStr = + typeof command.input.body === "string" + ? command.input.body + : new TextDecoder().decode(command.input.body as Uint8Array) + expect(JSON.parse(bodyStr || "{}")).toEqual({ + taskType: "SINGLE_EMBEDDING", + singleEmbeddingParams: { + embeddingPurpose: "GENERIC_INDEX", + embeddingDimension: 1024, + text: { + truncationMode: "END", + value: "Hello world", + }, + }, + }) + + expect(result).toEqual({ + embeddings: [[0.1, 0.2, 0.3]], + usage: { promptTokens: 2, totalTokens: 2 }, + }) + }) + + test("should handle Nova multimodal model with multiple texts", async () => { + const novaMultimodalEmbedder = new BedrockEmbedder( + "us-east-1", + "test-profile", + "amazon.nova-2-multimodal-embeddings-v1:0", + ) + const testTexts = ["Hello world", "Another text"] + const mockResponses = [ + { + body: new TextEncoder().encode( + JSON.stringify({ + embeddings: [ + { + embedding: [0.1, 0.2, 0.3], + }, + ], + inputTextTokenCount: 2, + }), + ), + }, + { + body: new TextEncoder().encode( + JSON.stringify({ + embeddings: [ + { + embedding: [0.4, 0.5, 0.6], + }, + ], + inputTextTokenCount: 3, + }), + ), + }, + ] + + mockSend.mockResolvedValueOnce(mockResponses[0]).mockResolvedValueOnce(mockResponses[1]) + + const result = await novaMultimodalEmbedder.createEmbeddings(testTexts) + + expect(mockSend).toHaveBeenCalledTimes(2) + + // Verify the request format for both texts + const firstCommand = mockSend.mock.calls[0][0] as any + const firstBodyStr = + typeof firstCommand.input.body === "string" + ? firstCommand.input.body + : new TextDecoder().decode(firstCommand.input.body as Uint8Array) + expect(JSON.parse(firstBodyStr || "{}")).toEqual({ + taskType: "SINGLE_EMBEDDING", + singleEmbeddingParams: { + embeddingPurpose: "GENERIC_INDEX", + embeddingDimension: 1024, + text: { + truncationMode: "END", + value: "Hello world", + }, + }, + }) + + const secondCommand = mockSend.mock.calls[1][0] as any + const secondBodyStr = + typeof secondCommand.input.body === "string" + ? secondCommand.input.body + : new TextDecoder().decode(secondCommand.input.body as Uint8Array) + expect(JSON.parse(secondBodyStr || "{}")).toEqual({ + taskType: "SINGLE_EMBEDDING", + singleEmbeddingParams: { + embeddingPurpose: "GENERIC_INDEX", + embeddingDimension: 1024, + text: { + truncationMode: "END", + value: "Another text", + }, + }, + }) + + expect(result).toEqual({ + embeddings: [ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + ], + usage: { promptTokens: 5, totalTokens: 5 }, + }) + }) + + test("should use custom model when provided", async () => { + const testTexts = ["Hello world"] + const customModel = "amazon.titan-embed-text-v1" + const mockResponse = { + body: new TextEncoder().encode( + JSON.stringify({ + embedding: [0.1, 0.2, 0.3], + inputTextTokenCount: 2, + }), + ), + } + mockSend.mockResolvedValue(mockResponse) + + await embedder.createEmbeddings(testTexts, customModel) + + const command = mockSend.mock.calls[0][0] as any + expect(command.input.modelId).toBe(customModel) + }) + + test("should handle missing token count data gracefully", async () => { + const testTexts = ["Hello world"] + const mockResponse = { + body: new TextEncoder().encode( + JSON.stringify({ + embedding: [0.1, 0.2, 0.3], + }), + ), + } + mockSend.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(testTexts) + + expect(result).toEqual({ + embeddings: [[0.1, 0.2, 0.3]], + usage: { promptTokens: 0, totalTokens: 0 }, + }) + }) + + describe("batching logic", () => { + test("should skip texts exceeding maximum token limit", async () => { + // Create a text that exceeds MAX_ITEM_TOKENS (4 characters ~ 1 token) + const oversizedText = "a".repeat(MAX_ITEM_TOKENS * 4 + 100) + const normalText = "normal text" + const testTexts = [normalText, oversizedText, "another normal"] + + const mockResponses = [ + { + body: new TextEncoder().encode( + JSON.stringify({ + embedding: [0.1, 0.2, 0.3], + inputTextTokenCount: 3, + }), + ), + }, + { + body: new TextEncoder().encode( + JSON.stringify({ + embedding: [0.4, 0.5, 0.6], + inputTextTokenCount: 3, + }), + ), + }, + ] + + mockSend.mockResolvedValueOnce(mockResponses[0]).mockResolvedValueOnce(mockResponses[1]) + + const result = await embedder.createEmbeddings(testTexts) + + // Verify only normal texts were processed (oversized skipped) + expect(mockSend).toHaveBeenCalledTimes(2) + expect(result.embeddings).toHaveLength(2) + }) + + test("should handle all texts being skipped due to size", async () => { + const oversizedText = "a".repeat(MAX_ITEM_TOKENS * 4 + 100) + const testTexts = [oversizedText, oversizedText] + + const result = await embedder.createEmbeddings(testTexts) + + expect(mockSend).not.toHaveBeenCalled() + expect(result).toEqual({ + embeddings: [], + usage: { promptTokens: 0, totalTokens: 0 }, + }) + }) + }) + + describe("retry logic", () => { + // TODO: bun:test doesn't support fake timers + test.skip("should retry on throttling errors with exponential backoff", async () => { + const testTexts = ["Hello world"] + const throttlingError = new Error("Rate limit exceeded") + throttlingError.name = "ThrottlingException" + + mockSend + .mockRejectedValueOnce(throttlingError) + .mockRejectedValueOnce(throttlingError) + .mockResolvedValueOnce({ + body: new TextEncoder().encode( + JSON.stringify({ + embedding: [0.1, 0.2, 0.3], + inputTextTokenCount: 2, + }), + ), + }) + + const result = await embedder.createEmbeddings(testTexts) + + expect(mockSend).toHaveBeenCalledTimes(3) + expect(result).toEqual({ + embeddings: [[0.1, 0.2, 0.3]], + usage: { promptTokens: 2, totalTokens: 2 }, + }) + }) + + test("should not retry on non-throttling errors", async () => { + const testTexts = ["Hello world"] + const authError = new Error("Unauthorized") + authError.name = "UnrecognizedClientException" + + mockSend.mockRejectedValue(authError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts: Unauthorized", + ) + + expect(mockSend).toHaveBeenCalledTimes(1) + }) + }) + + describe("error handling", () => { + test("should handle API errors gracefully", async () => { + const testTexts = ["Hello world"] + const apiError = new Error("API connection failed") + + mockSend.mockRejectedValue(apiError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts: API connection failed", + ) + }) + + test("should handle empty text arrays", async () => { + const testTexts: string[] = [] + + const result = await embedder.createEmbeddings(testTexts) + + expect(result).toEqual({ + embeddings: [], + usage: { promptTokens: 0, totalTokens: 0 }, + }) + expect(mockSend).not.toHaveBeenCalled() + }) + + test("should handle malformed API responses", async () => { + const testTexts = ["Hello world"] + const malformedResponse = { + body: new TextEncoder().encode("not json"), + } + + mockSend.mockResolvedValue(malformedResponse) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow() + }) + + test("should handle AWS-specific errors", async () => { + const testTexts = ["Hello world"] + + // Test UnrecognizedClientException + const authError = new Error("Invalid credentials") + authError.name = "UnrecognizedClientException" + mockSend.mockRejectedValueOnce(authError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts: Invalid credentials", + ) + + // Test AccessDeniedException + const accessError = new Error("Access denied") + accessError.name = "AccessDeniedException" + mockSend.mockRejectedValueOnce(accessError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts: Access denied", + ) + + // Test ResourceNotFoundException + const notFoundError = new Error("Model not found") + notFoundError.name = "ResourceNotFoundException" + mockSend.mockRejectedValueOnce(notFoundError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts: Model not found", + ) + }) + }) + }) + + describe("validateConfiguration", () => { + test("should validate successfully with valid configuration", async () => { + const mockResponse = { + body: new TextEncoder().encode( + JSON.stringify({ + embedding: [0.1, 0.2, 0.3], + inputTextTokenCount: 1, + }), + ), + } + mockSend.mockResolvedValue(mockResponse) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + expect(mockSend).toHaveBeenCalled() + }) + + test("should fail validation with authentication error", async () => { + const authError = new Error("Invalid credentials") + authError.name = "UnrecognizedClientException" + mockSend.mockRejectedValue(authError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Invalid AWS credentials for Bedrock") + }) + + test("should fail validation with access denied error", async () => { + const accessError = new Error("Access denied") + accessError.name = "AccessDeniedException" + mockSend.mockRejectedValue(accessError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Access denied to Bedrock embedding model") + }) + + test("should fail validation with model not found error", async () => { + const notFoundError = new Error("Model not found") + notFoundError.name = "ResourceNotFoundException" + mockSend.mockRejectedValue(notFoundError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toContain("not found") + }) + + test("should fail validation with invalid response", async () => { + const mockResponse = { + body: new TextEncoder().encode( + JSON.stringify({ + // Missing embedding field + inputTextTokenCount: 1, + }), + ), + } + mockSend.mockResolvedValue(mockResponse) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Bedrock returned an invalid response format") + }) + + test("should fail validation with connection error", async () => { + const connectionError = new Error("ECONNREFUSED") + mockSend.mockRejectedValue(connectionError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toContain("Connection failed") + }) + + test("should fail validation with generic error", async () => { + const genericError = new Error("Unknown error") + mockSend.mockRejectedValue(genericError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toContain("Configuration error") + }) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/gemini.test.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/gemini.test.ts new file mode 100644 index 00000000000..3ae7fd8fb7d --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/gemini.test.ts @@ -0,0 +1,134 @@ +import { describe, test, expect, beforeEach, mock } from "bun:test" +import { mockEmbeddingsCreate, openAIMockFactory } from "./__helpers__/openai-mock" + +// RATIONALE: Test GeminiEmbedder through the real OpenAICompatibleEmbedder with mocked OpenAI SDK. +// Mocking the openai-compatible module with mock.module() is process-wide in Bun and would +// interfere with openai-compatible.test.ts which needs the real implementation. +mock.module("openai", openAIMockFactory) + +import { GeminiEmbedder } from "../../../../src/indexing/embedders/gemini" + +describe("GeminiEmbedder", () => { + let embedder: GeminiEmbedder + + beforeEach(() => { + mockEmbeddingsCreate.mockReset() + }) + + describe("constructor", () => { + test("should create an instance with default model", () => { + embedder = new GeminiEmbedder("test-gemini-api-key") + expect(embedder).toBeDefined() + }) + + test("should create an instance with specified model", () => { + embedder = new GeminiEmbedder("test-gemini-api-key", "text-embedding-004") + expect(embedder).toBeDefined() + }) + + test("should throw error when API key is not provided", () => { + expect(() => new GeminiEmbedder("")).toThrow("API key is required for Gemini embedder") + expect(() => new GeminiEmbedder(null as any)).toThrow("API key is required for Gemini embedder") + expect(() => new GeminiEmbedder(undefined as any)).toThrow("API key is required for Gemini embedder") + }) + }) + + describe("embedderInfo", () => { + test("should return correct embedder info", () => { + embedder = new GeminiEmbedder("test-api-key") + + expect(embedder.embedderInfo).toEqual({ + name: "gemini", + }) + }) + }) + + describe("createEmbeddings", () => { + test("should use default model when no model parameter provided", async () => { + embedder = new GeminiEmbedder("test-api-key") + const texts = ["test text 1", "test text 2"] + const mockResponse = { + data: [{ embedding: [0.1, 0.2] }, { embedding: [0.3, 0.4] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(texts) + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: texts, + model: "gemini-embedding-001", + encoding_format: "base64", + }) + expect(result.embeddings).toEqual([ + [0.1, 0.2], + [0.3, 0.4], + ]) + }) + + test("should use provided model parameter when specified", async () => { + embedder = new GeminiEmbedder("test-api-key", "text-embedding-004") + const texts = ["test text 1"] + const mockResponse = { + data: [{ embedding: [0.5, 0.6] }], + usage: { prompt_tokens: 5, total_tokens: 5 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(texts, "gemini-embedding-001") + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: texts, + model: "gemini-embedding-001", + encoding_format: "base64", + }) + expect(result.embeddings).toEqual([[0.5, 0.6]]) + }) + + test("should handle errors from embedding API", async () => { + embedder = new GeminiEmbedder("test-api-key") + const error = new Error("Embedding failed") + mockEmbeddingsCreate.mockRejectedValue(error) + + await expect(embedder.createEmbeddings(["test text"])).rejects.toThrow() + }) + }) + + describe("validateConfiguration", () => { + test("should validate successfully with valid configuration", async () => { + embedder = new GeminiEmbedder("test-api-key") + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + }) + + test("should fail validation with authentication error", async () => { + embedder = new GeminiEmbedder("test-api-key") + const authError = new Error("Invalid API key") + ;(authError as any).status = 401 + mockEmbeddingsCreate.mockRejectedValue(authError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Authentication failed. Please check your API key.") + }) + + test("should handle validation exceptions", async () => { + embedder = new GeminiEmbedder("test-api-key") + const error = new Error("ECONNREFUSED") + mockEmbeddingsCreate.mockRejectedValue(error) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toContain("Connection failed") + }) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/mistral.test.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/mistral.test.ts new file mode 100644 index 00000000000..d0be4af9b40 --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/mistral.test.ts @@ -0,0 +1,134 @@ +import { describe, test, expect, beforeEach, mock } from "bun:test" +import { mockEmbeddingsCreate, openAIMockFactory } from "./__helpers__/openai-mock" + +// RATIONALE: Test MistralEmbedder through the real OpenAICompatibleEmbedder with mocked OpenAI SDK. +// Mocking the openai-compatible module with mock.module() is process-wide in Bun and would +// interfere with openai-compatible.test.ts which needs the real implementation. +mock.module("openai", openAIMockFactory) + +import { MistralEmbedder } from "../../../../src/indexing/embedders/mistral" + +describe("MistralEmbedder", () => { + let embedder: MistralEmbedder + + beforeEach(() => { + mockEmbeddingsCreate.mockReset() + }) + + describe("constructor", () => { + test("should create an instance with default model", () => { + embedder = new MistralEmbedder("test-mistral-api-key") + expect(embedder).toBeDefined() + }) + + test("should create an instance with specified model", () => { + embedder = new MistralEmbedder("test-mistral-api-key", "custom-embed-model") + expect(embedder).toBeDefined() + }) + + test("should throw error when API key is not provided", () => { + expect(() => new MistralEmbedder("")).toThrow("API key is required for Mistral embedder") + expect(() => new MistralEmbedder(null as any)).toThrow("API key is required for Mistral embedder") + expect(() => new MistralEmbedder(undefined as any)).toThrow("API key is required for Mistral embedder") + }) + }) + + describe("embedderInfo", () => { + test("should return correct embedder info", () => { + embedder = new MistralEmbedder("test-api-key") + + expect(embedder.embedderInfo).toEqual({ + name: "mistral", + }) + }) + }) + + describe("createEmbeddings", () => { + test("should use default model when no model parameter provided", async () => { + embedder = new MistralEmbedder("test-api-key") + const texts = ["test text 1", "test text 2"] + const mockResponse = { + data: [{ embedding: [0.1, 0.2] }, { embedding: [0.3, 0.4] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(texts) + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: texts, + model: "codestral-embed-2505", + encoding_format: "base64", + }) + expect(result.embeddings).toEqual([ + [0.1, 0.2], + [0.3, 0.4], + ]) + }) + + test("should use provided model parameter when specified", async () => { + embedder = new MistralEmbedder("test-api-key", "custom-embed-model") + const texts = ["test text 1"] + const mockResponse = { + data: [{ embedding: [0.5, 0.6] }], + usage: { prompt_tokens: 5, total_tokens: 5 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(texts, "codestral-embed-2505") + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: texts, + model: "codestral-embed-2505", + encoding_format: "base64", + }) + expect(result.embeddings).toEqual([[0.5, 0.6]]) + }) + + test("should handle errors from embedding API", async () => { + embedder = new MistralEmbedder("test-api-key") + const error = new Error("Embedding failed") + mockEmbeddingsCreate.mockRejectedValue(error) + + await expect(embedder.createEmbeddings(["test text"])).rejects.toThrow() + }) + }) + + describe("validateConfiguration", () => { + test("should validate successfully with valid configuration", async () => { + embedder = new MistralEmbedder("test-api-key") + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + }) + + test("should fail validation with authentication error", async () => { + embedder = new MistralEmbedder("test-api-key") + const authError = new Error("Invalid API key") + ;(authError as any).status = 401 + mockEmbeddingsCreate.mockRejectedValue(authError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Authentication failed. Please check your API key.") + }) + + test("should handle validation exceptions", async () => { + embedder = new MistralEmbedder("test-api-key") + const error = new Error("ECONNREFUSED") + mockEmbeddingsCreate.mockRejectedValue(error) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toContain("Connection failed") + }) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/ollama.test.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/ollama.test.ts new file mode 100644 index 00000000000..18a775fc89e --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/ollama.test.ts @@ -0,0 +1,256 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" + +import { CodeIndexOllamaEmbedder } from "../../../../src/indexing/embedders/ollama" + +const mockFetch = mock() as unknown as typeof fetch +global.fetch = mockFetch + +describe("CodeIndexOllamaEmbedder", () => { + let embedder: CodeIndexOllamaEmbedder + + beforeEach(() => { + ;(mockFetch as any).mockReset() + + embedder = new CodeIndexOllamaEmbedder("http://localhost:11434", "nomic-embed-text") + }) + + afterEach(() => { + ;(mockFetch as any).mockReset() + }) + + describe("constructor", () => { + test("should initialize with provided options", () => { + expect(embedder.embedderInfo.name).toBe("ollama") + }) + + test("should use default values when not provided", () => { + const embedderWithDefaults = new CodeIndexOllamaEmbedder("") + expect(embedderWithDefaults.embedderInfo.name).toBe("ollama") + }) + + test("should normalize URLs with trailing slashes", async () => { + const embedderWithTrailingSlash = new CodeIndexOllamaEmbedder("http://localhost:11434/", "nomic-embed-text") + + ;(mockFetch as any).mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ models: [{ name: "nomic-embed-text" }] }), + } as Response), + ) + + await embedderWithTrailingSlash.validateConfiguration() + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:11434/api/tags", + expect.objectContaining({ + method: "GET", + }), + ) + }) + + test("should not modify URLs without trailing slashes", async () => { + const embedderWithoutTrailingSlash = new CodeIndexOllamaEmbedder("http://localhost:11434", "nomic-embed-text") + + ;(mockFetch as any).mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ models: [{ name: "nomic-embed-text" }] }), + } as Response), + ) + + await embedderWithoutTrailingSlash.validateConfiguration() + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:11434/api/tags", + expect.objectContaining({ + method: "GET", + }), + ) + }) + + test("should handle multiple trailing slashes", async () => { + const embedderWithMultipleTrailingSlashes = new CodeIndexOllamaEmbedder( + "http://localhost:11434///", + "nomic-embed-text", + ) + + ;(mockFetch as any).mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ models: [{ name: "nomic-embed-text" }] }), + } as Response), + ) + + await embedderWithMultipleTrailingSlashes.validateConfiguration() + + expect(mockFetch).toHaveBeenCalledWith( + "http://localhost:11434/api/tags", + expect.objectContaining({ + method: "GET", + }), + ) + }) + }) + + describe("validateConfiguration", () => { + test("should validate successfully when service is available and model exists", async () => { + // Mock successful /api/tags call + ;(mockFetch as any).mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + models: [{ name: "nomic-embed-text:latest" }, { name: "llama2:latest" }], + }), + } as Response), + ) + + // Mock successful /api/embed test call + ;(mockFetch as any).mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + embeddings: [[0.1, 0.2, 0.3]], + }), + } as Response), + ) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + expect(mockFetch).toHaveBeenCalledTimes(2) + + // Check first call (GET /api/tags) + const firstCall = (mockFetch as any).mock.calls[0] + expect(firstCall[0]).toBe("http://localhost:11434/api/tags") + expect(firstCall[1]?.method).toBe("GET") + expect(firstCall[1]?.headers).toEqual({ "Content-Type": "application/json" }) + expect(firstCall[1]?.signal).toBeDefined() + + // Check second call (POST /api/embed) + const secondCall = (mockFetch as any).mock.calls[1] + expect(secondCall[0]).toBe("http://localhost:11434/api/embed") + expect(secondCall[1]?.method).toBe("POST") + expect(secondCall[1]?.headers).toEqual({ "Content-Type": "application/json" }) + expect(secondCall[1]?.body).toBe(JSON.stringify({ model: "nomic-embed-text", input: ["test"] })) + expect(secondCall[1]?.signal).toBeDefined() + }) + + test("should fail validation when service is not available", async () => { + ;(mockFetch as any).mockRejectedValueOnce(new Error("ECONNREFUSED")) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toContain("Ollama service is not running") + }) + + test("should fail validation when tags endpoint returns 404", async () => { + ;(mockFetch as any).mockImplementationOnce(() => + Promise.resolve({ + ok: false, + status: 404, + } as Response), + ) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toContain("Ollama service is not running") + }) + + test("should fail validation when tags endpoint returns other error", async () => { + ;(mockFetch as any).mockImplementationOnce(() => + Promise.resolve({ + ok: false, + status: 500, + } as Response), + ) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toContain("Ollama service unavailable") + }) + + test("should fail validation when model does not exist", async () => { + // Mock successful /api/tags call with different models + ;(mockFetch as any).mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + models: [{ name: "llama2:latest" }, { name: "mistral:latest" }], + }), + } as Response), + ) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toContain("not found") + }) + + test("should fail validation when model exists but doesn't support embeddings", async () => { + // Mock successful /api/tags call + ;(mockFetch as any).mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + models: [{ name: "nomic-embed-text" }], + }), + } as Response), + ) + + // Mock failed /api/embed test call + ;(mockFetch as any).mockImplementationOnce(() => + Promise.resolve({ + ok: false, + status: 400, + } as Response), + ) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toContain("not capable of generating embeddings") + }) + + test("should handle ECONNREFUSED errors", async () => { + ;(mockFetch as any).mockRejectedValueOnce(new Error("ECONNREFUSED")) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toContain("Ollama service is not running") + }) + + test("should handle ENOTFOUND errors", async () => { + ;(mockFetch as any).mockRejectedValueOnce(new Error("ENOTFOUND")) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toContain("Ollama host not found") + }) + + test("should handle generic network errors", async () => { + ;(mockFetch as any).mockRejectedValueOnce(new Error("Network timeout")) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Network timeout") + }) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible-rate-limit.test.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible-rate-limit.test.ts new file mode 100644 index 00000000000..a5575ad474a --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible-rate-limit.test.ts @@ -0,0 +1,153 @@ +// TODO: These tests require fake timers (vitest.useFakeTimers) which bun:test doesn't support + +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" +import { mockEmbeddingsCreate, openAIMockFactory } from "./__helpers__/openai-mock" + +mock.module("openai", openAIMockFactory) + +import { OpenAICompatibleEmbedder } from "../../../../src/indexing/embedders/openai-compatible" + +describe.skip("OpenAICompatibleEmbedder - Global Rate Limiting", () => { + const testBaseUrl = "https://api.openai.com/v1" + const testApiKey = "test-api-key" + const testModelId = "text-embedding-3-small" + + beforeEach(() => { + mockEmbeddingsCreate.mockReset() + + mockEmbeddingsCreate.mockImplementation(() => mockEmbeddingsCreate) + + // Reset global rate limit state + const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + ;(embedder as any).constructor.globalRateLimitState = { + isRateLimited: false, + rateLimitResetTime: 0, + consecutiveRateLimitErrors: 0, + lastRateLimitError: 0, + mutex: (embedder as any).constructor.globalRateLimitState.mutex, + } + }) + + afterEach(() => { + mockEmbeddingsCreate.mockReset() + }) + + test("should apply global rate limiting across multiple batch requests", async () => { + const embedder1 = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + const embedder2 = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + + // First batch hits rate limit + const rateLimitError = new Error("Rate limit exceeded") as any + rateLimitError.status = 429 + + mockEmbeddingsCreate + .mockRejectedValueOnce(rateLimitError) // First attempt fails + .mockResolvedValue({ + data: [{ embedding: "base64encodeddata" }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }) + + // Start first batch request + const batch1Promise = embedder1.createEmbeddings(["test1"]) + + // Start second batch request while global rate limit is active + const batch2Promise = embedder2.createEmbeddings(["test2"]) + + // Check that global rate limit was set + const state = (embedder1 as any).constructor.globalRateLimitState + expect(state.isRateLimited).toBe(true) + expect(state.consecutiveRateLimitErrors).toBe(1) + + // Both requests should complete + const [result1, result2] = await Promise.all([batch1Promise, batch2Promise]) + + expect(result1.embeddings).toHaveLength(1) + expect(result2.embeddings).toHaveLength(1) + }) + + test("should track consecutive rate limit errors", async () => { + const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + const state = (embedder as any).constructor.globalRateLimitState + + const rateLimitError = new Error("Rate limit exceeded") as any + rateLimitError.status = 429 + + // Test that consecutive errors increment when they happen quickly + // Mock multiple rate limit errors in a single request + mockEmbeddingsCreate + .mockRejectedValueOnce(rateLimitError) // First attempt + .mockRejectedValueOnce(rateLimitError) // Retry 1 + .mockResolvedValueOnce({ + data: [{ embedding: "base64encodeddata" }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }) + + const promise1 = embedder.createEmbeddings(["test1"]) + expect(state.consecutiveRateLimitErrors).toBe(1) + + await promise1 + + // Verify the delay increases with consecutive errors + // Make another request immediately that also hits rate limit + mockEmbeddingsCreate.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce({ + data: [{ embedding: "base64encodeddata" }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }) + + // Store the current consecutive count before the next request + const previousCount = state.consecutiveRateLimitErrors + + const promise2 = embedder.createEmbeddings(["test2"]) + + // Should have incremented from the previous count + expect(state.consecutiveRateLimitErrors).toBeGreaterThan(previousCount) + + // Complete the second request + await promise2 + }) + + test("should reset consecutive error count after time passes", async () => { + const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + const state = (embedder as any).constructor.globalRateLimitState + + // Manually set state to simulate previous errors + state.consecutiveRateLimitErrors = 3 + state.lastRateLimitError = Date.now() - 70000 // 70 seconds ago + + const rateLimitError = new Error("Rate limit exceeded") as any + rateLimitError.status = 429 + + mockEmbeddingsCreate.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce({ + data: [{ embedding: "base64encodeddata" }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }) + + // Trigger the updateGlobalRateLimitState method + await (embedder as any).updateGlobalRateLimitState(rateLimitError) + + // Should reset to 1 since more than 60 seconds passed + expect(state.consecutiveRateLimitErrors).toBe(1) + }) + + test("should not exceed maximum delay of 5 minutes", async () => { + const embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + const state = (embedder as any).constructor.globalRateLimitState + + // Set state to simulate many consecutive errors + state.consecutiveRateLimitErrors = 10 // This would normally result in a very long delay + + const rateLimitError = new Error("Rate limit exceeded") as any + rateLimitError.status = 429 + + // Trigger the updateGlobalRateLimitState method + await (embedder as any).updateGlobalRateLimitState(rateLimitError) + + // Calculate the expected delay + const now = Date.now() + const delay = state.rateLimitResetTime - now + + // Should be capped at 5 minutes (300000ms) + expect(delay).toBeLessThanOrEqual(300000) + expect(delay).toBeGreaterThan(0) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible.test.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible.test.ts new file mode 100644 index 00000000000..4bed6ea72ed --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/openai-compatible.test.ts @@ -0,0 +1,912 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" + +import { + MAX_ITEM_TOKENS, + REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, + REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, +} from "../../../../src/indexing/constants" +import { mockEmbeddingsCreate, setOpenAIConstructorHook, openAIMockFactory } from "./__helpers__/openai-mock" + +mock.module("openai", openAIMockFactory) + +import { OpenAICompatibleEmbedder } from "../../../../src/indexing/embedders/openai-compatible" + +// Mock global fetch +const mockFetch = mock() as any +global.fetch = mockFetch + +describe("OpenAICompatibleEmbedder", () => { + let embedder: OpenAICompatibleEmbedder + + const testBaseUrl = "https://api.example.com/v1" + const testApiKey = "test-api-key" + const testModelId = "text-embedding-3-small" + + beforeEach(() => { + mockEmbeddingsCreate.mockReset() + mockFetch.mockReset() + setOpenAIConstructorHook(undefined) + + // Reset global rate limit state to prevent interference between tests + const tempEmbedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + ;(tempEmbedder as any).constructor.globalRateLimitState = { + isRateLimited: false, + rateLimitResetTime: 0, + consecutiveRateLimitErrors: 0, + lastRateLimitError: 0, + mutex: (tempEmbedder as any).constructor.globalRateLimitState.mutex, + } + }) + + afterEach(() => { + mockEmbeddingsCreate.mockReset() + mockFetch.mockReset() + setOpenAIConstructorHook(undefined) + }) + + describe("constructor", () => { + test("should create embedder with valid configuration", () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + + expect(embedder).toBeDefined() + }) + + test("should use default model when modelId is not provided", () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey) + + expect(embedder).toBeDefined() + }) + + test("should throw error when baseUrl is missing", () => { + expect(() => new OpenAICompatibleEmbedder("", testApiKey, testModelId)).toThrow( + "Base URL is required for OpenAI-compatible embedder", + ) + }) + + test("should throw error when apiKey is missing", () => { + expect(() => new OpenAICompatibleEmbedder(testBaseUrl, "", testModelId)).toThrow( + "API key is required for OpenAI-compatible embedder", + ) + }) + + test("should throw error when both baseUrl and apiKey are missing", () => { + expect(() => new OpenAICompatibleEmbedder("", "", testModelId)).toThrow( + "Base URL is required for OpenAI-compatible embedder", + ) + }) + + test("should handle API key with invalid characters (ByteString conversion error)", () => { + const invalidApiKey = "sk-test\u2022invalid" // Contains bullet character (U+2022) + const byteStringError = new Error( + "Cannot convert argument to a ByteString because the character at index 7 has a value of 8226 which is greater than 255.", + ) + + // Make the mock OpenAI constructor throw + setOpenAIConstructorHook(() => { + throw byteStringError + }) + + expect(() => new OpenAICompatibleEmbedder(testBaseUrl, invalidApiKey, testModelId)).toThrow( + "Cannot convert argument to a ByteString", + ) + }) + }) + + describe("embedderInfo", () => { + beforeEach(() => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + }) + + test("should return correct embedder info", () => { + const info = embedder.embedderInfo + + expect(info).toEqual({ + name: "openai-compatible", + }) + }) + }) + + describe("createEmbeddings", () => { + beforeEach(() => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + }) + + test("should create embeddings for single text", async () => { + const testTexts = ["Hello world"] + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(testTexts) + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: testTexts, + model: testModelId, + encoding_format: "base64", + }) + expect(result).toEqual({ + embeddings: [[0.1, 0.2, 0.3]], + usage: { promptTokens: 10, totalTokens: 15 }, + }) + }) + + test("should create embeddings for multiple texts", async () => { + const testTexts = ["Hello world", "Goodbye world"] + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }, { embedding: [0.4, 0.5, 0.6] }], + usage: { prompt_tokens: 20, total_tokens: 30 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(testTexts) + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: testTexts, + model: testModelId, + encoding_format: "base64", + }) + expect(result).toEqual({ + embeddings: [ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + ], + usage: { promptTokens: 20, totalTokens: 30 }, + }) + }) + + test("should use custom model when provided", async () => { + const testTexts = ["Hello world"] + const customModel = "custom-embedding-model" + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + await embedder.createEmbeddings(testTexts, customModel) + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: testTexts, + model: customModel, + encoding_format: "base64", + }) + }) + + test("should handle missing usage data gracefully", async () => { + const testTexts = ["Hello world"] + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: undefined, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(testTexts) + + expect(result).toEqual({ + embeddings: [[0.1, 0.2, 0.3]], + usage: { promptTokens: 0, totalTokens: 0 }, + }) + }) + + /** + * Test base64 conversion logic + */ + describe("base64 conversion", () => { + test("should convert base64 encoded embeddings to float arrays", async () => { + const testTexts = ["Hello world"] + + // Create a Float32Array with test values that can be exactly represented in Float32 + const testEmbedding = new Float32Array([0.25, 0.5, 0.75, 1.0]) + + // Convert to base64 string (simulating what OpenAI API returns) + const buffer = Buffer.from(testEmbedding.buffer) + const base64String = buffer.toString("base64") + + const mockResponse = { + data: [{ embedding: base64String }], // Base64 string instead of array + usage: { prompt_tokens: 10, total_tokens: 15 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(testTexts) + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: testTexts, + model: testModelId, + encoding_format: "base64", + }) + + // Verify the base64 string was converted back to the original float array + expect(result).toEqual({ + embeddings: [[0.25, 0.5, 0.75, 1.0]], + usage: { promptTokens: 10, totalTokens: 15 }, + }) + }) + + test("should handle multiple base64 encoded embeddings", async () => { + const testTexts = ["Hello world", "Goodbye world"] + + // Create test embeddings with values that can be exactly represented in Float32 + const embedding1 = new Float32Array([0.25, 0.5, 0.75]) + const embedding2 = new Float32Array([1.0, 1.25, 1.5]) + + // Convert to base64 strings + const base64String1 = Buffer.from(embedding1.buffer).toString("base64") + const base64String2 = Buffer.from(embedding2.buffer).toString("base64") + + const mockResponse = { + data: [{ embedding: base64String1 }, { embedding: base64String2 }], + usage: { prompt_tokens: 20, total_tokens: 30 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(testTexts) + + expect(result).toEqual({ + embeddings: [ + [0.25, 0.5, 0.75], + [1.0, 1.25, 1.5], + ], + usage: { promptTokens: 20, totalTokens: 30 }, + }) + }) + + test("should handle mixed base64 and array embeddings", async () => { + const testTexts = ["Hello world", "Goodbye world"] + + // Create one base64 embedding and one regular array (edge case) + const embedding1 = new Float32Array([0.25, 0.5, 0.75]) + const base64String1 = Buffer.from(embedding1.buffer).toString("base64") + + const mockResponse = { + data: [ + { embedding: base64String1 }, // Base64 string + { embedding: [1.0, 1.25, 1.5] }, // Regular array + ], + usage: { prompt_tokens: 20, total_tokens: 30 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(testTexts) + + expect(result).toEqual({ + embeddings: [ + [0.25, 0.5, 0.75], + [1.0, 1.25, 1.5], + ], + usage: { promptTokens: 20, totalTokens: 30 }, + }) + }) + }) + + /** + * Test batching logic when texts exceed token limits + */ + describe("batching logic", () => { + test("should process texts in batches", async () => { + // Use normal sized texts that won't be skipped + const testTexts = ["text1", "text2", "text3"] + + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: [0.1, 0.2, 0.3] }, { embedding: [0.4, 0.5, 0.6] }, { embedding: [0.7, 0.8, 0.9] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }) + + await embedder.createEmbeddings(testTexts) + + // Should be called once for normal texts + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1) + }) + + test("should skip texts that exceed MAX_ITEM_TOKENS", async () => { + const normalText = "Hello world" + const oversizedText = "a".repeat(MAX_ITEM_TOKENS * 5) // Exceeds MAX_ITEM_TOKENS + const testTexts = [normalText, oversizedText, normalText] + + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }, { embedding: [0.4, 0.5, 0.6] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + await embedder.createEmbeddings(testTexts) + + // Should only process normal texts (1 call for 2 normal texts batched together) + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1) + }) + + test("should return correct usage statistics", async () => { + const testTexts = ["text1", "text2"] + + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: [0.1, 0.2, 0.3] }, { embedding: [0.4, 0.5, 0.6] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }) + + const result = await embedder.createEmbeddings(testTexts) + + expect(result.usage).toEqual({ + promptTokens: 10, + totalTokens: 15, + }) + }) + }) + + /** + * Test retry logic with exponential backoff + */ + describe("retry logic", () => { + // TODO: bun:test doesn't support fake timers + test.skip("should retry on rate limit errors with exponential backoff", async () => { + const testTexts = ["Hello world"] + const rateLimitError = { status: 429, message: "Rate limit exceeded" } + + // Create base64 encoded embedding for successful response + const testEmbedding = new Float32Array([0.25, 0.5, 0.75]) + const base64String = Buffer.from(testEmbedding.buffer).toString("base64") + + mockEmbeddingsCreate + .mockRejectedValueOnce(rateLimitError) + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValueOnce({ + data: [{ embedding: base64String }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }) + + const result = await embedder.createEmbeddings(testTexts) + + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(3) + expect(result).toEqual({ + embeddings: [[0.25, 0.5, 0.75]], + usage: { promptTokens: 10, totalTokens: 15 }, + }) + }) + + test("should not retry on non-rate-limit errors", async () => { + const testTexts = ["Hello world"] + const authError = new Error("Unauthorized") + ;(authError as any).status = 401 + + mockEmbeddingsCreate.mockRejectedValue(authError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Authentication failed. Please check your API key.", + ) + + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1) + }) + + test("should throw error immediately on non-retryable errors", async () => { + const testTexts = ["Hello world"] + const serverError = new Error("Internal server error") + ;(serverError as any).status = 500 + + mockEmbeddingsCreate.mockRejectedValue(serverError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts with status 500: Internal server error", + ) + + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1) + }) + }) + + /** + * Test error handling scenarios + */ + describe("error handling", () => { + test("should handle API errors gracefully", async () => { + const testTexts = ["Hello world"] + const apiError = new Error("API connection failed") + + mockEmbeddingsCreate.mockRejectedValue(apiError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts: API connection failed", + ) + }) + + test("should handle batch processing errors", async () => { + const testTexts = ["text1", "text2"] + const batchError = new Error("Batch processing failed") + + mockEmbeddingsCreate.mockRejectedValue(batchError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts: Batch processing failed", + ) + }) + + test("should handle empty text arrays", async () => { + const testTexts: string[] = [] + + const result = await embedder.createEmbeddings(testTexts) + + expect(result).toEqual({ + embeddings: [], + usage: { promptTokens: 0, totalTokens: 0 }, + }) + expect(mockEmbeddingsCreate).not.toHaveBeenCalled() + }) + + test("should handle malformed API responses", async () => { + const testTexts = ["Hello world"] + const malformedResponse = { + data: null, + usage: { prompt_tokens: 10, total_tokens: 15 }, + } + + mockEmbeddingsCreate.mockResolvedValue(malformedResponse) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow() + }) + + test("should provide specific authentication error message", async () => { + const testTexts = ["Hello world"] + const authError = new Error("Invalid API key") + ;(authError as any).status = 401 + + mockEmbeddingsCreate.mockRejectedValue(authError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Authentication failed. Please check your API key.", + ) + }) + + test("should provide detailed error message for HTTP errors", async () => { + const testTexts = ["Hello world"] + const httpError = new Error("Bad request") + ;(httpError as any).status = 400 + + mockEmbeddingsCreate.mockRejectedValue(httpError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts with status 400: Bad request", + ) + }) + + test("should handle errors without status codes", async () => { + const testTexts = ["Hello world"] + const networkError = new Error("Network timeout") + + mockEmbeddingsCreate.mockRejectedValue(networkError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts: Network timeout", + ) + }) + + test("should handle errors without message property", async () => { + const testTexts = ["Hello world"] + const weirdError = { toString: () => "Custom error object" } + + mockEmbeddingsCreate.mockRejectedValue(weirdError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts: Custom error object", + ) + }) + + test("should handle completely unknown error types", async () => { + const testTexts = ["Hello world"] + const unknownError = null + + mockEmbeddingsCreate.mockRejectedValue(unknownError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts: Unknown error", + ) + }) + }) + + /** + * Test to confirm OpenAI package bug with base64 encoding. + * This test verifies that when we request encoding_format: "base64", + * the OpenAI package returns unparsed base64 strings as expected. + */ + describe("OpenAI package base64 behavior verification", () => { + // TODO: bun:test doesn't support importActual + test.skip("should return unparsed base64 when encoding_format is base64", async () => { + // This test requires vi.importActual("openai") which is not available in bun:test + }) + }) + + /** + * Test Azure OpenAI compatibility with helper functions for conciseness + */ + describe("Azure OpenAI compatibility", () => { + const azureUrl = + "https://myresource.openai.azure.com/openai/deployments/mymodel/embeddings?api-version=2024-02-01" + const baseUrl = "https://api.openai.com/v1" + + // Helper to create mock fetch response + const createMockResponse = (data: any, status = 200, ok = true) => ({ + ok, + status, + json: mock().mockResolvedValue(data), + text: mock().mockResolvedValue(status === 200 ? "" : "Error message"), + }) + + // Helper to create base64 embedding + const createBase64Embedding = (values: number[]) => { + const embedding = new Float32Array(values) + return Buffer.from(embedding.buffer).toString("base64") + } + + // Helper to verify embedding values with floating-point tolerance + const expectEmbeddingValues = (actual: number[], expected: number[]) => { + expect(actual).toHaveLength(expected.length) + expected.forEach((val, i) => expect(actual[i]).toBeCloseTo(val, 5)) + } + + beforeEach(() => { + mockEmbeddingsCreate.mockReset() + mockFetch.mockReset() + }) + + describe("URL detection", () => { + test.each([ + ["https://myresource.openai.azure.com/openai/deployments/mymodel/embeddings?api-version=2024-02-01", true], + ["https://myresource.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings", true], + ["https://api.openai.com/v1", false], + ["https://api.example.com", false], + ["http://localhost:8080", false], + ])("should detect URL type correctly: %s -> %s", (url, expected) => { + const embedder = new OpenAICompatibleEmbedder(url, testApiKey, testModelId) + const isFullUrl = (embedder as any).isFullEndpointUrl(url) + expect(isFullUrl).toBe(expected) + }) + + // Edge cases where 'embeddings' or 'deployments' appear in non-endpoint contexts + test("should return false for URLs with 'embeddings' in non-endpoint contexts", () => { + const testUrls = [ + "https://api.example.com/embeddings-service/v1", + "https://embeddings.example.com/api", + "https://api.example.com/v1/embeddings-api", + "https://my-embeddings-provider.com/v1", + ] + + testUrls.forEach((url) => { + const embedder = new OpenAICompatibleEmbedder(url, testApiKey, testModelId) + const isFullUrl = (embedder as any).isFullEndpointUrl(url) + expect(isFullUrl).toBe(false) + }) + }) + + test("should return false for URLs with 'deployments' in non-endpoint contexts", () => { + const testUrls = [ + "https://deployments.example.com/api", + "https://api.deployments.com/v1", + "https://my-deployments-service.com/api/v1", + "https://deployments-manager.example.com", + ] + + testUrls.forEach((url) => { + const embedder = new OpenAICompatibleEmbedder(url, testApiKey, testModelId) + const isFullUrl = (embedder as any).isFullEndpointUrl(url) + expect(isFullUrl).toBe(false) + }) + }) + + test("should correctly identify actual endpoint URLs", () => { + const endpointUrls = [ + "https://api.example.com/v1/embeddings", + "https://api.example.com/v1/embeddings?api-version=2024", + "https://myresource.openai.azure.com/openai/deployments/mymodel/embeddings", + "https://api.example.com/embed", + "https://api.example.com/embed?version=1", + ] + + endpointUrls.forEach((url) => { + const embedder = new OpenAICompatibleEmbedder(url, testApiKey, testModelId) + const isFullUrl = (embedder as any).isFullEndpointUrl(url) + expect(isFullUrl).toBe(true) + }) + }) + }) + + describe("direct HTTP requests", () => { + test("should use direct fetch for Azure URLs and SDK for base URLs", async () => { + const testTexts = ["Test text"] + const base64String = createBase64Embedding([0.1, 0.2, 0.3]) + + // Test Azure URL (direct fetch) + const azureEmbedder = new OpenAICompatibleEmbedder(azureUrl, testApiKey, testModelId) + const mockFetchResponse = createMockResponse({ + data: [{ embedding: base64String }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }) + mockFetch.mockResolvedValue(mockFetchResponse as any) + + const azureResult = await azureEmbedder.createEmbeddings(testTexts) + expect(mockFetch).toHaveBeenCalledWith( + azureUrl, + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "api-key": testApiKey, + Authorization: `Bearer ${testApiKey}`, + }), + }), + ) + expect(mockEmbeddingsCreate).not.toHaveBeenCalled() + expectEmbeddingValues(azureResult.embeddings[0], [0.1, 0.2, 0.3]) + + // Reset and test base URL (SDK) + mockEmbeddingsCreate.mockReset() + mockFetch.mockReset() + const baseEmbedder = new OpenAICompatibleEmbedder(baseUrl, testApiKey, testModelId) + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: [0.4, 0.5, 0.6] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }) + + const baseResult = await baseEmbedder.createEmbeddings(testTexts) + expect(mockEmbeddingsCreate).toHaveBeenCalled() + expect(mockFetch).not.toHaveBeenCalled() + expect(baseResult.embeddings[0]).toEqual([0.4, 0.5, 0.6]) + }) + + test.each([ + [401, "Authentication failed. Please check your API key."], + [500, "Embedding request failed after 3 attempts"], + ])("should handle HTTP errors: %d", async (status, expectedMessage) => { + const embedder = new OpenAICompatibleEmbedder(azureUrl, testApiKey, testModelId) + const mockResponse = createMockResponse({}, status, false) + mockFetch.mockResolvedValue(mockResponse as any) + + await expect(embedder.createEmbeddings(["test"])).rejects.toThrow(expectedMessage) + }) + + // TODO: bun:test doesn't support fake timers + test.skip("should handle rate limiting with retries", async () => { + const embedder = new OpenAICompatibleEmbedder(azureUrl, testApiKey, testModelId) + const base64String = createBase64Embedding([0.1, 0.2, 0.3]) + + mockFetch + .mockResolvedValueOnce(createMockResponse({}, 429, false) as any) + .mockResolvedValueOnce(createMockResponse({}, 429, false) as any) + .mockResolvedValueOnce( + createMockResponse({ + data: [{ embedding: base64String }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }) as any, + ) + + const result = await embedder.createEmbeddings(["test"]) + + expect(mockFetch).toHaveBeenCalledTimes(3) + expectEmbeddingValues(result.embeddings[0], [0.1, 0.2, 0.3]) + }) + + test("should handle multiple embeddings and network errors", async () => { + const embedder = new OpenAICompatibleEmbedder(azureUrl, testApiKey, testModelId) + + // Test multiple embeddings + const base64_1 = createBase64Embedding([0.25, 0.5]) + const base64_2 = createBase64Embedding([0.75, 1.0]) + const mockResponse = createMockResponse({ + data: [{ embedding: base64_1 }, { embedding: base64_2 }], + usage: { prompt_tokens: 20, total_tokens: 30 }, + }) + mockFetch.mockResolvedValue(mockResponse as any) + + const result = await embedder.createEmbeddings(["test1", "test2"]) + expect(result.embeddings).toHaveLength(2) + expectEmbeddingValues(result.embeddings[0], [0.25, 0.5]) + expectEmbeddingValues(result.embeddings[1], [0.75, 1.0]) + + // Test network error + const networkError = new Error("Network failed") + mockFetch.mockRejectedValue(networkError) + await expect(embedder.createEmbeddings(["test"])).rejects.toThrow("Embedding request failed after 3 attempts") + }) + }) + }) + }) + + describe("URL detection", () => { + test("should detect Azure deployment URLs as full endpoints", async () => { + const embedder = new OpenAICompatibleEmbedder( + "https://myinstance.openai.azure.com/openai/deployments/my-deployment/embeddings?api-version=2023-05-15", + "test-key", + ) + + // The private method is tested indirectly through the createEmbeddings behavior + // If it's detected as a full URL, it will make a direct HTTP request + const localMockFetch = mock().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }), + }) + global.fetch = localMockFetch as any + + await embedder.createEmbeddings(["test"]) + + // Should make direct HTTP request to the full URL + expect(localMockFetch).toHaveBeenCalledWith( + "https://myinstance.openai.azure.com/openai/deployments/my-deployment/embeddings?api-version=2023-05-15", + expect.any(Object), + ) + + // Restore the module-level mock + global.fetch = mockFetch + }) + + test("should detect /embed endpoints as full URLs", async () => { + const embedder = new OpenAICompatibleEmbedder("https://api.example.com/v1/embed", "test-key") + + const localMockFetch = mock().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [{ embedding: [0.1, 0.2] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }), + }) + global.fetch = localMockFetch as any + + await embedder.createEmbeddings(["test"]) + + // Should make direct HTTP request to the full URL + expect(localMockFetch).toHaveBeenCalledWith("https://api.example.com/v1/embed", expect.any(Object)) + + // Restore the module-level mock + global.fetch = mockFetch + }) + + test("should treat base URLs without endpoint patterns as SDK URLs", async () => { + const embedder = new OpenAICompatibleEmbedder("https://api.openai.com/v1", "test-key") + + // Mock the OpenAI SDK's embeddings.create method + const localMockCreate = mock().mockResolvedValue({ + data: [{ embedding: [0.1, 0.2] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }) + embedder["embeddingsClient"].embeddings = { + create: localMockCreate, + } as any + + await embedder.createEmbeddings(["test"]) + + // Should use SDK which will append /embeddings + expect(localMockCreate).toHaveBeenCalled() + }) + }) + + describe("validateConfiguration", () => { + let embedder: OpenAICompatibleEmbedder + + beforeEach(() => { + mockEmbeddingsCreate.mockReset() + mockFetch.mockReset() + }) + + test("should validate successfully with valid configuration and base URL", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + expect(mockEmbeddingsCreate).toHaveBeenCalledWith( + { + input: ["test"], + model: testModelId, + encoding_format: "base64", + }, + { + timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, + maxRetries: REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, + }, + ) + }) + + test("should validate successfully with full endpoint URL", async () => { + const fullUrl = "https://api.example.com/v1/embeddings" + embedder = new OpenAICompatibleEmbedder(fullUrl, testApiKey, testModelId) + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }), + text: async () => "", + } as any) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + expect(mockFetch).toHaveBeenCalledWith( + fullUrl, + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: `Bearer ${testApiKey}`, + }), + }), + ) + }) + + test("should fail validation with authentication error", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + + const authError = new Error("Invalid API key") + ;(authError as any).status = 401 + mockEmbeddingsCreate.mockRejectedValue(authError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Authentication failed. Please check your API key.") + }) + + test("should fail validation with connection error", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + + const connectionError = new Error("ECONNREFUSED") + mockEmbeddingsCreate.mockRejectedValue(connectionError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Connection failed. Please check the endpoint URL and network connectivity.") + }) + + test("should fail validation with invalid endpoint for full URL", async () => { + const fullUrl = "https://api.example.com/v1/embeddings" + embedder = new OpenAICompatibleEmbedder(fullUrl, testApiKey, testModelId) + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ error: "Not found" }), + text: async () => "Not found", + } as any) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + // 404 for non-openai embedder returns "Invalid endpoint URL. Please verify the endpoint." + expect(result.error).toBe("Invalid endpoint URL. Please verify the endpoint.") + }) + + test("should fail validation with rate limit error", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + + const rateLimitError = new Error("Rate limit exceeded") + ;(rateLimitError as any).status = 429 + mockEmbeddingsCreate.mockRejectedValue(rateLimitError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Service is temporarily unavailable due to rate limiting. Please try again later.") + }) + + test("should fail validation with generic error", async () => { + embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId) + + const genericError = new Error("Unknown error") + ;(genericError as any).status = 500 + mockEmbeddingsCreate.mockRejectedValue(genericError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Configuration error. Please verify your embedder settings.") + }) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/openai.test.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/openai.test.ts new file mode 100644 index 00000000000..430dd698abf --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/openai.test.ts @@ -0,0 +1,466 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" + +import { + MAX_ITEM_TOKENS, + REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, + REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, +} from "../../../../src/indexing/constants" +import { mockEmbeddingsCreate, openAIMockFactory } from "./__helpers__/openai-mock" + +mock.module("openai", openAIMockFactory) + +import { OpenAiEmbedder } from "../../../../src/indexing/embedders/openai" + +describe("OpenAiEmbedder", () => { + let embedder: OpenAiEmbedder + + beforeEach(() => { + mockEmbeddingsCreate.mockReset() + + embedder = new OpenAiEmbedder("test-api-key", "text-embedding-3-small") + }) + + afterEach(() => { + mockEmbeddingsCreate.mockReset() + }) + + describe("constructor", () => { + test("should initialize with provided options", () => { + expect(embedder.embedderInfo.name).toBe("openai") + }) + + test("should use default model if not specified", () => { + const embedderWithDefaultModel = new OpenAiEmbedder("test-api-key") + expect(embedderWithDefaultModel).toBeDefined() + }) + }) + + describe("createEmbeddings", () => { + const testModelId = "text-embedding-3-small" + + test("should create embeddings for a single text", async () => { + const testTexts = ["Hello world"] + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(testTexts) + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: testTexts, + model: testModelId, + }) + expect(result).toEqual({ + embeddings: [[0.1, 0.2, 0.3]], + usage: { promptTokens: 10, totalTokens: 15 }, + }) + }) + + test("should create embeddings for multiple texts", async () => { + const testTexts = ["Hello world", "Another text"] + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }, { embedding: [0.4, 0.5, 0.6] }], + usage: { prompt_tokens: 20, total_tokens: 30 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(testTexts) + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: testTexts, + model: testModelId, + }) + expect(result).toEqual({ + embeddings: [ + [0.1, 0.2, 0.3], + [0.4, 0.5, 0.6], + ], + usage: { promptTokens: 20, totalTokens: 30 }, + }) + }) + + test("should use custom model when provided", async () => { + const testTexts = ["Hello world"] + const customModel = "text-embedding-ada-002" + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + await embedder.createEmbeddings(testTexts, customModel) + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: testTexts, + model: customModel, + }) + }) + + test("should handle missing usage data gracefully", async () => { + const testTexts = ["Hello world"] + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: undefined, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(testTexts) + + expect(result).toEqual({ + embeddings: [[0.1, 0.2, 0.3]], + usage: { promptTokens: 0, totalTokens: 0 }, + }) + }) + + /** + * Test batching logic when texts exceed token limits + */ + describe("batching logic", () => { + test("should process texts in batches", async () => { + // Use normal sized texts that won't be skipped + const testTexts = ["text1", "text2", "text3"] + + mockEmbeddingsCreate.mockResolvedValue({ + data: testTexts.map((_, i) => ({ embedding: [i, i + 0.1, i + 0.2] })), + usage: { prompt_tokens: 30, total_tokens: 45 }, + }) + + const result = await embedder.createEmbeddings(testTexts) + + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1) + expect(result.embeddings).toHaveLength(3) + expect(result.usage?.promptTokens).toBe(30) + }) + + test("should skip texts exceeding maximum token limit", async () => { + // Create a text that exceeds MAX_ITEM_TOKENS (4 characters ~ 1 token) + const oversizedText = "a".repeat(MAX_ITEM_TOKENS * 4 + 100) + const normalText = "normal text" + const testTexts = [normalText, oversizedText, "another normal"] + + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: [0.1, 0.2, 0.3] }, { embedding: [0.4, 0.5, 0.6] }], + usage: { prompt_tokens: 20, total_tokens: 30 }, + }) + + const result = await embedder.createEmbeddings(testTexts) + + // Verify only normal texts were processed + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: [normalText, "another normal"], + model: testModelId, + }) + expect(result.embeddings).toHaveLength(2) + }) + + test("should handle multiple batches when total tokens exceed batch limit", async () => { + // Create texts that will require multiple batches + // Each text needs to be less than MAX_ITEM_TOKENS (8191) but together exceed MAX_BATCH_TOKENS (100000) + // Let's use 8000 tokens per text (safe under MAX_ITEM_TOKENS) + const tokensPerText = 8000 + const largeText = "a".repeat(tokensPerText * 4) // 4 chars ~ 1 token + // Create 15 texts * 8000 tokens = 120000 tokens total + const testTexts = Array(15).fill(largeText) + + // Mock responses for each batch + // First batch will have 12 texts (96000 tokens), second batch will have 3 texts (24000 tokens) + mockEmbeddingsCreate + .mockResolvedValueOnce({ + data: Array(12) + .fill(null) + .map((_, i) => ({ embedding: [i * 0.1, i * 0.1 + 0.1, i * 0.1 + 0.2] })), + usage: { prompt_tokens: 96000, total_tokens: 96000 }, + }) + .mockResolvedValueOnce({ + data: Array(3) + .fill(null) + .map((_, i) => ({ + embedding: [(12 + i) * 0.1, (12 + i) * 0.1 + 0.1, (12 + i) * 0.1 + 0.2], + })), + usage: { prompt_tokens: 24000, total_tokens: 24000 }, + }) + + const result = await embedder.createEmbeddings(testTexts) + + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(2) + expect(result.embeddings).toHaveLength(15) + expect(result.usage?.promptTokens).toBe(120000) + expect(result.usage?.totalTokens).toBe(120000) + }) + + test("should handle all texts being skipped due to size", async () => { + const oversizedText = "a".repeat(MAX_ITEM_TOKENS * 4 + 100) + const testTexts = [oversizedText, oversizedText] + + const result = await embedder.createEmbeddings(testTexts) + + expect(mockEmbeddingsCreate).not.toHaveBeenCalled() + expect(result).toEqual({ + embeddings: [], + usage: { promptTokens: 0, totalTokens: 0 }, + }) + }) + }) + + /** + * Test retry logic for rate limiting and other errors + */ + describe("retry logic", () => { + // TODO: bun:test doesn't support fake timers + test.skip("should retry on rate limit errors with exponential backoff", async () => { + const testTexts = ["Hello world"] + const rateLimitError = { status: 429, message: "Rate limit exceeded" } + + mockEmbeddingsCreate + .mockRejectedValueOnce(rateLimitError) + .mockRejectedValueOnce(rateLimitError) + .mockResolvedValueOnce({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + }) + + const result = await embedder.createEmbeddings(testTexts) + + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(3) + expect(result).toEqual({ + embeddings: [[0.1, 0.2, 0.3]], + usage: { promptTokens: 10, totalTokens: 15 }, + }) + }) + + test("should not retry on non-rate-limit errors", async () => { + const testTexts = ["Hello world"] + const authError = new Error("Unauthorized") + ;(authError as any).status = 401 + + mockEmbeddingsCreate.mockRejectedValue(authError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Authentication failed. Please check your API key.", + ) + + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1) + }) + + test("should throw error immediately on non-retryable errors", async () => { + const testTexts = ["Hello world"] + const serverError = new Error("Internal server error") + ;(serverError as any).status = 500 + + mockEmbeddingsCreate.mockRejectedValue(serverError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts with status 500: Internal server error", + ) + + expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1) + }) + }) + + /** + * Test error handling scenarios + */ + describe("error handling", () => { + test("should handle API errors gracefully", async () => { + const testTexts = ["Hello world"] + const apiError = new Error("API connection failed") + + mockEmbeddingsCreate.mockRejectedValue(apiError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts: API connection failed", + ) + }) + + test("should handle empty text arrays", async () => { + const testTexts: string[] = [] + + const result = await embedder.createEmbeddings(testTexts) + + expect(result).toEqual({ + embeddings: [], + usage: { promptTokens: 0, totalTokens: 0 }, + }) + expect(mockEmbeddingsCreate).not.toHaveBeenCalled() + }) + + test("should handle malformed API responses", async () => { + const testTexts = ["Hello world"] + const malformedResponse = { + data: null, + usage: { prompt_tokens: 10, total_tokens: 15 }, + } + + mockEmbeddingsCreate.mockResolvedValue(malformedResponse) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow() + }) + + test("should provide specific authentication error message", async () => { + const testTexts = ["Hello world"] + const authError = new Error("Invalid API key") + ;(authError as any).status = 401 + + mockEmbeddingsCreate.mockRejectedValue(authError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Authentication failed. Please check your API key.", + ) + }) + + test("should provide detailed error message for HTTP errors", async () => { + const testTexts = ["Hello world"] + const httpError = new Error("Bad request") + ;(httpError as any).status = 400 + + mockEmbeddingsCreate.mockRejectedValue(httpError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts with status 400: Bad request", + ) + }) + + test("should handle errors without status codes", async () => { + const testTexts = ["Hello world"] + const networkError = new Error("Network timeout") + + mockEmbeddingsCreate.mockRejectedValue(networkError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts: Network timeout", + ) + }) + + test("should handle errors without message property", async () => { + const testTexts = ["Hello world"] + const weirdError = { toString: () => "Custom error object" } + + mockEmbeddingsCreate.mockRejectedValue(weirdError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts: Custom error object", + ) + }) + + test("should handle completely unknown error types", async () => { + const testTexts = ["Hello world"] + const unknownError = null + + mockEmbeddingsCreate.mockRejectedValue(unknownError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts: Unknown error", + ) + }) + + test("should handle string errors", async () => { + const testTexts = ["Hello world"] + const stringError = "Something went wrong" + + mockEmbeddingsCreate.mockRejectedValue(stringError) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts: Something went wrong", + ) + }) + + test("should handle errors with failing toString method", async () => { + const testTexts = ["Hello world"] + const errorWithFailingToString = { + toString: () => { + throw new Error("toString failed") + }, + } + + mockEmbeddingsCreate.mockRejectedValue(errorWithFailingToString) + + // The error handler catches the failing toString and falls back to "Unknown error" + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow() + }) + + test("should handle errors from response.status property", async () => { + const testTexts = ["Hello world"] + const errorWithResponseStatus = { + message: "Request failed", + response: { status: 403 }, + } + + mockEmbeddingsCreate.mockRejectedValue(errorWithResponseStatus) + + await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow( + "Embedding request failed after 3 attempts with status 403: Request failed", + ) + }) + }) + }) + + describe("validateConfiguration", () => { + test("should validate successfully with valid configuration", async () => { + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + expect(mockEmbeddingsCreate).toHaveBeenCalledWith( + { + input: ["test"], + model: "text-embedding-3-small", + }, + { + timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, + maxRetries: REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, + }, + ) + }) + + test("should fail validation with authentication error", async () => { + const authError = new Error("Invalid API key") + ;(authError as any).status = 401 + mockEmbeddingsCreate.mockRejectedValue(authError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Authentication failed. Please check your API key.") + }) + + test("should fail validation with rate limit error", async () => { + const rateLimitError = new Error("Rate limit exceeded") + ;(rateLimitError as any).status = 429 + mockEmbeddingsCreate.mockRejectedValue(rateLimitError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Service is temporarily unavailable due to rate limiting. Please try again later.") + }) + + test("should fail validation with connection error", async () => { + const connectionError = new Error("ECONNREFUSED") + mockEmbeddingsCreate.mockRejectedValue(connectionError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Connection failed. Please check the endpoint URL and network connectivity.") + }) + + test("should fail validation with generic error", async () => { + const genericError = new Error("Unknown error") + ;(genericError as any).status = 500 + mockEmbeddingsCreate.mockRejectedValue(genericError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Configuration error. Please verify your embedder settings.") + }) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/openrouter.test.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/openrouter.test.ts new file mode 100644 index 00000000000..729e5c77875 --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/openrouter.test.ts @@ -0,0 +1,425 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" +import { mockEmbeddingsCreate, openAIMockFactory } from "./__helpers__/openai-mock" + +mock.module("openai", openAIMockFactory) + +import { OpenRouterEmbedder, OPENROUTER_DEFAULT_PROVIDER_NAME } from "../../../../src/indexing/embedders/openrouter" +import { + REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, + REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, +} from "../../../../src/indexing/constants" +import { getModelDimension, getDefaultModelId } from "../../../../src/indexing/model-registry" +import { DEFAULT_HEADERS } from "../../../../src/headers" + +describe("OpenRouterEmbedder", () => { + const mockApiKey = "test-api-key" + + beforeEach(() => { + mockEmbeddingsCreate.mockReset() + }) + + describe("constructor", () => { + test("should create an instance with valid API key", () => { + const embedder = new OpenRouterEmbedder(mockApiKey) + expect(embedder).toBeInstanceOf(OpenRouterEmbedder) + }) + + test("should throw error with empty API key", () => { + expect(() => new OpenRouterEmbedder("")).toThrow("API key is required") + }) + + test("should use default model when none specified", () => { + const embedder = new OpenRouterEmbedder(mockApiKey) + expect(embedder.embedderInfo.name).toBe("openrouter") + }) + + test("should use custom model when specified", () => { + const customModel = "openai/text-embedding-3-small" + const embedder = new OpenRouterEmbedder(mockApiKey, customModel) + expect(embedder.embedderInfo.name).toBe("openrouter") + }) + + test("should accept specificProvider parameter", () => { + const embedder = new OpenRouterEmbedder(mockApiKey, undefined, undefined, "together") + expect(embedder).toBeInstanceOf(OpenRouterEmbedder) + }) + + test("should ignore default provider name as specificProvider", () => { + const embedder = new OpenRouterEmbedder(mockApiKey, undefined, undefined, OPENROUTER_DEFAULT_PROVIDER_NAME) + expect(embedder).toBeInstanceOf(OpenRouterEmbedder) + }) + }) + + describe("embedderInfo", () => { + test("should return correct embedder info", () => { + const embedder = new OpenRouterEmbedder(mockApiKey) + expect(embedder.embedderInfo).toEqual({ + name: "openrouter", + }) + }) + }) + + describe("createEmbeddings", () => { + let embedder: OpenRouterEmbedder + const defaultModel = getDefaultModelId("openrouter") + + beforeEach(() => { + embedder = new OpenRouterEmbedder(mockApiKey) + }) + + test("should create embeddings successfully", async () => { + // Create base64 encoded embedding with values that can be exactly represented in Float32 + const testEmbedding = new Float32Array([0.25, 0.5, 0.75]) + const base64String = Buffer.from(testEmbedding.buffer).toString("base64") + + const mockResponse = { + data: [ + { + embedding: base64String, + }, + ], + usage: { + prompt_tokens: 5, + total_tokens: 5, + }, + } + + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(["test text"]) + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: ["test text"], + model: defaultModel, + encoding_format: "base64", + }) + expect(result.embeddings).toHaveLength(1) + expect(result.embeddings[0]).toEqual([0.25, 0.5, 0.75]) + expect(result.usage?.promptTokens).toBe(5) + expect(result.usage?.totalTokens).toBe(5) + }) + + test("should handle multiple texts", async () => { + const embedding1 = new Float32Array([0.25, 0.5]) + const embedding2 = new Float32Array([0.75, 1.0]) + const base64String1 = Buffer.from(embedding1.buffer).toString("base64") + const base64String2 = Buffer.from(embedding2.buffer).toString("base64") + + const mockResponse = { + data: [ + { + embedding: base64String1, + }, + { + embedding: base64String2, + }, + ], + usage: { + prompt_tokens: 10, + total_tokens: 10, + }, + } + + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(["text1", "text2"]) + + expect(result.embeddings).toHaveLength(2) + expect(result.embeddings[0]).toEqual([0.25, 0.5]) + expect(result.embeddings[1]).toEqual([0.75, 1.0]) + }) + + test("should use custom model when provided", async () => { + const customModel = "mistralai/mistral-embed-2312" + const embedderWithCustomModel = new OpenRouterEmbedder(mockApiKey, customModel) + + const testEmbedding = new Float32Array([0.25, 0.5]) + const base64String = Buffer.from(testEmbedding.buffer).toString("base64") + + const mockResponse = { + data: [ + { + embedding: base64String, + }, + ], + usage: { + prompt_tokens: 5, + total_tokens: 5, + }, + } + + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + await embedderWithCustomModel.createEmbeddings(["test"]) + + // Verify the embeddings.create was called with the custom model + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: ["test"], + model: customModel, + encoding_format: "base64", + }) + }) + + test("should include provider routing when specificProvider is set", async () => { + const specificProvider = "together" + const embedderWithProvider = new OpenRouterEmbedder(mockApiKey, undefined, undefined, specificProvider) + + const testEmbedding = new Float32Array([0.25, 0.5]) + const base64String = Buffer.from(testEmbedding.buffer).toString("base64") + + const mockResponse = { + data: [ + { + embedding: base64String, + }, + ], + usage: { + prompt_tokens: 5, + total_tokens: 5, + }, + } + + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + await embedderWithProvider.createEmbeddings(["test"]) + + // Verify the embeddings.create was called with provider routing + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: ["test"], + model: defaultModel, + encoding_format: "base64", + provider: { + order: [specificProvider], + only: [specificProvider], + allow_fallbacks: false, + }, + }) + }) + + test("should include dimensions when configured", async () => { + const embedderWithDimensions = new OpenRouterEmbedder(mockApiKey, undefined, undefined, undefined, 1024) + + const testEmbedding = new Float32Array([0.25, 0.5]) + const base64String = Buffer.from(testEmbedding.buffer).toString("base64") + + const mockResponse = { + data: [ + { + embedding: base64String, + }, + ], + usage: { + prompt_tokens: 5, + total_tokens: 5, + }, + } + + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + await embedderWithDimensions.createEmbeddings(["test"]) + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: ["test"], + model: defaultModel, + encoding_format: "base64", + dimensions: 1024, + }) + }) + + test("should not include provider routing when specificProvider is default", async () => { + const embedderWithDefaultProvider = new OpenRouterEmbedder( + mockApiKey, + undefined, + undefined, + OPENROUTER_DEFAULT_PROVIDER_NAME, + ) + + const testEmbedding = new Float32Array([0.25, 0.5]) + const base64String = Buffer.from(testEmbedding.buffer).toString("base64") + + const mockResponse = { + data: [ + { + embedding: base64String, + }, + ], + usage: { + prompt_tokens: 5, + total_tokens: 5, + }, + } + + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + await embedderWithDefaultProvider.createEmbeddings(["test"]) + + // Verify the embeddings.create was called without provider routing + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: ["test"], + model: defaultModel, + encoding_format: "base64", + }) + }) + }) + + describe("validateConfiguration", () => { + let embedder: OpenRouterEmbedder + const defaultModel = getDefaultModelId("openrouter") + + beforeEach(() => { + embedder = new OpenRouterEmbedder(mockApiKey) + }) + + test("should validate configuration successfully", async () => { + const testEmbedding = new Float32Array([0.25, 0.5]) + const base64String = Buffer.from(testEmbedding.buffer).toString("base64") + + const mockResponse = { + data: [ + { + embedding: base64String, + }, + ], + usage: { + prompt_tokens: 1, + total_tokens: 1, + }, + } + + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + expect(mockEmbeddingsCreate).toHaveBeenCalledWith( + { + input: ["test"], + model: defaultModel, + encoding_format: "base64", + }, + { + timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, + maxRetries: REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, + }, + ) + }) + + test("should handle validation failure", async () => { + const authError = new Error("Invalid API key") + ;(authError as any).status = 401 + + mockEmbeddingsCreate.mockRejectedValue(authError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Authentication failed. Please check your API key.") + }) + + test("should validate configuration with specificProvider", async () => { + const specificProvider = "openai" + const embedderWithProvider = new OpenRouterEmbedder(mockApiKey, undefined, undefined, specificProvider) + + const testEmbedding = new Float32Array([0.25, 0.5]) + const base64String = Buffer.from(testEmbedding.buffer).toString("base64") + + const mockResponse = { + data: [ + { + embedding: base64String, + }, + ], + usage: { + prompt_tokens: 1, + total_tokens: 1, + }, + } + + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedderWithProvider.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + expect(mockEmbeddingsCreate).toHaveBeenCalledWith( + { + input: ["test"], + model: defaultModel, + encoding_format: "base64", + provider: { + order: [specificProvider], + only: [specificProvider], + allow_fallbacks: false, + }, + }, + { + timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, + maxRetries: REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, + }, + ) + }) + + test("should include dimensions in validation when configured", async () => { + const embedderWithDimensions = new OpenRouterEmbedder(mockApiKey, undefined, undefined, undefined, 1024) + + const testEmbedding = new Float32Array([0.25, 0.5]) + const base64String = Buffer.from(testEmbedding.buffer).toString("base64") + + const mockResponse = { + data: [ + { + embedding: base64String, + }, + ], + usage: { + prompt_tokens: 1, + total_tokens: 1, + }, + } + + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedderWithDimensions.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + expect(mockEmbeddingsCreate).toHaveBeenCalledWith( + { + input: ["test"], + model: defaultModel, + encoding_format: "base64", + dimensions: 1024, + }, + { + timeout: REMOTE_EMBEDDER_VALIDATION_TIMEOUT_MS, + maxRetries: REMOTE_EMBEDDER_VALIDATION_MAX_RETRIES, + }, + ) + }) + }) + + describe("integration with shared models", () => { + test("should work with defined OpenRouter models", () => { + // Only models present in the new model-registry + const openRouterModels = ["openai/text-embedding-3-small", "openai/text-embedding-3-large"] + + openRouterModels.forEach((model) => { + const dimension = getModelDimension("openrouter", model) + expect(dimension).toBeDefined() + expect(dimension).toBeGreaterThan(0) + + const embedder = new OpenRouterEmbedder(mockApiKey, model) + expect(embedder.embedderInfo.name).toBe("openrouter") + }) + }) + + test("should use correct default model", () => { + const defaultModel = getDefaultModelId("openrouter") + expect(defaultModel).toBe("openai/text-embedding-3-small") + + const dimension = getModelDimension("openrouter", defaultModel) + expect(dimension).toBe(1536) + }) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/embedders/vercel-ai-gateway.test.ts b/packages/kilo-indexing/test/kilocode/indexing/embedders/vercel-ai-gateway.test.ts new file mode 100644 index 00000000000..9786950089c --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/embedders/vercel-ai-gateway.test.ts @@ -0,0 +1,126 @@ +import { describe, test, expect, beforeEach, mock } from "bun:test" +import { mockEmbeddingsCreate, openAIMockFactory } from "./__helpers__/openai-mock" + +// RATIONALE: Test VercelAiGatewayEmbedder through the real OpenAICompatibleEmbedder with mocked OpenAI SDK. +// Mocking the openai-compatible module with mock.module() is process-wide in Bun and would +// interfere with openai-compatible.test.ts which needs the real implementation. +mock.module("openai", openAIMockFactory) + +import { VercelAiGatewayEmbedder } from "../../../../src/indexing/embedders/vercel-ai-gateway" + +describe("VercelAiGatewayEmbedder", () => { + let embedder: VercelAiGatewayEmbedder + + beforeEach(() => { + mockEmbeddingsCreate.mockReset() + }) + + describe("constructor", () => { + test("should create VercelAiGatewayEmbedder with default model", () => { + embedder = new VercelAiGatewayEmbedder("test-vercel-api-key") + expect(embedder).toBeDefined() + }) + + test("should create VercelAiGatewayEmbedder with custom model", () => { + embedder = new VercelAiGatewayEmbedder("test-vercel-api-key", "openai/text-embedding-3-small") + expect(embedder).toBeDefined() + }) + + test("should throw error when API key is missing", () => { + expect(() => new VercelAiGatewayEmbedder("")).toThrow("API key is required for Vercel AI Gateway embedder") + }) + }) + + describe("createEmbeddings", () => { + beforeEach(() => { + embedder = new VercelAiGatewayEmbedder("test-api-key") + }) + + test("should delegate to OpenAICompatibleEmbedder with default model", async () => { + const texts = ["test text 1", "test text 2"] + const mockResponse = { + data: [{ embedding: [0.1, 0.2] }, { embedding: [0.3, 0.4] }], + usage: { prompt_tokens: 10, total_tokens: 15 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(texts) + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: texts, + model: "openai/text-embedding-3-large", + encoding_format: "base64", + }) + expect(result.embeddings).toEqual([ + [0.1, 0.2], + [0.3, 0.4], + ]) + }) + + test("should delegate to OpenAICompatibleEmbedder with custom model", async () => { + const texts = ["test text"] + const customModel = "google/gemini-embedding-001" + const mockResponse = { + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 5, total_tokens: 5 }, + } + mockEmbeddingsCreate.mockResolvedValue(mockResponse) + + const result = await embedder.createEmbeddings(texts, customModel) + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: texts, + model: customModel, + encoding_format: "base64", + }) + expect(result.embeddings).toEqual([[0.1, 0.2, 0.3]]) + }) + + test("should handle errors from OpenAICompatibleEmbedder", async () => { + const texts = ["test text"] + const error = new Error("API request failed") + mockEmbeddingsCreate.mockRejectedValue(error) + + await expect(embedder.createEmbeddings(texts)).rejects.toThrow() + }) + }) + + describe("validateConfiguration", () => { + beforeEach(() => { + embedder = new VercelAiGatewayEmbedder("test-api-key") + }) + + test("should validate successfully", async () => { + mockEmbeddingsCreate.mockResolvedValue({ + data: [{ embedding: [0.1, 0.2, 0.3] }], + usage: { prompt_tokens: 2, total_tokens: 2 }, + }) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(true) + expect(result.error).toBeUndefined() + }) + + test("should handle validation errors", async () => { + const authError = new Error("Invalid API key") + ;(authError as any).status = 401 + mockEmbeddingsCreate.mockRejectedValue(authError) + + const result = await embedder.validateConfiguration() + + expect(result.valid).toBe(false) + expect(result.error).toBe("Authentication failed. Please check your API key.") + }) + }) + + describe("embedderInfo", () => { + test("should return correct embedder info", () => { + embedder = new VercelAiGatewayEmbedder("test-api-key") + + expect(embedder.embedderInfo).toEqual({ + name: "vercel-ai-gateway", + }) + }) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/manager.test.ts b/packages/kilo-indexing/test/kilocode/indexing/manager.test.ts new file mode 100644 index 00000000000..cbffc433eac --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/manager.test.ts @@ -0,0 +1,454 @@ +import { describe, expect, test } from "bun:test" +import { CodeIndexManager } from "../../../src/indexing/manager" +import type { IndexingConfigInput } from "../../../src/indexing/config-manager" +import type { IndexingTelemetryEvent, IndexingTelemetryTrigger } from "../../../src/indexing/interfaces/telemetry" + +function createInput(input: Partial = {}): IndexingConfigInput { + return { + enabled: true, + embedderProvider: "openai", + vectorStoreProvider: "lancedb", + ...input, + } +} + +type Data = { + _configManager: { + isFeatureEnabled: boolean + isFeatureConfigured: boolean + getConfig(): { + embedderProvider: "openai" + vectorStoreProvider: "lancedb" + modelId: string + } + } + _orchestrator?: { + state: string + stopWatcher(): void + startIndexing(trigger: IndexingTelemetryTrigger): Promise + } + _searchService?: {} + _cacheManager: {} + _stateManager: { + setSystemState(state: "Standby" | "Indexing" | "Indexed" | "Error", message?: string): void + } + _retryTask?: Promise + _retryMaxAttempts: number + _retryInitialDelayMs: number + _recreateServices(): Promise + handleTelemetry(event: IndexingTelemetryEvent): void +} + +function createData(mgr: CodeIndexManager): Data { + const data = mgr as unknown as Data + data._configManager = { + isFeatureEnabled: true, + isFeatureConfigured: true, + getConfig() { + return { + embedderProvider: "openai", + vectorStoreProvider: "lancedb", + modelId: "text-embedding-3-small", + } + }, + } + data._cacheManager = {} + data._searchService = {} + data._retryMaxAttempts = 1 + data._retryInitialDelayMs = 0 + return data +} + +function createStartError(location = "orchestrator:startIndexing"): IndexingTelemetryEvent { + return { + type: "error", + source: "scan", + location, + trigger: "background", + error: "fail", + provider: "openai", + vectorStore: "lancedb", + modelId: "text-embedding-3-small", + } +} + +describe("CodeIndexManager", () => { + test("returns standby state before services are initialized", () => { + const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") + const data = mgr as unknown as { + _configManager: { + isFeatureEnabled: boolean + } + } + + data._configManager = { + isFeatureEnabled: true, + } + + expect(() => mgr.state).not.toThrow() + expect(mgr.state).toBe("Standby") + }) + + test("does not throw when indexing is enabled but not configured", async () => { + const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") + + await mgr.initialize(createInput({ openAiKey: undefined })) + + expect(mgr.isFeatureEnabled).toBe(true) + expect(mgr.isFeatureConfigured).toBe(false) + expect(mgr.getCurrentStatus().systemStatus).toBe("Standby") + expect(mgr.getCurrentStatus().message).toContain("not configured") + }) + + test("cancels active indexing when configuration is removed", async () => { + const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") + let stop = 0 + let cancel = 0 + const data = mgr as unknown as { + _orchestrator?: { + stopWatcher(): void + cancelIndexing(): void + } + } + + data._orchestrator = { + stopWatcher() { + stop += 1 + }, + cancelIndexing() { + cancel += 1 + }, + } + + await mgr.initialize(createInput({ openAiKey: undefined })) + + expect(cancel).toBe(1) + expect(stop).toBe(0) + }) + + test("emits manual indexing start telemetry", async () => { + const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") + const events: IndexingTelemetryEvent[] = [] + const data = mgr as unknown as { + _configManager: { + isFeatureEnabled: boolean + isFeatureConfigured: boolean + getConfig(): { + embedderProvider: "openai" + vectorStoreProvider: "lancedb" + modelId: string + } + } + _orchestrator: { + state: string + startIndexing(trigger: IndexingTelemetryTrigger): Promise + } + _searchService: {} + _cacheManager: {} + } + + let trigger: IndexingTelemetryTrigger | undefined + data._configManager = { + isFeatureEnabled: true, + isFeatureConfigured: true, + getConfig() { + return { + embedderProvider: "openai", + vectorStoreProvider: "lancedb", + modelId: "text-embedding-3-small", + } + }, + } + data._orchestrator = { + state: "Standby", + async startIndexing(value: IndexingTelemetryTrigger) { + trigger = value + }, + } + data._searchService = {} + data._cacheManager = {} + + const sub = mgr.onTelemetry.on((event) => events.push(event)) + await mgr.startIndexing() + sub.dispose() + + const started = events.find((event) => event.type === "started") + expect(trigger).toBe("manual") + expect(started).toBeDefined() + expect(started?.type).toBe("started") + expect(started?.trigger).toBe("manual") + expect(started?.source).toBe("scan") + }) + + test("emits background indexing start telemetry", async () => { + const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") + const events: IndexingTelemetryEvent[] = [] + const data = mgr as unknown as { + _cacheManager: { + clearCacheFile(): Promise + } + _orchestrator?: { + state: string + startIndexing(trigger: IndexingTelemetryTrigger): Promise + } + _searchService?: {} + _recreateServices(): Promise + } + + let trigger: IndexingTelemetryTrigger | undefined + data._cacheManager = { + async clearCacheFile() {}, + } + data._recreateServices = async () => { + data._orchestrator = { + state: "Standby", + async startIndexing(value: IndexingTelemetryTrigger) { + trigger = value + }, + } + data._searchService = {} + } + + const sub = mgr.onTelemetry.on((event) => events.push(event)) + await mgr.initialize(createInput({ openAiKey: "sk-test" })) + sub.dispose() + + const started = events.find((event) => event.type === "started") + expect(trigger).toBe("background") + expect(started).toBeDefined() + expect(started?.type).toBe("started") + expect(started?.trigger).toBe("background") + expect(started?.source).toBe("scan") + }) + + test("schedules auto-recovery for orchestrator start failures", async () => { + const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") + const data = createData(mgr) + let calls = 0 + + data._recreateServices = async () => { + data._orchestrator = { + state: "Standby", + stopWatcher() {}, + async startIndexing() { + calls += 1 + this.state = "Indexed" + data._stateManager.setSystemState("Indexed", "done") + }, + } + data._searchService = {} + } + + data.handleTelemetry(createStartError()) + await data._retryTask + + expect(calls).toBe(1) + }) + + test("ignores non-orchestrator telemetry errors for auto-recovery", async () => { + const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") + const data = createData(mgr) + let calls = 0 + + data._recreateServices = async () => { + calls += 1 + data._searchService = {} + } + + data.handleTelemetry(createStartError("manager:initialize")) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(calls).toBe(0) + }) + + test("runs only one recovery loop for duplicate error telemetry", async () => { + const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") + const data = createData(mgr) + let calls = 0 + const gate = {} as { + done: Promise + wake: () => void + } + + gate.done = new Promise((resolve) => { + gate.wake = resolve + }) + + data._recreateServices = async () => { + data._orchestrator = { + state: "Standby", + stopWatcher() {}, + async startIndexing() { + calls += 1 + this.state = "Indexed" + await gate.done + data._stateManager.setSystemState("Indexed", "done") + }, + } + data._searchService = {} + } + + data.handleTelemetry(createStartError()) + data.handleTelemetry(createStartError()) + + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(calls).toBe(1) + + gate.wake() + await data._retryTask + }) + + test("startIndexing restarts from Error state in one call", async () => { + const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") + const data = createData(mgr) + let calls = 0 + + data._recreateServices = async () => { + data._orchestrator = { + state: "Standby", + stopWatcher() {}, + async startIndexing() { + calls += 1 + this.state = "Indexed" + data._stateManager.setSystemState("Indexed", "done") + }, + } + data._searchService = {} + } + + data._stateManager.setSystemState("Error", "failed") + await mgr.startIndexing() + + expect(calls).toBe(1) + expect(mgr.getCurrentStatus().systemStatus).toBe("Indexed") + }) + + test("dispose calls cancelIndexing on orchestrator", () => { + const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") + let cancel = 0 + let stop = 0 + const data = mgr as unknown as { + _orchestrator?: { + stopWatcher(): void + cancelIndexing(): void + } + } + + data._orchestrator = { + stopWatcher() { + stop += 1 + }, + cancelIndexing() { + cancel += 1 + }, + } + + mgr.dispose() + + expect(cancel).toBe(1) + expect(stop).toBe(0) + }) + + test("dispose during service recreation cancels the recreated orchestrator", async () => { + const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") + const data = mgr as unknown as { + _cacheManager: { + clearCacheFile(): Promise + } + _orchestrator?: { + state: string + cancelIndexing(): void + startIndexing(trigger: IndexingTelemetryTrigger): Promise + } + _searchService?: {} + _recreateServices(): Promise + } + const gate = Promise.withResolvers() + let cancel = 0 + let start = 0 + + data._cacheManager = { + async clearCacheFile() {}, + } + data._recreateServices = async () => { + await gate.promise + data._orchestrator = { + state: "Standby", + cancelIndexing() { + cancel += 1 + }, + async startIndexing() { + start += 1 + }, + } + data._searchService = {} + } + + const init = mgr.initialize(createInput({ openAiKey: "sk-test" })) + await new Promise((resolve) => setTimeout(resolve, 0)) + mgr.dispose() + gate.resolve() + await init + + expect(cancel).toBe(1) + expect(start).toBe(0) + }) + + test("dispose during recovery prevents restart after service recreation", async () => { + const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") + const data = createData(mgr) + const gate = Promise.withResolvers() + let start = 0 + + data._recreateServices = async () => { + await gate.promise + data._orchestrator = { + state: "Standby", + stopWatcher() {}, + async startIndexing() { + start += 1 + }, + } + data._searchService = {} + } + + const task = data.handleTelemetry(createStartError()) + await new Promise((resolve) => setTimeout(resolve, 0)) + mgr.dispose() + gate.resolve() + await data._retryTask + + expect(task).toBeUndefined() + expect(start).toBe(0) + }) + + test("retry exhaustion keeps Error and stops future retries", async () => { + const mgr = new CodeIndexManager("/tmp/ws", "/tmp/cache") + const data = createData(mgr) + data._retryMaxAttempts = 2 + let calls = 0 + + data._recreateServices = async () => { + data._orchestrator = { + state: "Standby", + stopWatcher() {}, + async startIndexing() { + calls += 1 + this.state = "Error" + data._stateManager.setSystemState("Error", "failed") + }, + } + data._searchService = {} + } + + data.handleTelemetry(createStartError()) + await data._retryTask + + expect(calls).toBe(2) + expect(mgr.getCurrentStatus().systemStatus).toBe("Error") + + data.handleTelemetry(createStartError()) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(calls).toBe(2) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/orchestrator.test.ts b/packages/kilo-indexing/test/kilocode/indexing/orchestrator.test.ts new file mode 100644 index 00000000000..7ce180ff553 --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/orchestrator.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, test } from "bun:test" +import { CodeIndexConfigManager } from "../../../src/indexing/config-manager" +import { CodeIndexOrchestrator } from "../../../src/indexing/orchestrator" +import { CodeIndexStateManager } from "../../../src/indexing/state-manager" +import type { CacheManager } from "../../../src/indexing/cache-manager" +import type { DirectoryScanner } from "../../../src/indexing/processors/scanner" +import type { + BatchProcessingSummary, + FileProcessingResult, + IFileWatcher, + IndexingTelemetryEvent, + IVectorStore, + PointStruct, + VectorStoreSearchResult, +} from "../../../src/indexing/interfaces" +import { Emitter } from "../../../src/indexing/runtime" + +class Store { + public clearCount = 0 + public deleteCount = 0 + + constructor( + private readonly existing: boolean, + private readonly created = false, + ) {} + + async initialize(): Promise { + return this.created + } + + async upsertPoints(_points: PointStruct[]): Promise {} + + async search( + _queryVector: number[], + _directoryPrefix?: string, + _minScore?: number, + _maxResults?: number, + ): Promise { + return [] + } + + async deletePointsByFilePath(_filePath: string): Promise {} + async deletePointsByMultipleFilePaths(_filePaths: string[]): Promise {} + async clearCollection(): Promise { + this.clearCount += 1 + } + async deleteCollection(): Promise { + this.deleteCount += 1 + } + async collectionExists(): Promise { + return true + } + async hasIndexedData(): Promise { + return this.existing + } + async markIndexingComplete(): Promise {} + async markIndexingIncomplete(): Promise {} +} + +class Scanner { + public readonly isCancelled = false + + constructor( + private readonly discovered: number, + private readonly indexed: number, + private readonly blocks: number, + ) {} + + async scanDirectory( + _directory: string, + _onError?: (error: Error) => void, + onFilesIndexed?: (indexedCount: number) => void, + onFileParsed?: () => void, + ): Promise<{ stats: { processed: number; skipped: number }; totalBlockCount: number }> { + for (let i = 0; i < this.discovered; i += 1) { + onFileParsed?.() + } + onFilesIndexed?.(this.indexed) + return { + stats: { + processed: this.indexed, + skipped: 0, + }, + totalBlockCount: this.blocks, + } + } + + cancel(): void {} + updateBatchSegmentThreshold(_newThreshold: number): void {} +} + +class Watcher { + public readonly onDidStartBatchProcessing = new Emitter() + public readonly onBatchProgressUpdate = new Emitter<{ + processedInBatch: number + totalInBatch: number + currentFile?: string + }>() + public readonly onDidFinishBatchProcessing = new Emitter() + + async initialize(): Promise {} + updateBatchSegmentThreshold(_newThreshold: number): void {} + setCollecting(_collecting: boolean): void {} + + async processFile(filePath: string): Promise { + return { + path: filePath, + status: "skipped", + reason: "not used in test", + } + } + + dispose(): void { + this.onDidStartBatchProcessing.dispose() + this.onBatchProgressUpdate.dispose() + this.onDidFinishBatchProcessing.dispose() + } +} + +class FailScanner { + public readonly isCancelled = false + + async scanDirectory(): Promise<{ stats: { processed: number; skipped: number }; totalBlockCount: number }> { + throw new Error("scan failed") + } + + cancel(): void {} + updateBatchSegmentThreshold(_newThreshold: number): void {} +} + +function createConfig(): CodeIndexConfigManager { + return new CodeIndexConfigManager({ + enabled: true, + embedderProvider: "openai", + openAiKey: "sk-test", + vectorStoreProvider: "lancedb", + modelId: "text-embedding-3-small", + }) +} + +describe("CodeIndexOrchestrator telemetry", () => { + test("emits full completion telemetry", async () => { + const events: IndexingTelemetryEvent[] = [] + const orchestrator = new CodeIndexOrchestrator( + createConfig(), + new CodeIndexStateManager(), + "/tmp/ws", + { + async clearCacheFile() {}, + } as unknown as CacheManager, + new Store(false) as unknown as IVectorStore, + new Scanner(3, 3, 6) as unknown as DirectoryScanner, + new Watcher() as unknown as IFileWatcher, + (event) => events.push(event), + ) + + await orchestrator.startIndexing("manual") + + const completed = events.find( + (event): event is Extract => event.type === "completed", + ) + expect(completed).toBeDefined() + expect(completed?.mode).toBe("full") + expect(completed?.trigger).toBe("manual") + expect(completed?.filesDiscovered).toBe(3) + expect(completed?.filesIndexed).toBe(3) + expect(completed?.totalBlocks).toBe(6) + }) + + test("emits incremental completion telemetry", async () => { + const events: IndexingTelemetryEvent[] = [] + const orchestrator = new CodeIndexOrchestrator( + createConfig(), + new CodeIndexStateManager(), + "/tmp/ws", + { + async clearCacheFile() {}, + } as unknown as CacheManager, + new Store(true) as unknown as IVectorStore, + new Scanner(2, 1, 2) as unknown as DirectoryScanner, + new Watcher() as unknown as IFileWatcher, + (event) => events.push(event), + ) + + await orchestrator.startIndexing("manual") + + const completed = events.find( + (event): event is Extract => event.type === "completed", + ) + expect(completed).toBeDefined() + expect(completed?.mode).toBe("incremental") + expect(completed?.trigger).toBe("manual") + expect(completed?.filesDiscovered).toBe(2) + expect(completed?.filesIndexed).toBe(1) + expect(completed?.totalBlocks).toBe(2) + }) + + test("cancelIndexing prevents scan from running", async () => { + let scanned = false + const scanner = new Scanner(3, 3, 6) as unknown as DirectoryScanner + const original = scanner.scanDirectory.bind(scanner) + scanner.scanDirectory = async (...args: Parameters) => { + scanned = true + return original(...args) + } + + const orchestrator = new CodeIndexOrchestrator( + createConfig(), + new CodeIndexStateManager(), + "/tmp/ws", + { async clearCacheFile() {} } as unknown as CacheManager, + new Store(false) as unknown as IVectorStore, + scanner, + new Watcher() as unknown as IFileWatcher, + ) + + // Start indexing then immediately cancel + const done = orchestrator.startIndexing("background") + orchestrator.cancelIndexing() + await done + + expect(orchestrator.state).toBe("Standby") + // Scanner may or may not have been reached depending on timing, + // but the orchestrator must not be in Indexing state + expect(orchestrator.state).not.toBe("Indexing") + }) + + test("preserves cache and collection data on retryable start failures", async () => { + const events: IndexingTelemetryEvent[] = [] + const cache = { + clears: 0, + async clearCacheFile() { + this.clears += 1 + }, + } + const store = new Store(true) + const orchestrator = new CodeIndexOrchestrator( + createConfig(), + new CodeIndexStateManager(), + "/tmp/ws", + cache as unknown as CacheManager, + store as unknown as IVectorStore, + new FailScanner() as unknown as DirectoryScanner, + new Watcher() as unknown as IFileWatcher, + (event) => events.push(event), + ) + + await orchestrator.startIndexing("background") + + const error = events.find( + (event): event is Extract => + event.type === "error" && event.location === "orchestrator:startIndexing", + ) + expect(error).toBeDefined() + expect(error?.mode).toBe("incremental") + expect(cache.clears).toBe(0) + expect(store.clearCount).toBe(0) + expect(store.deleteCount).toBe(0) + expect(orchestrator.state).toBe("Error") + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/processors/file-watcher.test.ts b/packages/kilo-indexing/test/kilocode/indexing/processors/file-watcher.test.ts new file mode 100644 index 00000000000..2a66a389abe --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/processors/file-watcher.test.ts @@ -0,0 +1,235 @@ +import { describe, test, expect } from "bun:test" +import { mkdtemp, mkdir, writeFile } from "fs/promises" +import { tmpdir } from "os" +import path from "path" +import { v5 as uuidv5 } from "uuid" +import { CacheManager } from "../../../../src/indexing/cache-manager" +import { QDRANT_CODE_BLOCK_NAMESPACE } from "../../../../src/indexing/constants" +import type { + IEmbedder, + IndexingTelemetryEvent, + IVectorStore, + PointStruct, + VectorStoreSearchResult, +} from "../../../../src/indexing/interfaces" +import { FileWatcher } from "../../../../src/indexing/processors/file-watcher" +import { loadIgnore } from "../../../../src/indexing/shared/load-ignore" + +function createEmbedder(): IEmbedder { + return { + async createEmbeddings(texts) { + return { + embeddings: texts.map((_, index) => [index + 1]), + } + }, + async validateConfiguration() { + return { valid: true } + }, + get embedderInfo() { + return { name: "openai" as const } + }, + } +} + +class RetryStore implements IVectorStore { + constructor(private readonly fail: number) {} + + private calls = 0 + + async initialize(): Promise { + return false + } + + async upsertPoints(_points: PointStruct[]): Promise { + this.calls += 1 + if (this.calls <= this.fail) { + throw new Error("watcher upsert failure for /tmp/watcher/path.ts") + } + } + + async search( + _queryVector: number[], + _directoryPrefix?: string, + _minScore?: number, + _maxResults?: number, + ): Promise { + return [] + } + + async deletePointsByFilePath(_filePath: string): Promise {} + async deletePointsByMultipleFilePaths(_filePaths: string[]): Promise {} + async clearCollection(): Promise {} + async deleteCollection(): Promise {} + async collectionExists(): Promise { + return true + } + async hasIndexedData(): Promise { + return false + } + async markIndexingComplete(): Promise {} + async markIndexingIncomplete(): Promise {} +} + +describe("FileWatcher", () => { + test("processFile preserves same-line segments during incremental updates", async () => { + const root = await mkdtemp(path.join(tmpdir(), "file-watcher-test-")) + const cacheDir = path.join(root, ".cache") + const file = path.join(root, "oversized.md") + const line = "x".repeat(5000) + + await mkdir(cacheDir, { recursive: true }) + await writeFile(file, line) + + const cache = new CacheManager(cacheDir, root) + await cache.initialize() + + const watcher = new FileWatcher(root, cache, createEmbedder()) + const result = await watcher.processFile(file) + + expect(result.status).toBe("processed_for_batching") + expect(result.pointsToUpsert).toBeDefined() + + const points = result.pointsToUpsert! + expect(points.length).toBe(5) + + const ids = points.map((point) => point.id) + expect(new Set(ids).size).toBe(points.length) + + const hashes = points.map((point) => point.payload.segmentHash) + expect(new Set(hashes).size).toBe(points.length) + + points.forEach((point) => { + expect(point.payload.startLine).toBe(1) + expect(point.payload.endLine).toBe(1) + expect(point.id).toBe(uuidv5(point.payload.segmentHash, QDRANT_CODE_BLOCK_NAMESPACE)) + }) + }) + + test("emits retry telemetry for watcher upsert retries", async () => { + const root = await mkdtemp(path.join(tmpdir(), "file-watcher-test-")) + const cacheDir = path.join(root, ".cache") + const file = path.join(root, "oversized.md") + const line = "x".repeat(5000) + + await mkdir(cacheDir, { recursive: true }) + await writeFile(file, line) + + const cache = new CacheManager(cacheDir, root) + await cache.initialize() + + const events: IndexingTelemetryEvent[] = [] + const watcher = new FileWatcher( + root, + cache, + createEmbedder(), + new RetryStore(1), + undefined, + 1, + 2, + (event) => events.push(event), + { + provider: "openai", + vectorStore: "lancedb", + modelId: "text-embedding-3-small", + }, + ) + const data = watcher as unknown as { + processBatch(events: Map): Promise + } + + await data.processBatch( + new Map([ + [ + file, + { + path: file, + type: "create", + }, + ], + ]), + ) + + const retry = events.find((event) => event.type === "batch_retry") + expect(retry).toBeDefined() + expect(retry?.type).toBe("batch_retry") + expect(retry?.source).toBe("watcher") + expect(retry?.attempt).toBe(1) + expect(retry?.maxRetries).toBe(2) + expect(retry?.error).toContain("[REDACTED_PATH]") + }) + + test("emits error telemetry when watcher retries are exhausted", async () => { + const root = await mkdtemp(path.join(tmpdir(), "file-watcher-test-")) + const cacheDir = path.join(root, ".cache") + const file = path.join(root, "oversized.md") + const line = "x".repeat(5000) + + await mkdir(cacheDir, { recursive: true }) + await writeFile(file, line) + + const cache = new CacheManager(cacheDir, root) + await cache.initialize() + + const events: IndexingTelemetryEvent[] = [] + const watcher = new FileWatcher( + root, + cache, + createEmbedder(), + new RetryStore(10), + undefined, + 1, + 2, + (event) => events.push(event), + { + provider: "openai", + vectorStore: "lancedb", + modelId: "text-embedding-3-small", + }, + ) + const data = watcher as unknown as { + processBatch(events: Map): Promise + } + + await data.processBatch( + new Map([ + [ + file, + { + path: file, + type: "create", + }, + ], + ]), + ) + + const error = events.find( + (event): event is Extract => + event.type === "error" && event.location === "file-watcher:upsert_retry_exhausted", + ) + expect(error).toBeDefined() + expect(error?.type).toBe("error") + expect(error?.source).toBe("watcher") + expect(error?.mode).toBe("incremental") + expect(error?.retryCount).toBe(2) + expect(error?.error).toContain("[REDACTED_PATH]") + }) + + test("processFile skips files matched by .kilocodeignore during incremental updates", async () => { + const root = await mkdtemp(path.join(tmpdir(), "file-watcher-test-")) + const cacheDir = path.join(root, ".cache") + const file = path.join(root, "secret.ts") + + await mkdir(cacheDir, { recursive: true }) + await writeFile(path.join(root, ".kilocodeignore"), "secret.ts\n") + await writeFile(file, "export const secret = 1\n") + + const cache = new CacheManager(cacheDir, root) + await cache.initialize() + + const watcher = new FileWatcher(root, cache, createEmbedder(), undefined, await loadIgnore(root)) + const result = await watcher.processFile(file) + + expect(result.status).toBe("skipped") + expect(result.reason).toBe("File is ignored by .gitignore or .kilocodeignore") + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/processors/parser-vb.test.ts b/packages/kilo-indexing/test/kilocode/indexing/processors/parser-vb.test.ts new file mode 100644 index 00000000000..864d7f519eb --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/processors/parser-vb.test.ts @@ -0,0 +1,339 @@ +import { describe, test, expect, beforeEach } from "bun:test" + +import { CodeParser } from "../../../../src/indexing/processors/parser" +import { shouldUseFallbackChunking } from "../../../../src/indexing/shared/supported-extensions" + +describe("CodeParser - VB.NET and Fallback Extensions Support", () => { + let parser: CodeParser + + beforeEach(() => { + parser = new CodeParser() + }) + + test("should use fallback chunking for VB.NET files", async () => { + // First verify that shouldUseFallbackChunking works + expect(shouldUseFallbackChunking(".vb")).toBe(true) + + const vbContent = ` +Imports System +Imports System.Collections.Generic +Imports System.Linq + +Namespace MyApplication + Public Class Calculator + Private _history As New List(Of String)() + + Public Function Add(a As Integer, b As Integer) As Integer + Dim result As Integer = a + b + _history.Add($"{a} + {b} = {result}") + Return result + End Function + + Public Function Subtract(a As Integer, b As Integer) As Integer + Dim result As Integer = a - b + _history.Add($"{a} - {b} = {result}") + Return result + End Function + + Public Function Multiply(a As Integer, b As Integer) As Integer + Dim result As Integer = a * b + _history.Add($"{a} * {b} = {result}") + Return result + End Function + + Public Function Divide(a As Integer, b As Integer) As Double + If b = 0 Then + Throw New DivideByZeroException("Cannot divide by zero") + End If + Dim result As Double = CDbl(a) / CDbl(b) + _history.Add($"{a} / {b} = {result}") + Return result + End Function + + Public Function GetHistory() As List(Of String) + Return New List(Of String)(_history) + End Function + + Public Sub ClearHistory() + _history.Clear() + End Sub + End Class + + Public Module Program + Sub Main(args As String()) + Dim calc As New Calculator() + + Console.WriteLine("Calculator Demo") + Console.WriteLine("===============") + + Console.WriteLine($"10 + 5 = {calc.Add(10, 5)}") + Console.WriteLine($"10 - 5 = {calc.Subtract(10, 5)}") + Console.WriteLine($"10 * 5 = {calc.Multiply(10, 5)}") + Console.WriteLine($"10 / 5 = {calc.Divide(10, 5)}") + + Console.WriteLine() + Console.WriteLine("History:") + For Each entry In calc.GetHistory() + Console.WriteLine($" {entry}") + Next + End Sub + End Module +End Namespace +`.trim() + + const result = await parser.parseFile("test.vb", { + content: vbContent, + fileHash: "test-hash", + }) + + // Should have results from fallback chunking + expect(result.length).toBeGreaterThan(0) + + // Check that all blocks are of type 'fallback_chunk' + result.forEach((block) => { + expect(block.type).toBe("fallback_chunk") + }) + + // Verify content is properly chunked + const totalContent = result.map((block) => block.content).join("\n") + expect(totalContent).toBe(vbContent) + + // Verify file path is correct + const first = result[0] + expect(first).toBeDefined() + if (!first) return + expect(first.file_path).toBe("test.vb") + }) + + test("should handle large VB.NET files with proper chunking", async () => { + // Create a large VB.NET file content + const largeVbContent = + ` +Imports System +Imports System.Collections.Generic + +Namespace LargeApplication +` + + // Generate many classes to create a large file + Array.from( + { length: 50 }, + (_, i) => ` + Public Class TestClass${i} + Private _id As Integer = ${i} + Private _name As String = "Class ${i}" + Private _data As New Dictionary(Of String, Object)() + + Public Property Id As Integer + Get + Return _id + End Get + Set(value As Integer) + _id = value + End Set + End Property + + Public Property Name As String + Get + Return _name + End Get + Set(value As String) + _name = value + End Set + End Property + + Public Sub ProcessData() + For i As Integer = 0 To 100 + _data.Add($"key_{i}", $"value_{i}") + Next + End Sub + + Public Function GetData() As Dictionary(Of String, Object) + Return New Dictionary(Of String, Object)(_data) + End Function + End Class +`, + ).join("\n") + + ` +End Namespace +` + + const result = await parser.parseFile("large-test.vb", { + content: largeVbContent, + fileHash: "large-test-hash", + }) + + // Should have multiple chunks due to size + expect(result.length).toBeGreaterThan(1) + + // All chunks should be fallback chunks + result.forEach((block) => { + expect(block.type).toBe("fallback_chunk") + }) + + // Verify chunks don't exceed max size + result.forEach((block) => { + expect(block.content.length).toBeLessThanOrEqual(150000) // MAX_BLOCK_CHARS * MAX_CHARS_TOLERANCE_FACTOR + }) + }) + + test("should handle empty VB.NET files", async () => { + const emptyContent = "" + + const result = await parser.parseFile("empty.vb", { + content: emptyContent, + fileHash: "empty-hash", + }) + + // Should return empty array for empty content + expect(result).toEqual([]) + }) + + test("should handle small VB.NET files below minimum chunk size", async () => { + const smallContent = "Imports System" + + const result = await parser.parseFile("small.vb", { + content: smallContent, + fileHash: "small-hash", + }) + + // Should return empty array for content below MIN_BLOCK_CHARS + expect(result).toEqual([]) + }) + + test("should use fallback chunking for other configured fallback extensions", async () => { + // Test with Scala which is in our fallback list + const content = `object ScalaExample { + def main(args: Array[String]): Unit = { + println("This is a Scala file that should use fallback chunking") + val numbers = List(1, 2, 3, 4, 5) + val doubled = numbers.map(_ * 2) + println(s"Doubled numbers: $doubled") + } + + def factorial(n: Int): Int = { + if (n <= 1) 1 + else n * factorial(n - 1) + } + }` + + const result = await parser.parseFile("test.scala", { + content: content, + fileHash: "test-hash-scala", + }) + + // Should have results from fallback chunking + expect(result.length).toBeGreaterThan(0) + + // Check that all blocks are of type 'fallback_chunk' + result.forEach((block) => { + expect(block.type).toBe("fallback_chunk") + }) + }) + + test.each([ + [ + "script.sh", + `#!/usr/bin/env bash +set -euo pipefail + +build() { + local target="$1" + echo "building \${target}" + mkdir -p dist + cp -R src/. "dist/\${target}" +} + +build release +`, + ], + [ + "schema.sql", + `CREATE TABLE users ( + id INTEGER PRIMARY KEY, + email TEXT NOT NULL, + created_at TIMESTAMP NOT NULL +); + +CREATE INDEX users_email_idx ON users(email); + +SELECT id, email +FROM users +WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '7 days'; +`, + ], + [ + "guide.rst", + `Release Notes +============= + +This document captures the rollout steps for the indexing service. + +1. Update the workspace settings. +2. Run the package test suite. +3. Restart the watcher after a successful deploy. +`, + ], + [ + "config.yaml", + `services: + api: + image: example/api:latest + environment: + LOG_LEVEL: debug + ENABLE_INDEXING: "true" + ports: + - "8080:8080" +`, + ], + [ + "build.gradle", + `plugins { + id("java") +} + +repositories { + mavenCentral() +} + +dependencies { + testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") +} +`, + ], + ])("should use fallback chunking for %s", async (file, content) => { + const result = await parser.parseFile(file, { + content, + fileHash: `${file}-hash`, + }) + + expect(result.length).toBeGreaterThan(0) + result.forEach((block) => { + expect(block.type).toBe("fallback_chunk") + }) + }) +}) + +describe("Fallback Extensions Configuration", () => { + test("should correctly identify extensions that need fallback chunking", () => { + // Extensions that should use fallback + expect(shouldUseFallbackChunking(".vb")).toBe(true) + expect(shouldUseFallbackChunking(".scala")).toBe(true) + expect(shouldUseFallbackChunking(".swift")).toBe(true) + + // Extensions that should not use fallback (have working parsers) + expect(shouldUseFallbackChunking(".js")).toBe(false) + expect(shouldUseFallbackChunking(".ts")).toBe(false) + expect(shouldUseFallbackChunking(".py")).toBe(false) + expect(shouldUseFallbackChunking(".java")).toBe(false) + expect(shouldUseFallbackChunking(".cs")).toBe(false) + expect(shouldUseFallbackChunking(".go")).toBe(false) + expect(shouldUseFallbackChunking(".rs")).toBe(false) + }) + + test("should be case-insensitive", () => { + expect(shouldUseFallbackChunking(".VB")).toBe(true) + expect(shouldUseFallbackChunking(".Vb")).toBe(true) + expect(shouldUseFallbackChunking(".SCALA")).toBe(true) + expect(shouldUseFallbackChunking(".Scala")).toBe(true) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/processors/parser.test.ts b/packages/kilo-indexing/test/kilocode/indexing/processors/parser.test.ts new file mode 100644 index 00000000000..2ba9b716be1 --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/processors/parser.test.ts @@ -0,0 +1,1060 @@ +import { describe, test, expect, beforeEach, mock } from "bun:test" +import { join } from "path" +import { mkdtemp } from "fs/promises" +import { tmpdir } from "os" + +const mockLoadParsers = mock() +mock.module("../../../../src/tree-sitter/languageParser", () => ({ + loadRequiredLanguageParsers: mockLoadParsers, +})) + +const mockParseMarkdown = mock() +mock.module("../../../../src/tree-sitter/markdownParser", () => ({ + parseMarkdown: mockParseMarkdown, +})) + +import { CodeParser, codeParser } from "../../../../src/indexing/processors/parser" +import { loadRequiredLanguageParsers } from "../../../../src/tree-sitter/languageParser" +import { parseMarkdown } from "../../../../src/tree-sitter/markdownParser" + +const mockLanguageParser = { + js: { + parser: { + parse: mock((content: string) => ({ + rootNode: { + text: content, + startPosition: { row: 0 }, + endPosition: { row: content.split("\n").length - 1 }, + children: [], + type: "program", + }, + })), + }, + query: { + captures: mock((): unknown[] => []), + }, + }, +} + +describe("CodeParser", () => { + let parser: CodeParser + let tempDir: string + + // Create a shared temp directory for tests that need real files + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "parser-test-")) + + mockLoadParsers.mockReset() + mockParseMarkdown.mockReset() + mockLanguageParser.js.parser.parse.mockReset() + mockLanguageParser.js.query.captures.mockReset() + + // Restore default implementations after reset + mockLanguageParser.js.parser.parse.mockImplementation((content: string) => ({ + rootNode: { + text: content, + startPosition: { row: 0 }, + endPosition: { row: content.split("\n").length - 1 }, + children: [], + type: "program", + }, + })) + mockLanguageParser.js.query.captures.mockReturnValue([]) + + parser = new CodeParser() + mockLoadParsers.mockResolvedValue(mockLanguageParser as any) + }) + + describe("parseFile", () => { + test("should return empty array for unsupported extensions", async () => { + const result = await parser.parseFile("test.unsupported") + expect(result).toEqual([]) + }) + + test("should use provided content instead of reading file when options.content is provided", async () => { + const content = `/* This is a long test content string that exceeds 100 characters to properly test the parser's behavior with large inputs. + It includes multiple lines and various JavaScript constructs to simulate real-world code. + const a = 1; + const b = 2; + function test() { return a + b; } + class Example { constructor() { this.value = 42; } } + // More comments to pad the length to ensure we hit the minimum character requirement */` + const result = await parser.parseFile("test.js", { content }) + expect(result.length).toBeGreaterThan(0) + }) + + test("should read file when no content is provided", async () => { + const testContent = `/* This is a long test content string that exceeds 100 characters to properly test file reading behavior. + It includes multiple lines and various JavaScript constructs to simulate real-world code. + const x = 10; + const y = 20; + function calculate() { return x * y; } + class Calculator { + constructor() { this.history = []; } + add(a, b) { return a + b; } + } + // More comments to pad the length to ensure we hit the minimum character requirement */` + + const tempFile = join(tempDir, "test-read.js") + await Bun.write(tempFile, testContent) + + const result = await parser.parseFile(tempFile) + expect(result.length).toBeGreaterThan(0) + }) + + test("should handle file read errors gracefully", async () => { + const result = await parser.parseFile("/tmp/nonexistent-parser-test-file.js") + expect(result).toEqual([]) + }) + + test("should use provided fileHash when available", async () => { + const content = `/* This is a long test content string that exceeds 100 characters to test fileHash behavior. + It includes multiple lines and various JavaScript constructs to simulate real-world code. + const items = [1, 2, 3]; + const sum = items.reduce((a, b) => a + b, 0); + function processItems(items) { + return items.map(item => item * 2); + } + // More comments to pad the length to ensure we hit the minimum character requirement */` + const fileHash = "test-hash" + const result = await parser.parseFile("test.js", { content, fileHash }) + expect(result[0].fileHash).toBe(fileHash) + }) + }) + + describe("isSupportedLanguage", () => { + test("should return true for supported extensions", () => { + expect(parser["isSupportedLanguage"](".js")).toBe(true) + }) + + test("should return false for unsupported extensions", () => { + expect(parser["isSupportedLanguage"](".unsupported")).toBe(false) + }) + }) + + describe("createFileHash", () => { + test("should generate consistent hashes for same content", () => { + const content = "test content" + const hash1 = parser["createFileHash"](content) + const hash2 = parser["createFileHash"](content) + expect(hash1).toBe(hash2) + expect(hash1).toMatch(/^[a-f0-9]{64}$/) // SHA-256 hex format + }) + + test("should generate different hashes for different content", () => { + const hash1 = parser["createFileHash"]("content1") + const hash2 = parser["createFileHash"]("content2") + expect(hash1).not.toBe(hash2) + }) + }) + + describe("parseContent", () => { + test("should wait for pending parser loads", async () => { + const pendingLoad = new Promise((resolve) => setTimeout(() => resolve(mockLanguageParser), 100)) + parser["pendingLoads"].set("js", pendingLoad as Promise) + + const result = await parser["parseContent"]("test.js", "const test = 123", "hash") + expect(result).toBeDefined() + }) + + test("should handle parser load errors", async () => { + mockLoadParsers.mockRejectedValue(new Error("Load failed")) + const content = `function fallbackTest() { + const value = 123 + const text = "This content is long enough to trigger fallback chunking" + return value + text.length +}` + const result = await parser["parseContent"]("test.js", content, "hash") + expect(result.length).toBeGreaterThan(0) + expect(result[0].type).toBe("fallback_chunk") + }) + + test("should use fallback chunking when no parser is available", async () => { + mockLoadParsers.mockResolvedValue({} as any) + const content = `function missingParser() { + const message = "Fallback should be used when parser is missing" + return message.toUpperCase() +}` + const result = await parser["parseContent"]("test.js", content, "hash") + expect(result.length).toBeGreaterThan(0) + expect(result[0].type).toBe("fallback_chunk") + }) + + test("should cache parser failures by extension", async () => { + mockLoadParsers.mockRejectedValue(new Error("Load failed")) + const content = `function cacheFailure() { + const message = "Parser should fail once per extension and then reuse fallback" + return message +}` + + const first = await parser["parseContent"]("first.js", content, "hash1") + const second = await parser["parseContent"]("second.js", content, "hash2") + + expect(first.length).toBeGreaterThan(0) + expect(second.length).toBeGreaterThan(0) + expect(mockLoadParsers).toHaveBeenCalledTimes(1) + }) + }) + + describe("_performFallbackChunking", () => { + test("should chunk content when no captures are found", async () => { + const content = `/* This is a long test content string that exceeds 100 characters to test fallback chunking behavior. + It includes multiple lines and various JavaScript constructs to simulate real-world code. + line1: const a = 1; + line2: const b = 2; + line3: function sum() { return a + b; } + line4: class Adder { constructor(x, y) { this.x = x; this.y = y; } } + line5: const instance = new Adder(1, 2); + line6: console.log(instance.x + instance.y); + line7: // More comments to pad the length to ensure we hit the minimum character requirement */` + const result = await parser["_performFallbackChunking"]("test.js", content, "hash", new Set()) + expect(result.length).toBeGreaterThan(0) + expect(result[0].type).toBe("fallback_chunk") + }) + + test("should respect MIN_BLOCK_CHARS for fallback chunks", async () => { + const shortContent = "short" + const result = await parser["_performFallbackChunking"]("test.js", shortContent, "hash", new Set()) + expect(result).toEqual([]) + }) + + test("should respect 50-character minimum threshold for all languages", async () => { + // Test content that is exactly 49 characters (should be filtered) + const shortContent = "function f() { return 1; } // Exactly 49 chars!!!" + expect(shortContent.length).toBe(49) + + // Test content that is exactly 50 characters (should be included) + const minContent = "function g() { return 42; } // Exactly 50 chars!!!" + expect(minContent.length).toBe(50) + + // Test content that is longer than 50 characters (should be included) + const longContent = "function calculate() { return 1 + 2 + 3; } // This is longer than 50 characters" + expect(longContent.length).toBeGreaterThan(50) + + // Mock the language parser to return captures for our test content + const mockCapture = (content: string, startLine: number = 0) => ({ + node: { + text: content, + startPosition: { row: startLine }, + endPosition: { row: startLine }, + type: "function_declaration", + childForFieldName: mock(() => null), + children: [], + }, + name: "definition.function", + }) + + // Test short content (49 chars) - should be filtered out + mockLanguageParser.js.query.captures.mockReturnValue([mockCapture(shortContent)]) + const shortResult = await parser["parseContent"]("test.js", shortContent, "hash1") + expect(shortResult).toEqual([]) + + // Test minimum content (50 chars) - should be included + mockLanguageParser.js.query.captures.mockReturnValue([mockCapture(minContent)]) + const minResult = await parser["parseContent"]("test.js", minContent, "hash2") + expect(minResult.length).toBe(1) + expect(minResult[0].content).toBe(minContent) + + // Test longer content - should be included + mockLanguageParser.js.query.captures.mockReturnValue([mockCapture(longContent)]) + const longResult = await parser["parseContent"]("test.js", longContent, "hash3") + expect(longResult.length).toBe(1) + expect(longResult[0].content).toBe(longContent) + }) + }) + + describe("_chunkLeafNodeByLines", () => { + test("should chunk leaf nodes by lines", async () => { + const mockNode = { + text: `/* This is a long test content string that exceeds 100 characters to test line chunking behavior. + line1: const a = 1; + line2: const b = 2; + line3: function sum() { return a + b; } + line4: class Multiplier { constructor(x, y) { this.x = x; this.y = y; } } + line5: const instance = new Multiplier(3, 4); + line6: console.log(instance.x * instance.y); + line7: // More comments to pad the length to ensure we hit the minimum character requirement */`, + startPosition: { row: 10 }, + endPosition: { row: 12 }, + type: "function", + } as any + + const result = await parser["_chunkLeafNodeByLines"](mockNode, "test.js", "hash", new Set()) + expect(result.length).toBeGreaterThan(0) + expect(result[0].type).toBe("function") + expect(result[0].start_line).toBe(11) // 1-based + }) + }) + + describe("_chunkTextByLines", () => { + test("should handle oversized lines by splitting them", async () => { + const longLine = "a".repeat(2000) + const lines = ["normal", longLine, "normal"] + const result = await parser["_chunkTextByLines"](lines, "test.js", "hash", "test_type", new Set(), 100) + + const segments = result.filter((r) => r.type === "test_type_segment") + expect(segments.length).toBeGreaterThan(1) + }) + + test("should re-balance chunks when remainder is too small", async () => { + const lines = Array(100) + .fill("line with 10 chars") + .map((_, i) => `${i}: line`) + const result = await parser["_chunkTextByLines"](lines, "test.js", "hash", "test_type", new Set(), 100) + + result.forEach((chunk) => { + expect(chunk.content.length).toBeGreaterThanOrEqual(100) + expect(chunk.content.length).toBeLessThanOrEqual(1150) + }) + }) + }) + + describe("singleton instance", () => { + test("should maintain parser state across calls", async () => { + const result1 = await codeParser.parseFile("test.js", { content: "const a = 1" }) + const result2 = await codeParser.parseFile("test.js", { content: "const b = 2" }) + expect(result1).toBeDefined() + expect(result2).toBeDefined() + }) + }) + + describe("Markdown Support", () => { + beforeEach(() => { + mockLoadParsers.mockReset() + mockParseMarkdown.mockReset() + mockLanguageParser.js.parser.parse.mockReset() + mockLanguageParser.js.query.captures.mockReset() + + mockLanguageParser.js.parser.parse.mockImplementation((content: string) => ({ + rootNode: { + text: content, + startPosition: { row: 0 }, + endPosition: { row: content.split("\n").length - 1 }, + children: [], + type: "program", + }, + })) + mockLanguageParser.js.query.captures.mockReturnValue([]) + mockLoadParsers.mockResolvedValue(mockLanguageParser as any) + }) + + test("should generate unique segment hashes for each markdown block", async () => { + const markdownContent = `# Section One +This is a section with substantial content that meets the minimum character requirements. +It contains detailed information and multiple paragraphs to ensure proper indexing. +The content is comprehensive and provides valuable information for search functionality. + +## Section Two +Another section with different content but also meeting the minimum requirements. +This ensures we can test that different sections get different segment hashes. +Each section should have its own unique hash based on its content.` + + mockParseMarkdown.mockReturnValue([ + { + node: { startPosition: { row: 0 }, endPosition: { row: 4 }, text: "Section One" }, + name: "name.definition.header.h1", + patternIndex: 0, + }, + { + node: { startPosition: { row: 0 }, endPosition: { row: 4 }, text: "Section One" }, + name: "definition.header.h1", + patternIndex: 0, + }, + { + node: { startPosition: { row: 5 }, endPosition: { row: 8 }, text: "Section Two" }, + name: "name.definition.header.h2", + patternIndex: 0, + }, + { + node: { startPosition: { row: 5 }, endPosition: { row: 8 }, text: "Section Two" }, + name: "definition.header.h2", + patternIndex: 0, + }, + ] as any) + + const result = await parser.parseFile("test.md", { content: markdownContent }) + + expect(result).toHaveLength(2) + + // Verify each block has unique segment hash + expect(result[0].segmentHash).toMatch(/^[a-f0-9]{64}$/) + expect(result[1].segmentHash).toMatch(/^[a-f0-9]{64}$/) + expect(result[0].segmentHash).not.toBe(result[1].segmentHash) + + // Verify file hash is consistent + expect(result[0].fileHash).toBe(result[1].fileHash) + expect(result[0].fileHash).toMatch(/^[a-f0-9]{64}$/) + }) + + test("should use fallback chunking for markdown files without headers", async () => { + const markdownContent = `This is a markdown file without any headers but with substantial content. +It contains multiple paragraphs and detailed information that should be indexed. +The content is long enough to meet the minimum character requirements for fallback chunking. +This ensures that even headerless markdown files can be properly indexed and searched. +Additional content to ensure we exceed the minimum block size requirements for proper indexing.` + + mockParseMarkdown.mockReturnValue([]) + + const result = await parser.parseFile("test.md", { content: markdownContent }) + + expect(parseMarkdown).toHaveBeenCalledWith(markdownContent) + expect(result).toHaveLength(1) + expect(result[0].type).toBe("markdown_content") + expect(result[0].content).toBe(markdownContent) + expect(result[0].start_line).toBe(1) + + // Verify hash generation for fallback chunks + expect(result[0].segmentHash).toMatch(/^[a-f0-9]{64}$/) + expect(result[0].fileHash).toMatch(/^[a-f0-9]{64}$/) + }) + + test("should chunk large markdown files with no headers", async () => { + // Create a large markdown file without headers (2000+ chars) + const lines = [] + for (let i = 0; i < 80; i++) { + lines.push(`This is line ${i} with substantial content to ensure proper chunking behavior.`) + } + const largeMarkdownContent = lines.join("\n") // ~80 lines * ~78 chars = ~6240 chars + + mockParseMarkdown.mockReturnValue([]) + + const result = await parser.parseFile("test.md", { content: largeMarkdownContent }) + + expect(parseMarkdown).toHaveBeenCalledWith(largeMarkdownContent) + // Should have multiple chunks due to size + expect(result.length).toBeGreaterThan(1) + // All chunks should be of type markdown_content + result.forEach((block) => { + expect(block.type).toBe("markdown_content") + expect(block.identifier).toBeNull() + // Each chunk should respect MAX_BLOCK_CHARS * MAX_CHARS_TOLERANCE_FACTOR + expect(block.content.length).toBeLessThanOrEqual(1150) + }) + // Verify chunks cover the entire content + const totalLines = result.reduce((acc, block) => { + return acc + (block.end_line - block.start_line + 1) + }, 0) + expect(totalLines).toBe(80) + }) + + test("should enforce MIN_BLOCK_CHARS for all markdown sections", async () => { + const markdownContent = `# Short +Small content. + +## Another Short +Also small. + +### Long Section +This section has substantial content that exceeds the minimum character requirements. +It includes multiple lines with detailed information to ensure proper indexing. +The content is comprehensive enough to be included in the search results.` + + mockParseMarkdown.mockReturnValue([ + { + node: { startPosition: { row: 0 }, endPosition: { row: 1 }, text: "Short" }, + name: "name.definition.header.h1", + patternIndex: 0, + }, + { + node: { startPosition: { row: 0 }, endPosition: { row: 1 }, text: "Short" }, + name: "definition.header.h1", + patternIndex: 0, + }, + { + node: { startPosition: { row: 3 }, endPosition: { row: 4 }, text: "Another Short" }, + name: "name.definition.header.h2", + patternIndex: 0, + }, + { + node: { startPosition: { row: 3 }, endPosition: { row: 4 }, text: "Another Short" }, + name: "definition.header.h2", + patternIndex: 0, + }, + { + node: { startPosition: { row: 6 }, endPosition: { row: 9 }, text: "Long Section" }, + name: "name.definition.header.h3", + patternIndex: 0, + }, + { + node: { startPosition: { row: 6 }, endPosition: { row: 9 }, text: "Long Section" }, + name: "definition.header.h3", + patternIndex: 0, + }, + ] as any) + + const result = await parser.parseFile("test.md", { content: markdownContent }) + + // Only the long section should be included + expect(result).toHaveLength(1) + expect(result[0].identifier).toBe("Long Section") + expect(result[0].content.length).toBeGreaterThanOrEqual(100) // MIN_BLOCK_CHARS + }) + + test("should chunk large markdown sections and generate unique hashes for each chunk", async () => { + // Create content with multiple lines + const lines = [] + // Add header + lines.push("# Large Section Header") + // Add 50 lines of content, each ~30 chars = ~1500 chars total + for (let i = 0; i < 50; i++) { + lines.push(`This is line ${i} with some content.`) + } + + const markdownContent = lines.join("\n") + + // The mock should return sections that span the actual content + mockParseMarkdown.mockReturnValue([ + { + node: { + startPosition: { row: 0 }, + endPosition: { row: 50 }, // Header + 50 lines of content + text: "Large Section Header", + }, + name: "name.definition.header.h1", + patternIndex: 0, + }, + { + node: { + startPosition: { row: 0 }, + endPosition: { row: 50 }, // Header + 50 lines of content + text: markdownContent, // Full section content + }, + name: "definition.header.h1", + patternIndex: 0, + }, + ] as any) + + const result = await parser.parseFile("test.md", { content: markdownContent }) + + // Large section should be chunked into multiple blocks + const h1Blocks = result.filter((r) => r.type === "markdown_header_h1") + expect(h1Blocks.length).toBeGreaterThan(1) + + // Each chunk should have a unique segment hash + const segmentHashes = h1Blocks.map((block) => block.segmentHash) + const uniqueHashes = new Set(segmentHashes) + expect(uniqueHashes.size).toBe(h1Blocks.length) + + // All chunks should preserve the header identifier + h1Blocks.forEach((block) => { + expect(block.identifier).toBe("Large Section Header") + // Each chunk should respect MAX_BLOCK_CHARS * MAX_CHARS_TOLERANCE_FACTOR + expect(block.content.length).toBeLessThanOrEqual(1150) + // Each chunk should have valid hashes + expect(block.segmentHash).toMatch(/^[a-f0-9]{64}$/) + expect(block.fileHash).toMatch(/^[a-f0-9]{64}$/) + }) + }) + + test("should handle markdown with very long single lines with chunking", async () => { + const veryLongLine = "a".repeat(2000) // Single line exceeding max chars + const markdownContent = `# Section with Long Line +Normal content here. +${veryLongLine} +More normal content.` + + mockParseMarkdown.mockReturnValue([ + { + node: { startPosition: { row: 0 }, endPosition: { row: 3 }, text: "Section with Long Line" }, + name: "name.definition.header.h1", + patternIndex: 0, + }, + { + node: { startPosition: { row: 0 }, endPosition: { row: 3 }, text: markdownContent }, + name: "definition.header.h1", + patternIndex: 0, + }, + ] as any) + + const result = await parser.parseFile("test.md", { content: markdownContent }) + + // Should create multiple blocks due to chunking + expect(result.length).toBeGreaterThan(1) + // Should have segment blocks for the oversized line + const segmentBlocks = result.filter((r) => r.type === "markdown_header_h1_segment") + expect(segmentBlocks.length).toBeGreaterThan(0) + // All blocks should preserve the header identifier + result.forEach((block) => { + expect(block.identifier).toBe("Section with Long Line") + }) + }) + + test("should preserve header information when chunking large sections", async () => { + const largeContent = Array(100).fill("Line with substantial content to ensure proper handling.").join("\n") + const markdownContent = `### Deep Header Level 3 +${largeContent}` + + mockParseMarkdown.mockReturnValue([ + { + node: { startPosition: { row: 0 }, endPosition: { row: 100 }, text: "Deep Header Level 3" }, + name: "name.definition.header.h3", + patternIndex: 0, + }, + { + node: { startPosition: { row: 0 }, endPosition: { row: 100 }, text: markdownContent }, + name: "definition.header.h3", + patternIndex: 0, + }, + ] as any) + + const result = await parser.parseFile("test.md", { content: markdownContent }) + + // Should have multiple blocks due to chunking + expect(result.length).toBeGreaterThan(1) + // All blocks should have the same type and identifier + result.forEach((block) => { + expect(block.type).toBe("markdown_header_h3") + expect(block.identifier).toBe("Deep Header Level 3") + }) + }) + + test("should apply chunking logic based on MAX_BLOCK_CHARS and re-balancing", async () => { + // Create content that will trigger re-balancing logic + // 60 lines * 30 chars = 1800 chars, which should trigger chunking + const lines = [] + for (let i = 0; i < 60; i++) { + lines.push(`Line ${i}: Some content here to test.`) // ~30 chars per line + } + const markdownContent = lines.join("\n") + + mockParseMarkdown.mockReturnValue([]) + + const result = await parser.parseFile("test.md", { content: markdownContent }) + + // Should have multiple chunks due to size + expect(result.length).toBeGreaterThan(1) + + // Verify re-balancing: chunks should be roughly equal in size + const chunkSizes = result.map((block) => block.content.length) + const avgSize = chunkSizes.reduce((a, b) => a + b, 0) / chunkSizes.length + + chunkSizes.forEach((size) => { + // Each chunk should be within 30% of average size (re-balanced) + expect(Math.abs(size - avgSize) / avgSize).toBeLessThan(0.3) + // Each chunk should respect MIN_BLOCK_CHARS + expect(size).toBeGreaterThanOrEqual(50) + }) + + // Verify each chunk has unique segment hash + const segmentHashes = result.map((block) => block.segmentHash) + expect(new Set(segmentHashes).size).toBe(result.length) + }) + + test("should handle markdown content before the first header", async () => { + const preHeaderContent = `This is content before any headers that contains substantial information. +It has multiple lines and should be indexed because it meets the minimum size requirements. +This content contains important documentation that would be lost without proper handling. +We need to ensure that all content is captured, not just content within header sections. +This paragraph continues with more details to ensure we exceed the minimum block size.` + + const headerContent = `# First Header + +Content under the first header with enough text to be indexed properly. +This section contains multiple lines to ensure it meets the minimum character requirements. +We need at least 50 characters for a section to be included in the index. +This additional content ensures the header section will be processed correctly.` + + const markdownContent = `${preHeaderContent} + +${headerContent}` + + // Mock the parseMarkdown function to return headers + // The header section spans from line 6 to line 10 (5 lines total) + mockParseMarkdown.mockReturnValue([ + { + node: { + startPosition: { row: 6 }, + endPosition: { row: 10 }, + text: "First Header", + }, + name: "name.definition.header.h1", + patternIndex: 0, + } as any, + { + node: { + startPosition: { row: 6 }, + endPosition: { row: 10 }, + text: "First Header", + }, + name: "definition.header.h1", + patternIndex: 0, + } as any, + ]) + + const result = await parser.parseFile("test.md", { content: markdownContent }) + + // With MIN_BLOCK_CHARS=50, content may be split into more blocks + expect(result.length).toBeGreaterThanOrEqual(2) + + // First block should be the content before the header + expect(result[0]).toMatchObject({ + file_path: "test.md", + type: "markdown_content", + start_line: 1, + end_line: 6, // Up to the header line + }) + expect(result[0].content).toContain("This is content before any headers") + + // Second block should be the header section + expect(result[1]).toMatchObject({ + file_path: "test.md", + identifier: "First Header", + type: "markdown_header_h1", + start_line: 7, + end_line: 11, + }) + }) + + test("should handle markdown content after the last header", async () => { + const markdownContent = `# Header + +Header content with enough text to meet the minimum requirements for proper indexing. +This header section needs to have at least 100 characters to be included in the results. +We're adding this extra line to ensure the header section meets the minimum size threshold. + +This is content after the last header that contains substantial documentation. +It has multiple lines and should be indexed because it's important information. +This content would be lost without proper handling of content outside header sections. +We're adding more content here to ensure we meet the minimum block size requirements. +This ensures that trailing content in markdown files is properly captured and indexed.` + + // Mock the parseMarkdown function to return headers + // The header section spans from line 0 to line 4 (5 lines) + mockParseMarkdown.mockReturnValue([ + { + node: { + startPosition: { row: 0 }, + endPosition: { row: 4 }, + text: "Header", + }, + name: "name.definition.header.h1", + patternIndex: 0, + } as any, + { + node: { + startPosition: { row: 0 }, + endPosition: { row: 4 }, + text: "Header", + }, + name: "definition.header.h1", + patternIndex: 0, + } as any, + ]) + + const result = await parser.parseFile("test.md", { content: markdownContent }) + + // Should have exactly 2 blocks: header section and post-header content + expect(result.length).toBe(2) + + // First block should be the header section + expect(result[0]).toMatchObject({ + file_path: "test.md", + identifier: "Header", + type: "markdown_header_h1", + start_line: 1, + end_line: 5, + }) + + // Second block should be the content after the header + expect(result[1]).toMatchObject({ + file_path: "test.md", + type: "markdown_content", + start_line: 6, + }) + expect(result[1].content).toContain("This is content after the last header") + }) + + test("should handle very long paragraphs with chunking", async () => { + // Create a very long paragraph + const longParagraph = "This is a very long paragraph that contains substantial content. ".repeat(50) + const markdownContent = `# Introduction + +Some intro text. + +${longParagraph} + +## Conclusion + +Final thoughts that need to be long enough to meet the minimum character requirement. +This conclusion section contains multiple lines to ensure it exceeds 100 characters.` + + const lines = markdownContent.split("\n") + + mockParseMarkdown.mockReturnValue([ + { + node: { + startPosition: { row: 0 }, + endPosition: { row: 0 }, + text: "Introduction", + }, + name: "name.definition.header.h1", + patternIndex: 0, + } as any, + { + node: { + startPosition: { row: 0 }, + endPosition: { row: 4 }, + text: "Introduction", + }, + name: "definition.header.h1", + patternIndex: 0, + } as any, + { + node: { + startPosition: { row: 6 }, + endPosition: { row: 6 }, + text: "Conclusion", + }, + name: "name.definition.header.h2", + patternIndex: 0, + } as any, + { + node: { + startPosition: { row: 6 }, + endPosition: { row: 9 }, + text: "Conclusion", + }, + name: "definition.header.h2", + patternIndex: 0, + } as any, + ]) + + const result = await parser.parseFile("test.md", { content: markdownContent }) + + // The introduction section should be chunked + const h1Blocks = result.filter((r) => r.type === "markdown_header_h1" || r.type === "markdown_header_h1_segment") + expect(h1Blocks.length).toBeGreaterThan(1) + // All chunks should preserve the identifier + h1Blocks.forEach((block) => { + expect(block.identifier).toBe("Introduction") + }) + + // Conclusion should be a single block + const h2Blocks = result.filter((r) => r.type === "markdown_header_h2") + expect(h2Blocks.length).toBe(1) + }) + + test("should continue processing after encountering a very long line", async () => { + // Create a markdown file with a very long single line followed by more content + const veryLongLine = "a".repeat(5000) // 5000 characters - exceeds MAX_BLOCK_CHARS * MAX_CHARS_TOLERANCE_FACTOR + + // Create content that will be chunked + const markdownContent = `This is content before the very long line that should be properly indexed. +It contains multiple lines to ensure it meets the minimum character requirements. +We need enough content here to trigger the chunking behavior. + +${veryLongLine} + +This is content after the very long line that must also be properly indexed. +It's critical that this content is not ignored due to the oversized line bug. +We need to ensure all content is processed, not just content before the long line. +Adding more content to ensure we meet minimum block requirements.` + + // Mock parseMarkdown to return no headers (testing fallback chunking) + mockParseMarkdown.mockReturnValue([]) + + const result = await parser.parseFile("test.md", { content: markdownContent }) + + // The content should be chunked due to the oversized line + expect(result.length).toBeGreaterThan(1) + + // Should have segment blocks for the oversized line + const segmentBlocks = result.filter((r) => r.type.includes("_segment")) + expect(segmentBlocks.length).toBeGreaterThan(0) + + // Verify that content after the long line is included + const lastBlock = result[result.length - 1] + expect(lastBlock.content).toContain("content after the very long line") + + // Verify all segments are from the oversized line + segmentBlocks.forEach((block) => { + expect(block.content).toMatch(/^a+$/) + }) + }) + + test("should handle multiple oversized lines in sequence", async () => { + // Test with multiple consecutive oversized lines + const longLine1 = "x".repeat(3000) + const longLine2 = "y".repeat(3000) + const longLine3 = "z".repeat(3000) + + const markdownContent = `# Test Multiple Long Lines +Normal content before the long lines. +${longLine1} +${longLine2} +${longLine3} +Normal content after the long lines that must be indexed. +This content verifies that processing continues after multiple oversized lines.` + + mockParseMarkdown.mockReturnValue([ + { + node: { + startPosition: { row: 0 }, + endPosition: { row: 6 }, + text: "Test Multiple Long Lines", + }, + name: "name.definition.header.h1", + patternIndex: 0, + } as any, + { + node: { + startPosition: { row: 0 }, + endPosition: { row: 6 }, + text: "Test Multiple Long Lines", + }, + name: "definition.header.h1", + patternIndex: 0, + } as any, + ]) + + const result = await parser.parseFile("test.md", { content: markdownContent }) + + // Should have multiple segment blocks + const segmentBlocks = result.filter((r) => r.type === "markdown_header_h1_segment") + expect(segmentBlocks.length).toBeGreaterThan(6) // At least 3 segments per long line + + // Should also have regular blocks for the normal content + const regularBlocks = result.filter((r) => r.type === "markdown_header_h1" && !r.type.includes("_segment")) + expect(regularBlocks.length).toBeGreaterThan(0) + + // Verify the last block includes content after the long lines + const lastRegularBlock = regularBlocks[regularBlocks.length - 1] + expect(lastRegularBlock.content).toContain("Normal content after the long lines") + }) + }) + + describe("Edge case: Single oversized line in markdown", () => { + beforeEach(() => { + mockLoadParsers.mockReset() + mockParseMarkdown.mockReset() + mockLanguageParser.js.parser.parse.mockReset() + mockLanguageParser.js.query.captures.mockReset() + + mockLanguageParser.js.parser.parse.mockImplementation((content: string) => ({ + rootNode: { + text: content, + startPosition: { row: 0 }, + endPosition: { row: content.split("\n").length - 1 }, + children: [], + type: "program", + }, + })) + mockLanguageParser.js.query.captures.mockReturnValue([]) + mockLoadParsers.mockResolvedValue(mockLanguageParser as any) + }) + + test("should properly chunk a markdown file with a single very long line", async () => { + const parser = new CodeParser() + const veryLongLine = "x".repeat(5000) // 5000 chars in a single line + + // Mock parseMarkdown to return empty array (no headers) + mockParseMarkdown.mockReturnValue([]) + + const results = await parser["parseContent"]("test.md", veryLongLine, "test-hash") + + // Should create multiple segments + expect(results.length).toBeGreaterThan(1) + expect(results.length).toBe(5) // 5000 / 1000 = 5 segments + + // All chunks should be segments + const segments = results.filter((r) => r.type === "markdown_content_segment") + expect(segments.length).toBe(5) + + // Verify content is preserved + const reconstructed = results.map((r) => r.content).join("") + expect(reconstructed).toBe(veryLongLine) + + // Each segment (except possibly the last) should be MAX_BLOCK_CHARS (1000) + for (let i = 0; i < segments.length - 1; i++) { + expect(segments[i].content.length).toBe(1000) + } + + // Last segment should have the remainder + expect(segments[segments.length - 1].content.length).toBe(1000) + }) + + test("should handle markdown with headers followed by oversized lines", async () => { + const parser = new CodeParser() + const longLineA = "a".repeat(2000) + const longLineB = "b".repeat(3000) + const content = `# Header 1\n\n${longLineA}\n\n## Header 2\n\n${longLineB}` + + // Mock parseMarkdown to return headers + mockParseMarkdown.mockReturnValue([ + { + node: { startPosition: { row: 0 }, endPosition: { row: 2 }, text: "Header 1" }, + name: "name.definition.header.h1", + patternIndex: 0, + }, + { + node: { startPosition: { row: 0 }, endPosition: { row: 2 }, text: "Header 1" }, + name: "definition.header.h1", + patternIndex: 0, + }, + { + node: { startPosition: { row: 4 }, endPosition: { row: 6 }, text: "Header 2" }, + name: "name.definition.header.h2", + patternIndex: 0, + }, + { + node: { startPosition: { row: 4 }, endPosition: { row: 6 }, text: "Header 2" }, + name: "definition.header.h2", + patternIndex: 0, + }, + ] as any) + + const results = await parser["parseContent"]("test.md", content, "test-hash") + + // Should create multiple chunks + expect(results.length).toBeGreaterThan(2) + + // Should have both header chunks and segments + const headers = results.filter((r) => r.type.startsWith("markdown_header")) + const segments = results.filter((r) => r.type.includes("_segment")) + + expect(headers.length).toBeGreaterThan(0) + expect(segments.length).toBeGreaterThan(0) + + // Verify segments were created for oversized lines + // 2000 chars = 2 segments, 3000 chars = 3 segments + expect(segments.length).toBeGreaterThanOrEqual(5) + }) + + test("should not chunk markdown files with lines under the threshold", async () => { + const parser = new CodeParser() + const normalContent = "This is a normal line.\n".repeat(50) // Multiple normal lines + const totalLength = normalContent.length + + // Mock parseMarkdown to return empty array (no headers) + mockParseMarkdown.mockReturnValue([]) + + const results = await parser["parseContent"]("test.md", normalContent, "test-hash") + + // Since total content is 1150 chars (23 * 50), it's just over the threshold + // But no individual line is oversized, so it depends on total length + if (totalLength > 1150) { + // Content exceeds threshold, should be chunked + expect(results.length).toBeGreaterThan(1) + } else { + // Content is under threshold, should be single chunk + expect(results.length).toBe(1) + expect(results[0].type).toBe("markdown_content") + } + }) + + test("should return empty array for markdown content below MIN_BLOCK_CHARS threshold", async () => { + const parser = new CodeParser() + // Create content that is below the new MIN_BLOCK_CHARS threshold of 50 + const smallContent = "Small markdown.\nJust a bit.\nTiny." + + // Mock parseMarkdown to return empty array (no headers) + mockParseMarkdown.mockReturnValue([]) + + const results = await parser["parseContent"]("test.md", smallContent, "test-hash") + + // Should return empty array since content is below MIN_BLOCK_CHARS (50) + expect(results.length).toBe(0) + expect(smallContent.length).toBeLessThan(50) // Verify our test assumption + }) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/processors/scanner.test.ts b/packages/kilo-indexing/test/kilocode/indexing/processors/scanner.test.ts new file mode 100644 index 00000000000..2b3a1a0ffe2 --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/processors/scanner.test.ts @@ -0,0 +1,314 @@ +import { createHash } from "crypto" +import { mkdtemp } from "fs/promises" +import ignore from "ignore" +import { tmpdir } from "os" +import { join } from "path" +import { describe, expect, test } from "bun:test" +import { CacheManager } from "../../../../src/indexing/cache-manager" +import type { + CodeBlock, + ICodeParser, + IEmbedder, + IndexingTelemetryEvent, + IVectorStore, + PointStruct, + VectorStoreSearchResult, +} from "../../../../src/indexing/interfaces" +import { loadIgnore } from "../../../../src/indexing/shared/load-ignore" +import { DirectoryScanner } from "../../../../src/indexing/processors/scanner" + +class Emb implements IEmbedder { + public async createEmbeddings(texts: string[]): Promise<{ embeddings: number[][] }> { + return { + embeddings: texts.map(() => [0.1]), + } + } + + public async validateConfiguration(): Promise<{ valid: boolean; error?: string }> { + return { valid: true } + } + + public get embedderInfo() { + return { name: "openai" as const } + } +} + +class Parser implements ICodeParser { + public async parseFile( + filePath: string, + options?: { + minBlockLines?: number + maxBlockLines?: number + content?: string + fileHash?: string + }, + ): Promise { + return [ + { + file_path: filePath, + identifier: null, + type: "definition.function", + start_line: 1, + end_line: 1, + content: "export const x = 1", + fileHash: options?.fileHash ?? "", + segmentHash: `${filePath}:1:1`, + }, + ] + } +} + +class ManyParser implements ICodeParser { + public async parseFile( + filePath: string, + options?: { + minBlockLines?: number + maxBlockLines?: number + content?: string + fileHash?: string + }, + ): Promise { + return [ + { + file_path: filePath, + identifier: null, + type: "definition.function", + start_line: 1, + end_line: 1, + content: "export const a = 1", + fileHash: options?.fileHash ?? "", + segmentHash: `${filePath}:1:1`, + }, + { + file_path: filePath, + identifier: null, + type: "definition.function", + start_line: 2, + end_line: 2, + content: "export const b = 2", + fileHash: options?.fileHash ?? "", + segmentHash: `${filePath}:2:2`, + }, + { + file_path: filePath, + identifier: null, + type: "definition.function", + start_line: 3, + end_line: 3, + content: "export const c = 3", + fileHash: options?.fileHash ?? "", + segmentHash: `${filePath}:3:3`, + }, + ] + } +} + +class Store implements IVectorStore { + public multi: string[][] = [] + public points = 0 + + public async initialize(): Promise { + return false + } + + public async upsertPoints(points: PointStruct[]): Promise { + this.points += points.length + } + + public async search( + _queryVector: number[], + _directoryPrefix?: string, + _minScore?: number, + _maxResults?: number, + ): Promise { + return [] + } + + public async deletePointsByFilePath(_filePath: string): Promise {} + + public async deletePointsByMultipleFilePaths(filePaths: string[]): Promise { + this.multi.push(filePaths) + } + + public async clearCollection(): Promise {} + + public async deleteCollection(): Promise {} + + public async collectionExists(): Promise { + return true + } + + public async hasIndexedData(): Promise { + return false + } + + public async markIndexingComplete(): Promise {} + + public async markIndexingIncomplete(): Promise {} +} + +class FailStore extends Store { + private calls = 0 + + public override async upsertPoints(points: PointStruct[]): Promise { + this.calls++ + if (this.calls === 2) { + throw new Error("upsert failed") + } + await super.upsertPoints(points) + } +} + +class RetryStore extends Store { + private calls = 0 + + public override async upsertPoints(points: PointStruct[]): Promise { + this.calls += 1 + if (this.calls === 1) { + throw new Error("temporary upsert failure for /tmp/retry/path.ts") + } + await super.upsertPoints(points) + } +} + +describe("DirectoryScanner", () => { + test("keeps file metadata when threshold flush is triggered by that file", async () => { + const root = await mkdtemp(join(tmpdir(), "scanner-test-")) + const cacheDir = await mkdtemp(join(tmpdir(), "scanner-cache-")) + const file = join(root, "main.ts") + const content = "export const value = 2\n" + await Bun.write(file, content) + + const hash = createHash("sha256").update(content).digest("hex") + const cache = new CacheManager(cacheDir, root) + await cache.initialize() + cache.updateHash(file, "old-hash") + + const emb = new Emb() + const parser = new Parser() + const store = new Store() + const scan = new DirectoryScanner(emb, store, parser, cache, ignore(), 1, 1) + + const result = await scan.scanDirectory(root) + + expect(result.stats.processed).toBe(1) + expect(store.points).toBe(1) + expect(store.multi).toEqual([[file]]) + expect(cache.getHash(file)).toBe(hash) + }) + + test("does not mark hash current when a later batch fails for the same file", async () => { + const root = await mkdtemp(join(tmpdir(), "scanner-test-")) + const cacheDir = await mkdtemp(join(tmpdir(), "scanner-cache-")) + const file = join(root, "main.ts") + const content = "export const value = 2\n" + await Bun.write(file, content) + + const cache = new CacheManager(cacheDir, root) + await cache.initialize() + cache.updateHash(file, "old-hash") + + const emb = new Emb() + const parser = new ManyParser() + const store = new FailStore() + const scan = new DirectoryScanner(emb, store, parser, cache, ignore(), 2, 1) + + await scan.scanDirectory(root) + + expect(cache.getHash(file)).toBe("old-hash") + }) + + test("emits candidate counts for scan telemetry", async () => { + const root = await mkdtemp(join(tmpdir(), "scanner-test-")) + const cacheDir = await mkdtemp(join(tmpdir(), "scanner-cache-")) + const file = join(root, "main.ts") + await Bun.write(file, "export const value = 2\n") + + const cache = new CacheManager(cacheDir, root) + await cache.initialize() + + const events: IndexingTelemetryEvent[] = [] + const scan = new DirectoryScanner( + new Emb(), + new Store(), + new Parser(), + cache, + ignore(), + 1, + 1, + (event) => events.push(event), + { + provider: "openai", + vectorStore: "lancedb", + modelId: "text-embedding-3-small", + }, + ) + + await scan.scanDirectory(root, undefined, undefined, undefined, "full") + + const count = events.find((event) => event.type === "file_count") + expect(count).toBeDefined() + expect(count?.type).toBe("file_count") + expect(count?.mode).toBe("full") + expect(count?.source).toBe("scan") + expect(count?.candidate).toBe(1) + }) + + test("skips files matched by .kilocodeignore during full scans", async () => { + const root = await mkdtemp(join(tmpdir(), "scanner-test-")) + const cacheDir = await mkdtemp(join(tmpdir(), "scanner-cache-")) + const blocked = join(root, "blocked.ts") + const open = join(root, "open.ts") + + await Bun.write(join(root, ".kilocodeignore"), "blocked.ts\n") + await Bun.write(blocked, "export const blocked = 1\n") + await Bun.write(open, "export const open = 1\n") + + const cache = new CacheManager(cacheDir, root) + await cache.initialize() + + const scan = new DirectoryScanner(new Emb(), new Store(), new Parser(), cache, await loadIgnore(root), 1, 1) + const result = await scan.scanDirectory(root) + + expect(result.stats.processed).toBe(1) + expect(cache.getHash(blocked)).toBeUndefined() + expect(cache.getHash(open)).toBeDefined() + }) + + test("emits retry telemetry for transient batch failures", async () => { + const root = await mkdtemp(join(tmpdir(), "scanner-test-")) + const cacheDir = await mkdtemp(join(tmpdir(), "scanner-cache-")) + const file = join(root, "main.ts") + await Bun.write(file, "export const value = 2\n") + + const cache = new CacheManager(cacheDir, root) + await cache.initialize() + + const events: IndexingTelemetryEvent[] = [] + const scan = new DirectoryScanner( + new Emb(), + new RetryStore(), + new Parser(), + cache, + ignore(), + 1, + 2, + (event) => events.push(event), + { + provider: "openai", + vectorStore: "lancedb", + modelId: "text-embedding-3-small", + }, + ) + + await scan.scanDirectory(root, undefined, undefined, undefined, "full") + + const retry = events.find((event) => event.type === "batch_retry") + expect(retry).toBeDefined() + expect(retry?.type).toBe("batch_retry") + expect(retry?.source).toBe("scan") + expect(retry?.attempt).toBe(1) + expect(retry?.maxRetries).toBe(2) + expect(retry?.error).toContain("[REDACTED_PATH]") + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts b/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts new file mode 100644 index 00000000000..2b80ef331dc --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/service-factory.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test, mock, beforeEach } from "bun:test" +import path from "path" +import { mockEmbeddingsCreate, openAIMockFactory } from "./embedders/__helpers__/openai-mock" + +mock.module("openai", openAIMockFactory) +import { CodeIndexServiceFactory } from "../../../src/indexing/service-factory" +import { CodeIndexConfigManager } from "../../../src/indexing/config-manager" +import { CacheManager } from "../../../src/indexing/cache-manager" + +const workspacePath = "/tmp/ws" +const cacheDirectory = "/tmp/cache" + +function createFactory(input?: Partial[0]>) { + const cfg = new CodeIndexConfigManager({ + enabled: true, + embedderProvider: "openai", + openAiKey: "sk-test", + ...input, + }) + + const cache = {} as CacheManager + + return new CodeIndexServiceFactory(cfg, workspacePath, cache, cacheDirectory) +} + +describe("CodeIndexServiceFactory", () => { + beforeEach(() => { + mockEmbeddingsCreate.mockReset() + }) + + test("uses default LanceDB directory when config is unset", () => { + const factory = createFactory({ vectorStoreProvider: "lancedb", lancedbVectorStoreDirectory: undefined }) + + const store = factory.createVectorStore() as unknown as { dbPath: string } + + expect(store).toBeDefined() + expect(store.dbPath).toContain(path.join(cacheDirectory, "lancedb")) + }) + + test("uses explicit LanceDB directory when configured", () => { + const dir = "/tmp/custom-lancedb" + const factory = createFactory({ vectorStoreProvider: "lancedb", lancedbVectorStoreDirectory: dir }) + + const store = factory.createVectorStore() as unknown as { dbPath: string } + + expect(store.dbPath).toContain(dir) + }) + + test("passes configured dimension to Ollama embed requests", async () => { + const fn = mock(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ embeddings: [[0.1, 0.2]] }), + } as Response), + ) as unknown as typeof fetch + const prev = global.fetch + global.fetch = fn + + try { + const factory = createFactory({ + embedderProvider: "ollama", + openAiKey: undefined, + ollamaBaseUrl: "http://localhost:11434", + modelId: "mxbai-embed-large", + modelDimension: 1024, + }) + + const embedder = factory.createEmbedder() + await embedder.createEmbeddings(["hello"]) + + const calls = (fn as unknown as { mock: { calls: Array<[string, RequestInit | undefined]> } }).mock.calls + const req = calls[0]?.[1] + if (!req || typeof req.body !== "string") throw new Error("Missing Ollama embed request body") + const body = JSON.parse(req.body) + + expect(body.model).toBe("mxbai-embed-large") + expect(body.input).toEqual(["hello"]) + expect(body.dimensions).toBe(1024) + } finally { + global.fetch = prev + } + }) + + test("leaves Ollama dimensions unset when no override is configured", async () => { + const fn = mock(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ embeddings: [[0.1, 0.2]] }), + } as Response), + ) as unknown as typeof fetch + const prev = global.fetch + global.fetch = fn + + try { + const factory = createFactory({ + embedderProvider: "ollama", + openAiKey: undefined, + ollamaBaseUrl: "http://localhost:11434", + modelId: "mxbai-embed-large", + }) + + const embedder = factory.createEmbedder() + await embedder.createEmbeddings(["hello"]) + + const calls = (fn as unknown as { mock: { calls: Array<[string, RequestInit | undefined]> } }).mock.calls + const req = calls[0]?.[1] + if (!req || typeof req.body !== "string") throw new Error("Missing Ollama embed request body") + const body = JSON.parse(req.body) + + expect(body.model).toBe("mxbai-embed-large") + expect(body.input).toEqual(["hello"]) + expect("dimensions" in body).toBe(false) + } finally { + global.fetch = prev + } + }) + + test("passes configured dimension to OpenRouter embed requests", async () => { + const factory = createFactory({ + embedderProvider: "openrouter", + openAiKey: undefined, + openRouterApiKey: "or-test", + modelId: "openai/text-embedding-3-small", + modelDimension: 1024, + }) + + const testEmbedding = new Float32Array([0.25, 0.5]) + const base64String = Buffer.from(testEmbedding.buffer).toString("base64") + + mockEmbeddingsCreate.mockResolvedValue({ + data: [ + { + embedding: base64String, + }, + ], + usage: { + prompt_tokens: 1, + total_tokens: 1, + }, + }) + + const embedder = factory.createEmbedder() + + await embedder.createEmbeddings(["hello"]) + + expect(mockEmbeddingsCreate).toHaveBeenCalledWith({ + input: ["hello"], + model: "openai/text-embedding-3-small", + encoding_format: "base64", + dimensions: 1024, + }) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/shared/get-relative-path.test.ts b/packages/kilo-indexing/test/kilocode/indexing/shared/get-relative-path.test.ts new file mode 100644 index 00000000000..bc31fe1f46c --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/shared/get-relative-path.test.ts @@ -0,0 +1,91 @@ +import { describe, test, expect } from "bun:test" +import path from "path" + +import { + generateNormalizedAbsolutePath, + generateRelativeFilePath, + generateRelativeIgnorePath, +} from "../../../../src/indexing/shared/get-relative-path" + +describe("get-relative-path", () => { + describe("generateNormalizedAbsolutePath", () => { + test("should use provided workspace root", () => { + const filePath = "src/file.ts" + const workspaceRoot = path.join(path.sep, "custom", "workspace") + const result = generateNormalizedAbsolutePath(filePath, workspaceRoot) + // On Windows, path.resolve adds the drive letter, so we need to use path.resolve for the expected value + expect(result).toBe(path.resolve(workspaceRoot, filePath)) + }) + + test("should handle absolute paths", () => { + const filePath = path.join(path.sep, "absolute", "path", "file.ts") + const workspaceRoot = path.join(path.sep, "custom", "workspace") + const result = generateNormalizedAbsolutePath(filePath, workspaceRoot) + // When an absolute path is provided, it should be resolved to include drive letter on Windows + expect(result).toBe(path.resolve(filePath)) + }) + + test("should normalize paths with . and .. segments", () => { + const filePath = "./src/../src/file.ts" + const workspaceRoot = path.join(path.sep, "custom", "workspace") + const result = generateNormalizedAbsolutePath(filePath, workspaceRoot) + // Use path.resolve to get the expected normalized absolute path + expect(result).toBe(path.resolve(workspaceRoot, "src", "file.ts")) + }) + }) + + describe("generateRelativeFilePath", () => { + test("should use provided workspace root", () => { + const workspaceRoot = path.join(path.sep, "custom", "workspace") + const absolutePath = path.join(workspaceRoot, "src", "file.ts") + const result = generateRelativeFilePath(absolutePath, workspaceRoot) + expect(result).toBe(path.join("src", "file.ts")) + }) + + test("should handle paths outside workspace", () => { + const absolutePath = path.join(path.sep, "outside", "workspace", "file.ts") + const workspaceRoot = path.join(path.sep, "custom", "workspace") + const result = generateRelativeFilePath(absolutePath, workspaceRoot) + // The result will have .. segments to navigate outside + expect(result).toContain("..") + }) + + test("should handle same path as workspace", () => { + const workspaceRoot = path.join(path.sep, "custom", "workspace") + const absolutePath = workspaceRoot + const result = generateRelativeFilePath(absolutePath, workspaceRoot) + expect(result).toBe(".") + }) + + test("should handle multi-workspace scenarios", () => { + // Simulate the error scenario from the issue + const workspaceRoot = path.join(path.sep, "Users", "test", "project") + const absolutePath = path.join(path.sep, "Users", "test", "admin", ".prettierrc.json") + const result = generateRelativeFilePath(absolutePath, workspaceRoot) + // Should generate a valid relative path, not throw an error + expect(result).toBe(path.join("..", "admin", ".prettierrc.json")) + }) + }) + + describe("generateRelativeIgnorePath", () => { + test("should return relative path for files inside workspace", () => { + const workspaceRoot = path.join(path.sep, "custom", "workspace") + const absolutePath = path.join(workspaceRoot, "src", "file.ts") + const result = generateRelativeIgnorePath(absolutePath, workspaceRoot) + expect(result).toBe(path.join("src", "file.ts")) + }) + + test("should return undefined for workspace root path", () => { + const workspaceRoot = path.join(path.sep, "custom", "workspace") + const result = generateRelativeIgnorePath(workspaceRoot, workspaceRoot) + expect(result).toBeUndefined() + }) + + test("should return undefined for paths outside workspace", () => { + const absolutePath = path.join(path.sep, "outside", "workspace", "file.ts") + const workspaceRoot = path.join(path.sep, "custom", "workspace") + const result = generateRelativeIgnorePath(absolutePath, workspaceRoot) + expect(result).toBeUndefined() + }) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/shared/load-ignore.test.ts b/packages/kilo-indexing/test/kilocode/indexing/shared/load-ignore.test.ts new file mode 100644 index 00000000000..0ed75e61d86 --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/shared/load-ignore.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtemp, rm, writeFile } from "fs/promises" +import { tmpdir } from "os" +import path from "path" +import { loadIgnore } from "../../../../src/indexing/shared/load-ignore" + +describe("loadIgnore", () => { + let root = "" + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), "index-ignore-")) + }) + + afterEach(async () => { + if (!root) { + return + } + await rm(root, { recursive: true, force: true }) + }) + + test("loads root .kilocodeignore in addition to root .gitignore", async () => { + await writeFile(path.join(root, ".gitignore"), "dist/\n") + await writeFile(path.join(root, ".kilocodeignore"), "secret/\n") + + const ig = await loadIgnore(root) + + expect(ig.ignores("dist/out.ts")).toBe(true) + expect(ig.ignores("secret/key.ts")).toBe(true) + expect(ig.ignores("src/app.ts")).toBe(false) + }) + + test("preserves existing .gitignore-only behavior when .kilocodeignore is absent", async () => { + await writeFile(path.join(root, ".gitignore"), "coverage/\n") + + const ig = await loadIgnore(root) + + expect(ig.ignores("coverage/index.html")).toBe(true) + expect(ig.ignores("src/app.ts")).toBe(false) + }) + + test("ignores the ignore files themselves", async () => { + await writeFile(path.join(root, ".gitignore"), "dist/\n") + await writeFile(path.join(root, ".kilocodeignore"), "secret/\n") + + const ig = await loadIgnore(root) + + expect(ig.ignores(".gitignore")).toBe(true) + expect(ig.ignores(".kilocodeignore")).toBe(true) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/shared/validation-helpers.test.ts b/packages/kilo-indexing/test/kilocode/indexing/shared/validation-helpers.test.ts new file mode 100644 index 00000000000..b85116ce9af --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/shared/validation-helpers.test.ts @@ -0,0 +1,93 @@ +import { describe, test, expect } from "bun:test" +import { sanitizeErrorMessage } from "../../../../src/indexing/shared/validation-helpers" + +describe("sanitizeErrorMessage", () => { + test("should sanitize Unix-style file paths", () => { + const input = "Error reading file /Users/username/projects/myapp/src/index.ts" + const expected = "Error reading file [REDACTED_PATH]" + expect(sanitizeErrorMessage(input)).toBe(expected) + }) + + test("should sanitize Windows-style file paths", () => { + const input = "Cannot access C:\\Users\\username\\Documents\\project\\file.js" + const expected = "Cannot access [REDACTED_PATH]" + expect(sanitizeErrorMessage(input)).toBe(expected) + }) + + test("should sanitize relative file paths", () => { + const input = "File not found: ./src/components/Button.tsx" + const expected = "File not found: [REDACTED_PATH]" + expect(sanitizeErrorMessage(input)).toBe(expected) + + const input2 = "Cannot read ../config/settings.json" + const expected2 = "Cannot read [REDACTED_PATH]" + expect(sanitizeErrorMessage(input2)).toBe(expected2) + }) + + test("should sanitize URLs with various protocols", () => { + const input = "Failed to connect to http://localhost:11434/api/embed" + const expected = "Failed to connect to [REDACTED_URL]" + expect(sanitizeErrorMessage(input)).toBe(expected) + + const input2 = "Error fetching https://api.example.com:8080/v1/embeddings" + const expected2 = "Error fetching [REDACTED_URL]" + expect(sanitizeErrorMessage(input2)).toBe(expected2) + }) + + test("should sanitize IP addresses", () => { + const input = "Connection refused at 192.168.1.100" + const expected = "Connection refused at [REDACTED_IP]" + expect(sanitizeErrorMessage(input)).toBe(expected) + }) + + test("should sanitize port numbers", () => { + const input = "Server running on :8080 failed" + const expected = "Server running on :[REDACTED_PORT] failed" + expect(sanitizeErrorMessage(input)).toBe(expected) + }) + + test("should sanitize email addresses", () => { + const input = "User john.doe@example.com not found" + const expected = "User [REDACTED_EMAIL] not found" + expect(sanitizeErrorMessage(input)).toBe(expected) + }) + + test("should sanitize paths in quotes", () => { + const input = 'Cannot open file "/home/user/documents/secret.txt"' + const expected = 'Cannot open file "[REDACTED_PATH]"' + expect(sanitizeErrorMessage(input)).toBe(expected) + }) + + test("should handle complex error messages with multiple sensitive items", () => { + const input = "Failed to fetch http://localhost:11434 from /Users/john/project at 192.168.1.1:3000" + const expected = "Failed to fetch [REDACTED_URL] from [REDACTED_PATH] at [REDACTED_IP]:[REDACTED_PORT]" + expect(sanitizeErrorMessage(input)).toBe(expected) + }) + + test("should handle non-string inputs gracefully", () => { + expect(sanitizeErrorMessage(null as any)).toBe("null") + expect(sanitizeErrorMessage(undefined as any)).toBe("undefined") + expect(sanitizeErrorMessage(123 as any)).toBe("123") + expect(sanitizeErrorMessage({} as any)).toBe("[object Object]") + }) + + test("should preserve non-sensitive error messages", () => { + const input = "Invalid JSON format" + expect(sanitizeErrorMessage(input)).toBe(input) + + const input2 = "Connection timeout" + expect(sanitizeErrorMessage(input2)).toBe(input2) + }) + + test("should handle file paths with special characters", () => { + const input = 'Error in "/path/to/file with spaces.txt"' + const expected = 'Error in "[REDACTED_PATH]"' + expect(sanitizeErrorMessage(input)).toBe(expected) + }) + + test("should sanitize multiple occurrences of sensitive data", () => { + const input = "Copy from /src/file1.js to /dest/file2.js failed" + const expected = "Copy from [REDACTED_PATH] to [REDACTED_PATH] failed" + expect(sanitizeErrorMessage(input)).toBe(expected) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/state-manager.test.ts b/packages/kilo-indexing/test/kilocode/indexing/state-manager.test.ts new file mode 100644 index 00000000000..bba357213de --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/state-manager.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { CodeIndexStateManager } from "../../../src/indexing/state-manager" + +describe("CodeIndexStateManager", () => { + test("keeps file unit in the initial snapshot", () => { + const state = new CodeIndexStateManager() + + expect(state.getCurrentStatus()).toMatchObject({ + systemStatus: "Standby", + processedItems: 0, + totalItems: 0, + currentItemUnit: "files", + percent: 0, + }) + }) + + test("reports file-based indexing progress", () => { + const state = new CodeIndexStateManager() + + state.reportFileProgress(2, 5, "foo.ts") + + expect(state.getCurrentStatus()).toMatchObject({ + systemStatus: "Indexing", + processedItems: 2, + totalItems: 5, + currentItemUnit: "files", + percent: 40, + message: "Indexed 2 / 5 files (40%). Current: foo.ts", + }) + }) + + test("preserves final file counts on completion", () => { + const state = new CodeIndexStateManager() + + state.reportFileQueueProgress(3, 3, "bar.ts") + state.setSystemState("Indexed", "Index up-to-date.") + + expect(state.getCurrentStatus()).toMatchObject({ + systemStatus: "Indexed", + processedItems: 3, + totalItems: 3, + currentItemUnit: "files", + percent: 100, + }) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/vector-store/lancedb-loader.test.ts b/packages/kilo-indexing/test/kilocode/indexing/vector-store/lancedb-loader.test.ts new file mode 100644 index 00000000000..d3e467756ba --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/vector-store/lancedb-loader.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, test } from "bun:test" + +const env = "KILO_LANCEDB_PATH" +const prev = process.env[env] + +afterEach(() => { + if (prev === undefined) { + delete process.env[env] + return + } + + process.env[env] = prev +}) + +describe("resolveLanceDBSpecifier", () => { + test("prefers the explicit runtime-installed module URL", async () => { + process.env[env] = "file:///tmp/cache/node_modules/@lancedb/lancedb/dist/index.js" + const { resolveLanceDBSpecifier } = await import("../../../../src/indexing/vector-store/lancedb-loader") + + expect(resolveLanceDBSpecifier()).toBe(process.env[env]) + }) + + test("falls back to the package name when no override is present", async () => { + delete process.env[env] + const { resolveLanceDBSpecifier } = await import("../../../../src/indexing/vector-store/lancedb-loader") + + expect(resolveLanceDBSpecifier()).toBe("@lancedb/lancedb") + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/vector-store/lancedb-vector-store.test.ts b/packages/kilo-indexing/test/kilocode/indexing/vector-store/lancedb-vector-store.test.ts new file mode 100644 index 00000000000..0b47cd6830a --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/vector-store/lancedb-vector-store.test.ts @@ -0,0 +1,708 @@ +/** + * Comprehensive tests for LanceDBVectorStore. + * All LanceDB and fs operations are mocked. + */ + +import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test" +import type { Payload } from "../../../../src/indexing/interfaces" +import * as path from "path" +import fs from "fs" + +const mockTable = { + delete: mock().mockResolvedValue(undefined), + add: mock().mockResolvedValue(undefined), + query: mock().mockReturnThis(), + where: mock().mockReturnThis(), + toArray: mock().mockResolvedValue([]), + countRows: mock().mockResolvedValue(0), + vectorSearch: mock().mockReturnThis(), + limit: mock().mockReturnThis(), + refineFactor: mock().mockReturnThis(), + postfilter: mock().mockReturnThis(), + openTable: mock().mockResolvedValue(undefined), + search: mock().mockReturnThis(), + name: "vector", + isOpen: true, + close: mock(), + display: mock(), + schema: {}, + count: mock(), + get: mock(), + create: mock(), + drop: mock(), + insert: mock(), + update: mock(), + find: mock(), + remove: mock(), + createIndex: mock(), + dropIndex: mock(), + indexes: [], + columns: [], + primaryKey: "id", + metadata: {}, + batch: mock(), + distanceRange: mock().mockReturnThis(), +} +const mockDb = { + openTable: mock().mockResolvedValue(mockTable), + createTable: mock().mockResolvedValue(mockTable), + dropTable: mock().mockResolvedValue(undefined), + tableNames: mock().mockResolvedValue(["vector", "metadata"]), + close: mock().mockResolvedValue(undefined), + isOpen: true, + display: mock(), + createEmptyTable: mock(), + dropAllTables: mock(), +} + +const mockLanceDBModule = { + connect: mock().mockResolvedValue(mockDb), +} + +const mockLoadLanceDB = mock().mockResolvedValue(mockLanceDBModule) + +mock.module("@lancedb/lancedb", () => mockLanceDBModule) +mock.module("../../../../src/indexing/vector-store/lancedb-loader", () => ({ + loadLanceDB: mockLoadLanceDB, +})) + +// Import module under test AFTER mock.module +import { LanceDBVectorStore } from "../../../../src/indexing/vector-store/lancedb-vector-store" + +const workspacePath = path.join("mock", "workspace") +const vectorSize = 768 +const dbDirectory = path.join("mock", "db") +let store: LanceDBVectorStore + +// Collect all mock functions for bulk reset +const allMocks = [ + mockTable.delete, + mockTable.add, + mockTable.query, + mockTable.where, + mockTable.toArray, + mockTable.countRows, + mockTable.vectorSearch, + mockTable.limit, + mockTable.refineFactor, + mockTable.postfilter, + mockTable.openTable, + mockTable.search, + mockTable.close, + mockTable.display, + mockTable.count, + mockTable.get, + mockTable.create, + mockTable.drop, + mockTable.insert, + mockTable.update, + mockTable.find, + mockTable.remove, + mockTable.createIndex, + mockTable.dropIndex, + mockTable.batch, + mockTable.distanceRange, + mockDb.openTable, + mockDb.createTable, + mockDb.dropTable, + mockDb.tableNames, + mockDb.close, + mockDb.display, + mockDb.createEmptyTable, + mockDb.dropAllTables, + mockLanceDBModule.connect, + mockLoadLanceDB, +] + +function resetAllMocks() { + for (const m of allMocks) { + m.mockReset() + } + // Re-apply default resolved values after reset + mockTable.delete.mockResolvedValue(undefined) + mockTable.add.mockResolvedValue(undefined) + mockTable.query.mockReturnThis() + mockTable.where.mockReturnThis() + mockTable.toArray.mockResolvedValue([]) + mockTable.countRows.mockResolvedValue(0) + mockTable.vectorSearch.mockReturnThis() + mockTable.limit.mockReturnThis() + mockTable.refineFactor.mockReturnThis() + mockTable.postfilter.mockReturnThis() + mockTable.openTable.mockResolvedValue(undefined) + mockTable.search.mockReturnThis() + mockTable.distanceRange.mockReturnThis() + mockDb.openTable.mockResolvedValue(mockTable) + mockDb.createTable.mockResolvedValue(mockTable) + mockDb.dropTable.mockResolvedValue(undefined) + mockDb.tableNames.mockResolvedValue(["vector", "metadata"]) + mockDb.close.mockResolvedValue(undefined) + mockLanceDBModule.connect.mockResolvedValue(mockDb) + mockLoadLanceDB.mockResolvedValue(mockLanceDBModule) +} + +describe("LocalVectorStore", () => { + beforeEach(() => { + resetAllMocks() + store = new LanceDBVectorStore(workspacePath, vectorSize, dbDirectory) + // Patch LanceDB module directly for loadLanceDBModule + // @ts-ignore + store.lancedbModule = mockLanceDBModule + // Patch db/table for getDb/getTable + // @ts-ignore + store.db = mockDb + // @ts-ignore + store.table = mockTable + }) + + afterEach(async () => { + await store["closeConnect"]() + }) + + describe("constructor", () => { + test("should set dbPath and vectorSize correctly", () => { + expect(store["vectorSize"]).toBe(vectorSize) + expect(store["workspacePath"]).toBe(workspacePath) + expect(store["dbPath"]).toContain("mock") + }) + + test("loads LanceDB through the shared loader", async () => { + // @ts-ignore + store.lancedbModule = null + await store["loadLanceDBModule"]() + expect(mockLoadLanceDB).toHaveBeenCalledTimes(1) + expect(mockLanceDBModule.connect).not.toHaveBeenCalled() + }) + }) + + describe("initialize", () => { + test("should create tables if not exist", async () => { + mockDb.tableNames.mockResolvedValue([]) + mockDb.createTable.mockResolvedValue(mockTable) + const result = await store.initialize() + expect(result).toBe(true) + expect(mockDb.createTable).toHaveBeenCalled() + }) + + test("should recreate tables if vector size changed", async () => { + mockDb.tableNames.mockResolvedValue(["vector", "metadata"]) + mockDb.openTable.mockResolvedValue(mockTable) + store["_getStoredVectorSize"] = mock().mockResolvedValue(vectorSize + 1) + mockDb.dropTable.mockResolvedValue(undefined) + mockDb.createTable.mockResolvedValue(mockTable) + const result = await store.initialize() + expect(result).toBe(true) + expect(mockDb.dropTable).toHaveBeenCalled() + }) + + test("should not recreate if vector size matches", async () => { + mockDb.tableNames.mockResolvedValue(["vector", "metadata"]) + mockDb.openTable.mockResolvedValue(mockTable) + store["_getStoredVectorSize"] = mock().mockResolvedValue(vectorSize) + const result = await store.initialize() + expect(result).toBe(false) + }) + + test("should throw error on LanceDB failure", async () => { + mockDb.tableNames.mockRejectedValue(new Error("fail")) + await expect(store.initialize()).rejects.toThrow() + }) + + test("should recreate tables when stored embedding identity differs", async () => { + const identity = { + provider: "openai", + modelId: "text-embedding-3-small", + dimension: vectorSize, + } + store = new (LanceDBVectorStore as any)(workspacePath, vectorSize, dbDirectory, identity) + // @ts-ignore + store.lancedbModule = mockLanceDBModule + // @ts-ignore + store.db = mockDb + // @ts-ignore + store.table = mockTable + + const metadataTable = { + query: mock().mockReturnThis(), + where: mock().mockReturnThis(), + toArray: mock().mockResolvedValue([]), + } + + metadataTable.where.mockImplementation((query: string) => { + const rows = { + "key = 'vector_size'": [{ key: "vector_size", value: vectorSize }], + "key = 'embedding_provider'": [{ key: "embedding_provider", value: "ollama" }], + "key = 'embedding_model_id'": [{ key: "embedding_model_id", value: "nomic-embed-text" }], + "key = 'embedding_dimension'": [{ key: "embedding_dimension", value: vectorSize }], + } + metadataTable.toArray.mockResolvedValue(rows[query as keyof typeof rows] ?? []) + return metadataTable + }) + + mockDb.tableNames.mockResolvedValue(["vector", "metadata"]) + mockTable.countRows.mockResolvedValue(4) + mockDb.openTable.mockImplementation((name: string) => { + if (name === "metadata") return Promise.resolve(metadataTable as any) + return Promise.resolve(mockTable as any) + }) + + const result = await store.initialize() + + expect(result).toBe(true) + expect(mockDb.dropTable).toHaveBeenCalledWith("vector") + expect(mockDb.dropTable).toHaveBeenCalledWith("metadata") + }) + + test("should recreate legacy populated tables when identity metadata is missing", async () => { + const identity = { + provider: "openai", + modelId: "text-embedding-3-small", + dimension: vectorSize, + } + store = new (LanceDBVectorStore as any)(workspacePath, vectorSize, dbDirectory, identity) + // @ts-ignore + store.lancedbModule = mockLanceDBModule + // @ts-ignore + store.db = mockDb + // @ts-ignore + store.table = mockTable + + const metadataTable = { + query: mock().mockReturnThis(), + where: mock().mockReturnThis(), + toArray: mock().mockResolvedValue([]), + } + + metadataTable.where.mockImplementation((query: string) => { + const rows = { + "key = 'vector_size'": [{ key: "vector_size", value: vectorSize }], + "key = 'embedding_provider'": [], + "key = 'embedding_model_id'": [], + "key = 'embedding_dimension'": [], + } + metadataTable.toArray.mockResolvedValue(rows[query as keyof typeof rows] ?? []) + return metadataTable + }) + + mockDb.tableNames.mockResolvedValue(["vector", "metadata"]) + mockTable.countRows.mockResolvedValue(2) + mockDb.openTable.mockImplementation((name: string) => { + if (name === "metadata") return Promise.resolve(metadataTable as any) + return Promise.resolve(mockTable as any) + }) + + const result = await store.initialize() + + expect(result).toBe(true) + expect(mockDb.dropTable).toHaveBeenCalledWith("vector") + expect(mockDb.dropTable).toHaveBeenCalledWith("metadata") + }) + }) + + describe("upsertPoints", () => { + test("should do nothing for empty points", async () => { + await expect(store.upsertPoints([])).resolves.toBeUndefined() + }) + + test("should do nothing for invalid payloads", async () => { + const points = [{ id: "1", vector: [1, 2, 3], payload: {} }] + mockTable.add.mockResolvedValue(undefined) + await expect(store.upsertPoints(points)).resolves.toBeUndefined() + expect(mockTable.add).not.toHaveBeenCalled() + }) + + test("should upsert valid points", async () => { + const points = [ + { + id: "123e4567-e89b-12d3-a456-426614174000", + vector: [1, 2, 3], + payload: { filePath: "a", codeChunk: "b", startLine: 1, endLine: 2 }, + }, + ] + mockTable.delete.mockResolvedValue(undefined) + mockTable.add.mockResolvedValue(undefined) + await store.upsertPoints(points) + expect(mockTable.delete).toHaveBeenCalled() + expect(mockTable.add).toHaveBeenCalled() + }) + + test("should throw error on add failure", async () => { + const points = [ + { + id: "123e4567-e89b-12d3-a456-426614174000", + vector: [1, 2, 3], + payload: { filePath: "a", codeChunk: "b", startLine: 1, endLine: 2 }, + }, + ] + mockTable.delete.mockResolvedValue(undefined) + mockTable.add.mockRejectedValue(new Error("fail")) + await expect(store.upsertPoints(points)).rejects.toThrow() + }) + }) + + describe("search", () => { + test("should return filtered results using distanceRange", async () => { + const distanceRangeSpy = mock().mockReturnThis() + mockTable.search.mockResolvedValue({ + where: mock().mockReturnThis(), + distanceType: mock().mockReturnThis(), + distanceRange: distanceRangeSpy, + limit: mock().mockReturnThis(), + toArray: mock().mockResolvedValue([ + { id: "2", _distance: 0.2, filePath: "a", codeChunk: "c", startLine: 3, endLine: 4 }, + ]), + }) + const results = await store.search([1, 2, 3], "a", 0.7, 1) + expect(results.length).toBe(1) + const first = results[0] + expect(first).toBeDefined() + expect(first!.id).toBe("2") + expect(first!.score).toBeCloseTo(1 - 0.2) + // Verify distanceRange was called with correct parameters (0 to 1 - minScore) + const calls = distanceRangeSpy.mock.calls[0] + expect(calls).toBeDefined() + expect(calls![0]).toBe(0) + expect(calls![1]).toBeCloseTo(0.3) // Handle floating point precision: 1 - 0.7 + }) + + test("should filter by minScore at database level", async () => { + const distanceRangeSpy = mock().mockReturnThis() + mockTable.search.mockResolvedValue({ + where: mock().mockReturnThis(), + distanceType: mock().mockReturnThis(), + distanceRange: distanceRangeSpy, + limit: mock().mockReturnThis(), + toArray: mock().mockResolvedValue([ + { id: "2", _distance: 0.2, filePath: "a", codeChunk: "c", startLine: 3, endLine: 4 }, + ]), + }) + const results = await store.search([1, 2, 3], "a", 0.1, 2) + expect(results.length).toBe(1) + const first = results[0] + expect(first).toBeDefined() + expect(first!.id).toBe("2") + // Verify distanceRange was called with 0 to 0.9 (1 - 0.1) + expect(distanceRangeSpy).toHaveBeenCalledWith(0, 0.9) + }) + + test("should throw error on search failure", async () => { + mockTable.search.mockRejectedValue(new Error("fail")) + await expect(store.search([1, 2, 3])).rejects.toThrow() + }) + }) + + describe("deletePointsByFilePath", () => { + test("should call deletePointsByMultipleFilePaths", async () => { + const spy = spyOn(store, "deletePointsByMultipleFilePaths").mockResolvedValue(undefined) + await store.deletePointsByFilePath("a") + expect(spy).toHaveBeenCalledWith(["a"]) + }) + }) + + describe("deletePointsByMultipleFilePaths", () => { + test("should do nothing for empty filePaths", async () => { + await expect(store.deletePointsByMultipleFilePaths([])).resolves.toBeUndefined() + }) + + test("should delete points for valid filePaths", async () => { + mockTable.delete.mockResolvedValue(undefined) + await store.deletePointsByMultipleFilePaths(["a", "b"]) + expect(mockTable.delete).toHaveBeenCalled() + }) + + test("should throw error on delete failure", async () => { + mockTable.delete.mockRejectedValue(new Error("fail")) + await expect(store.deletePointsByMultipleFilePaths(["a"])).rejects.toThrow() + }) + }) + + describe("deleteCollection", () => { + test("should remove dbPath if exists", async () => { + spyOn(fs, "existsSync").mockReturnValue(true as any) + spyOn(fs, "rmSync").mockImplementation(() => {}) + await expect(store.deleteCollection()).resolves.toBeUndefined() + expect(fs.rmSync).toHaveBeenCalled() + }) + + test("should clear tables if rmSync fails", async () => { + spyOn(fs, "existsSync").mockReturnValue(true as any) + spyOn(fs, "rmSync").mockImplementation(() => { + throw new Error("fail") + }) + mockDb.tableNames.mockImplementation(() => ["vector"]) + mockDb.dropTable.mockResolvedValue(undefined) + await expect(store.deleteCollection()).rejects.toThrow() + expect(mockDb.dropTable).toHaveBeenCalled() + }) + }) + + describe("clearCollection", () => { + test("should delete all records from table and metadata", async () => { + mockTable.delete.mockResolvedValue(undefined) + mockDb.tableNames.mockResolvedValue(["metadata"]) + mockDb.openTable.mockResolvedValue(mockTable) + mockTable.delete.mockResolvedValue(undefined) + await expect(store.clearCollection()).resolves.toBeUndefined() + expect(mockTable.delete).toHaveBeenCalledWith("true") + }) + + test("should warn if metadata table clear fails", async () => { + mockTable.delete.mockResolvedValue(undefined) + mockDb.tableNames.mockResolvedValue(["metadata"]) + mockDb.openTable.mockRejectedValue(new Error("fail")) + await expect(store.clearCollection()).resolves.toBeUndefined() + }) + + test("should throw error on main table clear failure", async () => { + mockTable.delete.mockRejectedValue(new Error("fail")) + await expect(store.clearCollection()).rejects.toThrow() + }) + }) + + describe("collectionExists", () => { + test("should return true if vector table exists", async () => { + mockDb.tableNames.mockResolvedValue(["vector"]) + const exists = await store.collectionExists() + expect(exists).toBe(true) + }) + + test("should return false if vector table does not exist", async () => { + mockDb.tableNames.mockResolvedValue([]) + const exists = await store.collectionExists() + expect(exists).toBe(false) + }) + + test("should return false on error", async () => { + mockDb.tableNames.mockRejectedValue(new Error("fail")) + const exists = await store.collectionExists() + expect(exists).toBe(false) + }) + }) + + describe("isPayloadValid", () => { + test("should return false for null/undefined", () => { + expect(store["isPayloadValid"](null)).toBe(false) + expect(store["isPayloadValid"](undefined)).toBe(false) + }) + + test("should return false for missing keys", () => { + expect(store["isPayloadValid"]({ filePath: "a" })).toBe(false) + }) + + test("should return true for valid payload", () => { + const payload: Payload = { filePath: "a", codeChunk: "b", startLine: 1, endLine: 2 } + expect(store["isPayloadValid"](payload)).toBe(true) + }) + }) + + describe("escapeSqlString", () => { + test("should double single quotes", () => { + expect(store["escapeSqlString"]("O'Reilly")).toBe("O''Reilly") + }) + + test("should handle SQL injection attempts", () => { + expect(store["escapeSqlString"]("' OR '1'='1")).toBe("'' OR ''1''=''1") + }) + + test("should handle multiple consecutive quotes", () => { + expect(store["escapeSqlString"]("''")).toBe("''''") + }) + + test("should handle empty strings", () => { + expect(store["escapeSqlString"]("")).toBe("") + }) + + test("should preserve backslashes", () => { + expect(store["escapeSqlString"]("C:\\Users\\test")).toBe("C:\\Users\\test") + }) + + test("should handle strings with no special characters", () => { + expect(store["escapeSqlString"]("normalstring")).toBe("normalstring") + }) + + test("should handle unicode characters", () => { + expect(store["escapeSqlString"]("test's 文件")).toBe("test''s 文件") + }) + + test("should prevent comment injection attempts", () => { + expect(store["escapeSqlString"]("test' --")).toBe("test'' --") + }) + }) + + describe("escapeSqlLikePattern", () => { + test("should escape percent signs", () => { + expect(store["escapeSqlLikePattern"]("50%")).toBe("50\\%") + }) + + test("should escape underscores", () => { + expect(store["escapeSqlLikePattern"]("test_file")).toBe("test\\_file") + }) + + test("should escape both quotes and wildcards", () => { + expect(store["escapeSqlLikePattern"]("test'_%")).toBe("test''\\_\\%") + }) + + test("should handle empty strings", () => { + expect(store["escapeSqlLikePattern"]("")).toBe("") + }) + + test("should handle multiple wildcards", () => { + expect(store["escapeSqlLikePattern"]("%%__")).toBe("\\%\\%\\_\\_") + }) + + test("should escape quotes before wildcards", () => { + const result = store["escapeSqlLikePattern"]("path's%file_name") + expect(result).toBe("path''s\\%file\\_name") + }) + + test("should escape backslashes in Windows paths", () => { + expect(store["escapeSqlLikePattern"]("C:\\Users\\test")).toBe("C:\\\\Users\\\\test") + }) + + test("should escape backslashes before wildcards", () => { + expect(store["escapeSqlLikePattern"]("C:\\test_file%")).toBe("C:\\\\test\\_file\\%") + }) + + test("should handle backslash at end", () => { + expect(store["escapeSqlLikePattern"]("path\\")).toBe("path\\\\") + }) + }) + + describe("isValidId", () => { + test("should accept UUID ids", () => { + const id = "123e4567-e89b-12d3-a456-426614174000" + expect(store["isValidId"](id)).toBe(true) + }) + + test("should reject non UUID ids", () => { + expect(store["isValidId"]("test")).toBe(false) + expect(store["isValidId"]("test' OR id != '")).toBe(false) + expect(store["isValidId"]("123e4567-e89b-12d3-a456")).toBe(false) + }) + }) + + describe("SQL Injection Prevention", () => { + test("should reject non UUID ids in upsertPoints", async () => { + const maliciousId = "test' OR id != '" + const points = [ + { + id: maliciousId, + vector: [1, 2, 3], + payload: { + filePath: "test.ts", + codeChunk: "code", + startLine: 1, + endLine: 2, + }, + }, + ] + + mockTable.delete.mockResolvedValue(undefined) + mockTable.add.mockResolvedValue(undefined) + await expect(store.upsertPoints(points)).rejects.toThrow("Invalid point id format") + expect(mockTable.delete).not.toHaveBeenCalled() + expect(mockTable.add).not.toHaveBeenCalled() + }) + + test("should reject batches that include non UUID ids", async () => { + const points = [ + { + id: "123e4567-e89b-12d3-a456-426614174000", + vector: [1, 2, 3], + payload: { filePath: "a", codeChunk: "b", startLine: 1, endLine: 2 }, + }, + { + id: "' OR '1'='1", + vector: [4, 5, 6], + payload: { filePath: "c", codeChunk: "d", startLine: 3, endLine: 4 }, + }, + ] + + mockTable.delete.mockResolvedValue(undefined) + mockTable.add.mockResolvedValue(undefined) + await expect(store.upsertPoints(points)).rejects.toThrow("Invalid point id format") + expect(mockTable.delete).not.toHaveBeenCalled() + expect(mockTable.add).not.toHaveBeenCalled() + }) + + test("should prevent injection via directory prefix in search", async () => { + const maliciousPrefix = "src' OR '1'='1" + const whereSpy = mock().mockReturnThis() + mockTable.search.mockResolvedValue({ + where: whereSpy, + distanceType: mock().mockReturnThis(), + distanceRange: mock().mockReturnThis(), + limit: mock().mockReturnThis(), + toArray: mock().mockResolvedValue([]), + }) + + await store.search([1, 2, 3], maliciousPrefix) + + // Verify proper escaping in the where clause + expect(whereSpy).toHaveBeenCalledWith("`filePath` LIKE 'src'' OR ''1''=''1%'") + }) + + test("should prevent injection with wildcards in directory prefix", async () => { + const maliciousPrefix = "50%_test" + const whereSpy = mock().mockReturnThis() + mockTable.search.mockResolvedValue({ + where: whereSpy, + distanceType: mock().mockReturnThis(), + distanceRange: mock().mockReturnThis(), + limit: mock().mockReturnThis(), + toArray: mock().mockResolvedValue([]), + }) + + await store.search([1, 2, 3], maliciousPrefix) + + expect(whereSpy).toHaveBeenCalledWith("`filePath` LIKE '50\\%\\_test%'") + }) + + test("should handle Windows paths with backslashes in search", async () => { + const windowsPrefix = "C:\\Users\\test" + const whereSpy = mock().mockReturnThis() + mockTable.search.mockResolvedValue({ + where: whereSpy, + distanceType: mock().mockReturnThis(), + distanceRange: mock().mockReturnThis(), + limit: mock().mockReturnThis(), + toArray: mock().mockResolvedValue([]), + }) + + await store.search([1, 2, 3], windowsPrefix) + + // Backslashes should be escaped in LIKE patterns + expect(whereSpy).toHaveBeenCalledWith("`filePath` LIKE 'C:\\\\Users\\\\test%'") + }) + + test("should prevent injection via file paths in deletePointsByFilePath", async () => { + const maliciousPath = "file.ts' OR '1'='1" + mockTable.delete.mockResolvedValue(undefined) + + await store.deletePointsByFilePath(maliciousPath) + + expect(mockTable.delete).toHaveBeenCalledWith("`filePath` IN ('file.ts'' OR ''1''=''1')") + }) + + test("should prevent injection via multiple file paths in deletePointsByMultipleFilePaths", async () => { + const paths = ["normal.ts", "file' OR '1'='1.ts", "another.ts"] + mockTable.delete.mockResolvedValue(undefined) + + await store.deletePointsByMultipleFilePaths(paths) + + expect(mockTable.delete).toHaveBeenCalledWith( + "`filePath` IN ('normal.ts', 'file'' OR ''1''=''1.ts', 'another.ts')", + ) + }) + + test("should handle paths with backslashes safely", async () => { + const windowsPath = "C:\\Users\\test\\file.ts" + mockTable.delete.mockResolvedValue(undefined) + + await store.deletePointsByFilePath(windowsPath) + + // Backslashes should be preserved, only quotes escaped + expect(mockTable.delete).toHaveBeenCalledWith(`\`filePath\` IN ('C:\\Users\\test\\file.ts')`) + }) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/indexing/vector-store/qdrant-client.test.ts b/packages/kilo-indexing/test/kilocode/indexing/vector-store/qdrant-client.test.ts new file mode 100644 index 00000000000..8a580bcde83 --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/vector-store/qdrant-client.test.ts @@ -0,0 +1,1797 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from "bun:test" + +import { DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_SEARCH_MIN_SCORE } from "../../../../src/indexing/constants" + +// Set up mocks BEFORE importing modules that use them +const mockGetCollection = mock() +const mockCreateCollection = mock() +const mockDeleteCollection = mock() +const mockCreatePayloadIndex = mock() +const mockUpsert = mock() +const mockQuery = mock() +const mockDelete = mock() +const mockRetrieve = mock() + +const mockQdrantClientInstance = { + getCollection: mockGetCollection, + createCollection: mockCreateCollection, + deleteCollection: mockDeleteCollection, + createPayloadIndex: mockCreatePayloadIndex, + upsert: mockUpsert, + query: mockQuery, + delete: mockDelete, + retrieve: mockRetrieve, +} + +const MockQdrantClientConstructor = mock(() => mockQdrantClientInstance) + +mock.module("@qdrant/js-client-rest", () => ({ + QdrantClient: MockQdrantClientConstructor, +})) + +const mockDigest = mock() +const mockUpdate = mock(() => ({ update: mockUpdate, digest: mockDigest })) +const mockCreateHash = mock(() => ({ update: mockUpdate, digest: mockDigest })) + +mock.module("crypto", () => ({ + createHash: mockCreateHash, +})) + +// Now import the module under test +import { QdrantVectorStore } from "../../../../src/indexing/vector-store/qdrant-client" + +describe("QdrantVectorStore", () => { + let vectorStore: QdrantVectorStore + const mockWorkspacePath = "/test/workspace" + const mockQdrantUrl = "http://mock-qdrant:6333" + const mockApiKey = "test-api-key" + const mockVectorSize = 1536 + const mockHashedPath = "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6" + const expectedCollectionName = `ws-${mockHashedPath.substring(0, 16)}` + + beforeEach(() => { + // Reset all mocks + MockQdrantClientConstructor.mockClear() + MockQdrantClientConstructor.mockReturnValue(mockQdrantClientInstance) + mockGetCollection.mockReset() + mockCreateCollection.mockReset() + mockDeleteCollection.mockReset() + mockCreatePayloadIndex.mockReset() + mockUpsert.mockReset() + mockQuery.mockReset() + mockDelete.mockReset() + mockRetrieve.mockReset() + mockCreateHash.mockReset() + mockUpdate.mockReset() + mockDigest.mockReset() + + // Mock crypto.createHash chain + mockCreateHash.mockReturnValue({ update: mockUpdate, digest: mockDigest }) + mockUpdate.mockReturnValue({ update: mockUpdate, digest: mockDigest }) + mockDigest.mockReturnValue(mockHashedPath) + + vectorStore = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize, mockApiKey) + }) + + afterEach(() => { + MockQdrantClientConstructor.mockClear() + mockGetCollection.mockReset() + mockCreateCollection.mockReset() + mockDeleteCollection.mockReset() + mockCreatePayloadIndex.mockReset() + mockUpsert.mockReset() + mockQuery.mockReset() + mockDelete.mockReset() + mockRetrieve.mockReset() + mockCreateHash.mockReset() + mockUpdate.mockReset() + mockDigest.mockReset() + }) + + test("should correctly initialize QdrantClient and collectionName in constructor", () => { + expect(MockQdrantClientConstructor).toHaveBeenCalledTimes(1) + expect(MockQdrantClientConstructor).toHaveBeenCalledWith({ + host: "mock-qdrant", + https: false, + port: 6333, + apiKey: mockApiKey, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect(mockCreateHash).toHaveBeenCalledWith("sha256") + expect(mockUpdate).toHaveBeenCalledWith(mockWorkspacePath) + expect(mockDigest).toHaveBeenCalledWith("hex") + expect((vectorStore as any).collectionName).toBe(expectedCollectionName) + expect((vectorStore as any).vectorSize).toBe(mockVectorSize) + }) + + test("should handle constructor with default URL when none provided", () => { + const vectorStoreWithDefaults = new QdrantVectorStore(mockWorkspacePath, undefined as any, mockVectorSize) + + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "localhost", + https: false, + port: 6333, + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + }) + + test("should handle constructor without API key", () => { + const vectorStoreWithoutKey = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, mockVectorSize) + + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "mock-qdrant", + https: false, + port: 6333, + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + }) + + describe("URL Parsing and Explicit Port Handling", () => { + describe("HTTPS URL handling", () => { + test("should use explicit port 443 for HTTPS URLs without port (fixes the main bug)", () => { + const vectorStore = new QdrantVectorStore(mockWorkspacePath, "https://qdrant.ashbyfam.com", mockVectorSize) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "qdrant.ashbyfam.com", + https: true, + port: 443, + prefix: undefined, + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStore as any).qdrantUrl).toBe("https://qdrant.ashbyfam.com") + }) + + test("should use explicit port for HTTPS URLs with explicit port", () => { + const vectorStore = new QdrantVectorStore(mockWorkspacePath, "https://example.com:9000", mockVectorSize) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "example.com", + https: true, + port: 9000, + prefix: undefined, + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStore as any).qdrantUrl).toBe("https://example.com:9000") + }) + + test("should use port 443 for HTTPS URLs with paths and query parameters", () => { + const vectorStore = new QdrantVectorStore( + mockWorkspacePath, + "https://example.com/api/v1?key=value", + mockVectorSize, + ) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "example.com", + https: true, + port: 443, + prefix: "/api/v1", + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStore as any).qdrantUrl).toBe("https://example.com/api/v1?key=value") + }) + }) + + describe("HTTP URL handling", () => { + test("should use explicit port 80 for HTTP URLs without port", () => { + const vectorStore = new QdrantVectorStore(mockWorkspacePath, "http://example.com", mockVectorSize) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "example.com", + https: false, + port: 80, + prefix: undefined, + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStore as any).qdrantUrl).toBe("http://example.com") + }) + + test("should use explicit port for HTTP URLs with explicit port", () => { + const vectorStore = new QdrantVectorStore(mockWorkspacePath, "http://localhost:8080", mockVectorSize) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "localhost", + https: false, + port: 8080, + prefix: undefined, + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStore as any).qdrantUrl).toBe("http://localhost:8080") + }) + + test("should use port 80 for HTTP URLs while preserving paths and query parameters", () => { + const vectorStore = new QdrantVectorStore( + mockWorkspacePath, + "http://example.com/api/v1?key=value", + mockVectorSize, + ) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "example.com", + https: false, + port: 80, + prefix: "/api/v1", + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStore as any).qdrantUrl).toBe("http://example.com/api/v1?key=value") + }) + }) + + describe("Hostname handling", () => { + test("should convert hostname to http with port 80", () => { + const vectorStore = new QdrantVectorStore(mockWorkspacePath, "qdrant.example.com", mockVectorSize) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "qdrant.example.com", + https: false, + port: 80, + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStore as any).qdrantUrl).toBe("http://qdrant.example.com") + }) + + test("should handle hostname:port format with explicit port", () => { + const vectorStore = new QdrantVectorStore(mockWorkspacePath, "localhost:6333", mockVectorSize) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "localhost", + https: false, + port: 6333, + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStore as any).qdrantUrl).toBe("http://localhost:6333") + }) + + test("should handle explicit HTTP URLs correctly", () => { + const vectorStore = new QdrantVectorStore(mockWorkspacePath, "http://localhost:9000", mockVectorSize) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "localhost", + https: false, + port: 9000, + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStore as any).qdrantUrl).toBe("http://localhost:9000") + }) + }) + + describe("IP address handling", () => { + test("should convert IP address to http with port 80", () => { + const vectorStore = new QdrantVectorStore(mockWorkspacePath, "192.168.1.100", mockVectorSize) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "192.168.1.100", + https: false, + port: 80, + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStore as any).qdrantUrl).toBe("http://192.168.1.100") + }) + + test("should handle IP:port format with explicit port", () => { + const vectorStore = new QdrantVectorStore(mockWorkspacePath, "192.168.1.100:6333", mockVectorSize) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "192.168.1.100", + https: false, + port: 6333, + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStore as any).qdrantUrl).toBe("http://192.168.1.100:6333") + }) + }) + + describe("Edge cases", () => { + test("should handle undefined URL with host-based config", () => { + const vectorStore = new QdrantVectorStore(mockWorkspacePath, undefined as any, mockVectorSize) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "localhost", + https: false, + port: 6333, + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStore as any).qdrantUrl).toBe("http://localhost:6333") + }) + + test("should handle empty string URL with host-based config", () => { + const vectorStore = new QdrantVectorStore(mockWorkspacePath, "", mockVectorSize) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "localhost", + https: false, + port: 6333, + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStore as any).qdrantUrl).toBe("http://localhost:6333") + }) + + test("should handle whitespace-only URL with host-based config", () => { + const vectorStore = new QdrantVectorStore(mockWorkspacePath, " ", mockVectorSize) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "localhost", + https: false, + port: 6333, + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStore as any).qdrantUrl).toBe("http://localhost:6333") + }) + }) + + describe("Invalid URL fallback", () => { + test("should treat invalid URLs as hostnames with port 80", () => { + const vectorStore = new QdrantVectorStore(mockWorkspacePath, "invalid-url-format", mockVectorSize) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "invalid-url-format", + https: false, + port: 80, + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStore as any).qdrantUrl).toBe("http://invalid-url-format") + }) + }) + }) + + describe("URL Prefix Handling", () => { + test("should pass the URL pathname as prefix to QdrantClient if not root", () => { + const vectorStoreWithPrefix = new QdrantVectorStore( + mockWorkspacePath, + "http://localhost:6333/some/path", + mockVectorSize, + ) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "localhost", + https: false, + port: 6333, + prefix: "/some/path", + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStoreWithPrefix as any).qdrantUrl).toBe("http://localhost:6333/some/path") + }) + + test("should not pass prefix if the URL pathname is root ('/')", () => { + const vectorStoreWithoutPrefix = new QdrantVectorStore( + mockWorkspacePath, + "http://localhost:6333/", + mockVectorSize, + ) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "localhost", + https: false, + port: 6333, + prefix: undefined, + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStoreWithoutPrefix as any).qdrantUrl).toBe("http://localhost:6333/") + }) + + test("should handle HTTPS URL with path as prefix", () => { + const vectorStoreWithHttpsPrefix = new QdrantVectorStore( + mockWorkspacePath, + "https://qdrant.ashbyfam.com/api", + mockVectorSize, + ) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "qdrant.ashbyfam.com", + https: true, + port: 443, + prefix: "/api", + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStoreWithHttpsPrefix as any).qdrantUrl).toBe("https://qdrant.ashbyfam.com/api") + }) + + test("should normalize URL pathname by removing trailing slash for prefix", () => { + const vectorStoreWithTrailingSlash = new QdrantVectorStore( + mockWorkspacePath, + "http://localhost:6333/api/", + mockVectorSize, + ) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "localhost", + https: false, + port: 6333, + prefix: "/api", + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStoreWithTrailingSlash as any).qdrantUrl).toBe("http://localhost:6333/api/") + }) + + test("should normalize URL pathname by removing multiple trailing slashes for prefix", () => { + const vectorStoreWithMultipleTrailingSlashes = new QdrantVectorStore( + mockWorkspacePath, + "http://localhost:6333/api///", + mockVectorSize, + ) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "localhost", + https: false, + port: 6333, + prefix: "/api", + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStoreWithMultipleTrailingSlashes as any).qdrantUrl).toBe("http://localhost:6333/api///") + }) + + test("should handle multiple path segments correctly for prefix", () => { + const vectorStoreWithMultiSegment = new QdrantVectorStore( + mockWorkspacePath, + "http://localhost:6333/api/v1/qdrant", + mockVectorSize, + ) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "localhost", + https: false, + port: 6333, + prefix: "/api/v1/qdrant", + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStoreWithMultiSegment as any).qdrantUrl).toBe("http://localhost:6333/api/v1/qdrant") + }) + + test("should handle complex URL with multiple segments, multiple trailing slashes, query params, and fragment", () => { + const complexUrl = "https://example.com/ollama/api/v1///?key=value#pos" + const vectorStoreComplex = new QdrantVectorStore(mockWorkspacePath, complexUrl, mockVectorSize) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "example.com", + https: true, + port: 443, + prefix: "/ollama/api/v1", + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStoreComplex as any).qdrantUrl).toBe(complexUrl) + }) + + test("should ignore query parameters and fragments when determining prefix", () => { + const vectorStoreWithQueryParams = new QdrantVectorStore( + mockWorkspacePath, + "http://localhost:6333/api/path?key=value#fragment", + mockVectorSize, + ) + expect(MockQdrantClientConstructor).toHaveBeenLastCalledWith({ + host: "localhost", + https: false, + port: 6333, + prefix: "/api/path", + apiKey: undefined, + headers: { + "User-Agent": "Kilo-Code", + }, + }) + expect((vectorStoreWithQueryParams as any).qdrantUrl).toBe("http://localhost:6333/api/path?key=value#fragment") + }) + }) + + describe("initialize", () => { + test("should create a new collection if none exists and return true", async () => { + mockGetCollection.mockRejectedValue({ + response: { status: 404 }, + message: "Not found", + }) + mockCreateCollection.mockResolvedValue(true as any) + mockCreatePayloadIndex.mockResolvedValue({} as any) + + const result = await vectorStore.initialize() + + expect(result).toBe(true) + expect(mockGetCollection).toHaveBeenCalledTimes(1) + expect(mockGetCollection).toHaveBeenCalledWith(expectedCollectionName) + expect(mockCreateCollection).toHaveBeenCalledTimes(1) + expect(mockCreateCollection).toHaveBeenCalledWith(expectedCollectionName, { + vectors: { + size: mockVectorSize, + distance: "Cosine", + on_disk: true, + }, + hnsw_config: { + m: 64, + ef_construct: 512, + on_disk: true, + }, + }) + expect(mockDeleteCollection).not.toHaveBeenCalled() + + expect(mockCreatePayloadIndex).toHaveBeenCalledWith(expectedCollectionName, { + field_name: "type", + field_schema: "keyword", + }) + for (let i = 0; i <= 4; i++) { + expect(mockCreatePayloadIndex).toHaveBeenCalledWith(expectedCollectionName, { + field_name: `pathSegments.${i}`, + field_schema: "keyword", + }) + } + expect(mockCreatePayloadIndex).toHaveBeenCalledTimes(6) + }) + + test("should not create a new collection if one exists with matching vectorSize and return false", async () => { + mockGetCollection.mockResolvedValue({ + config: { + params: { + vectors: { + size: mockVectorSize, + }, + }, + }, + } as any) + mockCreatePayloadIndex.mockResolvedValue({} as any) + + const result = await vectorStore.initialize() + + expect(result).toBe(false) + expect(mockGetCollection).toHaveBeenCalledTimes(1) + expect(mockGetCollection).toHaveBeenCalledWith(expectedCollectionName) + expect(mockCreateCollection).not.toHaveBeenCalled() + expect(mockDeleteCollection).not.toHaveBeenCalled() + + expect(mockCreatePayloadIndex).toHaveBeenCalledWith(expectedCollectionName, { + field_name: "type", + field_schema: "keyword", + }) + for (let i = 0; i <= 4; i++) { + expect(mockCreatePayloadIndex).toHaveBeenCalledWith(expectedCollectionName, { + field_name: `pathSegments.${i}`, + field_schema: "keyword", + }) + } + expect(mockCreatePayloadIndex).toHaveBeenCalledTimes(6) + }) + + test("should recreate collection when stored embedding identity mismatches", async () => { + const identity = { + provider: "openai", + modelId: "text-embedding-3-small", + dimension: mockVectorSize, + } + const store = new (QdrantVectorStore as any)( + mockWorkspacePath, + mockQdrantUrl, + mockVectorSize, + mockApiKey, + identity, + ) + + mockGetCollection + .mockResolvedValueOnce({ + points_count: 7, + config: { + params: { + vectors: { + size: mockVectorSize, + }, + }, + }, + } as any) + .mockRejectedValueOnce({ + response: { status: 404 }, + message: "Not found", + }) + mockRetrieve.mockResolvedValue([ + { + payload: { + indexing_complete: true, + embedding_provider: "ollama", + embedding_model_id: "nomic-embed-text", + embedding_dimension: 768, + }, + }, + ] as any) + mockDeleteCollection.mockResolvedValue(true as any) + mockCreateCollection.mockResolvedValue(true as any) + mockCreatePayloadIndex.mockResolvedValue({} as any) + + const result = await store.initialize() + + expect(result).toBe(true) + expect(mockRetrieve).toHaveBeenCalledTimes(1) + expect(mockDeleteCollection).toHaveBeenCalledTimes(1) + expect(mockCreateCollection).toHaveBeenCalledTimes(1) + }) + + test("should recreate populated legacy collection when identity metadata is missing", async () => { + const identity = { + provider: "openai", + modelId: "text-embedding-3-small", + dimension: mockVectorSize, + } + const store = new (QdrantVectorStore as any)( + mockWorkspacePath, + mockQdrantUrl, + mockVectorSize, + mockApiKey, + identity, + ) + + mockGetCollection + .mockResolvedValueOnce({ + points_count: 12, + config: { + params: { + vectors: { + size: mockVectorSize, + }, + }, + }, + } as any) + .mockRejectedValueOnce({ + response: { status: 404 }, + message: "Not found", + }) + mockRetrieve.mockResolvedValue([ + { + payload: { + indexing_complete: true, + }, + }, + ] as any) + mockDeleteCollection.mockResolvedValue(true as any) + mockCreateCollection.mockResolvedValue(true as any) + mockCreatePayloadIndex.mockResolvedValue({} as any) + + const result = await store.initialize() + + expect(result).toBe(true) + expect(mockRetrieve).toHaveBeenCalledTimes(1) + expect(mockDeleteCollection).toHaveBeenCalledTimes(1) + expect(mockCreateCollection).toHaveBeenCalledTimes(1) + }) + + test("should recreate collection if it exists but vectorSize mismatches and return true", async () => { + const differentVectorSize = 768 + mockGetCollection + .mockResolvedValueOnce({ + config: { + params: { + vectors: { + size: differentVectorSize, + }, + }, + }, + } as any) + .mockRejectedValueOnce({ + response: { status: 404 }, + message: "Not found", + }) + mockDeleteCollection.mockResolvedValue(true as any) + mockCreateCollection.mockResolvedValue(true as any) + mockCreatePayloadIndex.mockResolvedValue({} as any) + + const result = await vectorStore.initialize() + + expect(result).toBe(true) + expect(mockGetCollection).toHaveBeenCalledTimes(2) + expect(mockGetCollection).toHaveBeenCalledWith(expectedCollectionName) + expect(mockDeleteCollection).toHaveBeenCalledTimes(1) + expect(mockDeleteCollection).toHaveBeenCalledWith(expectedCollectionName) + expect(mockCreateCollection).toHaveBeenCalledTimes(1) + expect(mockCreateCollection).toHaveBeenCalledWith(expectedCollectionName, { + vectors: { + size: mockVectorSize, + distance: "Cosine", + on_disk: true, + }, + hnsw_config: { + m: 64, + ef_construct: 512, + on_disk: true, + }, + }) + + expect(mockCreatePayloadIndex).toHaveBeenCalledWith(expectedCollectionName, { + field_name: "type", + field_schema: "keyword", + }) + for (let i = 0; i <= 4; i++) { + expect(mockCreatePayloadIndex).toHaveBeenCalledWith(expectedCollectionName, { + field_name: `pathSegments.${i}`, + field_schema: "keyword", + }) + } + expect(mockCreatePayloadIndex).toHaveBeenCalledTimes(6) + }) + + test("should log warning for non-404 errors but still create collection", async () => { + const genericError = new Error("Generic Qdrant Error") + mockGetCollection.mockRejectedValue(genericError) + mockCreateCollection.mockResolvedValue(true as any) + mockCreatePayloadIndex.mockResolvedValue({} as any) + + const result = await vectorStore.initialize() + + expect(result).toBe(true) + expect(mockGetCollection).toHaveBeenCalledTimes(1) + expect(mockCreateCollection).toHaveBeenCalledTimes(1) + expect(mockDeleteCollection).not.toHaveBeenCalled() + expect(mockCreatePayloadIndex).toHaveBeenCalledTimes(6) + }) + + test("should re-throw error from createCollection when no collection initially exists", async () => { + mockGetCollection.mockRejectedValue({ + response: { status: 404 }, + message: "Not found", + }) + const createError = new Error("Create Collection Failed") + mockCreateCollection.mockRejectedValue(createError) + + await expect(vectorStore.initialize()).rejects.toThrow(/Failed to connect to Qdrant at/) + + expect(mockGetCollection).toHaveBeenCalledTimes(1) + expect(mockCreateCollection).toHaveBeenCalledTimes(1) + expect(mockDeleteCollection).not.toHaveBeenCalled() + expect(mockCreatePayloadIndex).not.toHaveBeenCalled() + }) + + test("should log but not fail if payload index creation errors occur", async () => { + mockGetCollection.mockRejectedValue({ + response: { status: 404 }, + message: "Not found", + }) + mockCreateCollection.mockResolvedValue(true as any) + + const indexError = new Error("Index creation failed") + mockCreatePayloadIndex.mockRejectedValue(indexError) + + const result = await vectorStore.initialize() + + expect(result).toBe(true) + expect(mockCreateCollection).toHaveBeenCalledTimes(1) + + // All payload index creations should be attempted (6: type + 5 pathSegments) + expect(mockCreatePayloadIndex).toHaveBeenCalledTimes(6) + }) + + test("should throw vectorDimensionMismatch error when deleteCollection fails during recreation", async () => { + const differentVectorSize = 768 + mockGetCollection.mockResolvedValue({ + config: { + params: { + vectors: { + size: differentVectorSize, + }, + }, + }, + } as any) + + const deleteError = new Error("Delete Collection Failed") + mockDeleteCollection.mockRejectedValue(deleteError) + + let caughtError: any + try { + await vectorStore.initialize() + } catch (error: any) { + caughtError = error + } + + expect(caughtError).toBeDefined() + expect(caughtError.message).toContain("Vector dimension mismatch") + expect(caughtError.cause).toBe(deleteError) + + expect(mockGetCollection).toHaveBeenCalledTimes(1) + expect(mockDeleteCollection).toHaveBeenCalledTimes(1) + expect(mockCreateCollection).not.toHaveBeenCalled() + expect(mockCreatePayloadIndex).not.toHaveBeenCalled() + }) + + test("should throw vectorDimensionMismatch error when createCollection fails during recreation", async () => { + const differentVectorSize = 768 + mockGetCollection + .mockResolvedValueOnce({ + config: { + params: { + vectors: { + size: differentVectorSize, + }, + }, + }, + } as any) + .mockRejectedValueOnce({ + response: { status: 404 }, + message: "Not found", + }) + + mockDeleteCollection.mockResolvedValue(true as any) + const createError = new Error("Create Collection Failed") + mockCreateCollection.mockRejectedValue(createError) + + let caughtError: any + try { + await vectorStore.initialize() + } catch (error: any) { + caughtError = error + } + + expect(caughtError).toBeDefined() + expect(caughtError.message).toContain("Vector dimension mismatch") + expect(caughtError.cause).toBe(createError) + + expect(mockGetCollection).toHaveBeenCalledTimes(2) + expect(mockDeleteCollection).toHaveBeenCalledTimes(1) + expect(mockCreateCollection).toHaveBeenCalledTimes(1) + expect(mockCreatePayloadIndex).not.toHaveBeenCalled() + }) + + test("should verify collection deletion before proceeding with recreation", async () => { + const differentVectorSize = 768 + mockGetCollection + .mockResolvedValueOnce({ + config: { + params: { + vectors: { + size: differentVectorSize, + }, + }, + }, + } as any) + .mockRejectedValueOnce({ + response: { status: 404 }, + message: "Not found", + }) + + mockDeleteCollection.mockResolvedValue(true as any) + mockCreateCollection.mockResolvedValue(true as any) + mockCreatePayloadIndex.mockResolvedValue({} as any) + + const result = await vectorStore.initialize() + + expect(result).toBe(true) + // Should call getCollection twice: once to check existing, once to verify deletion + expect(mockGetCollection).toHaveBeenCalledTimes(2) + expect(mockDeleteCollection).toHaveBeenCalledTimes(1) + expect(mockCreateCollection).toHaveBeenCalledTimes(1) + expect(mockCreatePayloadIndex).toHaveBeenCalledTimes(6) + }) + + test("should throw error if collection still exists after deletion attempt", async () => { + const differentVectorSize = 768 + mockGetCollection + .mockResolvedValueOnce({ + config: { + params: { + vectors: { + size: differentVectorSize, + }, + }, + }, + } as any) + // Second call should still return the collection (deletion failed) + .mockResolvedValueOnce({ + config: { + params: { + vectors: { + size: differentVectorSize, + }, + }, + }, + } as any) + + mockDeleteCollection.mockResolvedValue(true as any) + + let caughtError: any + try { + await vectorStore.initialize() + } catch (error: any) { + caughtError = error + } + + expect(caughtError).toBeDefined() + expect(caughtError.message).toContain("Vector dimension mismatch") + expect(caughtError.message).toContain("Deleted existing collection but failed verification step") + + expect(mockGetCollection).toHaveBeenCalledTimes(2) + expect(mockDeleteCollection).toHaveBeenCalledTimes(1) + expect(mockCreateCollection).not.toHaveBeenCalled() + expect(mockCreatePayloadIndex).not.toHaveBeenCalled() + }) + + test("should handle dimension mismatch scenario from 2048 to 768 dimensions", async () => { + const oldVectorSize = 2048 + const newVectorSize = 768 + + const newVectorStore = new QdrantVectorStore(mockWorkspacePath, mockQdrantUrl, newVectorSize, mockApiKey) + + mockGetCollection + .mockResolvedValueOnce({ + config: { + params: { + vectors: { + size: oldVectorSize, + }, + }, + }, + } as any) + .mockRejectedValueOnce({ + response: { status: 404 }, + message: "Not found", + }) + + mockDeleteCollection.mockResolvedValue(true as any) + mockCreateCollection.mockResolvedValue(true as any) + mockCreatePayloadIndex.mockResolvedValue({} as any) + + const result = await newVectorStore.initialize() + + expect(result).toBe(true) + expect(mockGetCollection).toHaveBeenCalledTimes(2) + expect(mockDeleteCollection).toHaveBeenCalledTimes(1) + expect(mockCreateCollection).toHaveBeenCalledWith(expectedCollectionName, { + vectors: { + size: newVectorSize, + distance: "Cosine", + on_disk: true, + }, + hnsw_config: { + m: 64, + ef_construct: 512, + on_disk: true, + }, + }) + expect(mockCreatePayloadIndex).toHaveBeenCalledTimes(6) + }) + + test("should provide detailed error context for different failure scenarios", async () => { + const differentVectorSize = 768 + mockGetCollection.mockResolvedValue({ + config: { + params: { + vectors: { + size: differentVectorSize, + }, + }, + }, + } as any) + + const deleteError = new Error("Qdrant server unavailable") + mockDeleteCollection.mockRejectedValue(deleteError) + + let caughtError: any + try { + await vectorStore.initialize() + } catch (error: any) { + caughtError = error + } + + expect(caughtError).toBeDefined() + expect(caughtError.message).toContain("Vector dimension mismatch") + expect(caughtError.message).toContain("Failed to delete existing collection with vector size") + expect(caughtError.message).toContain("Qdrant server unavailable") + expect(caughtError.cause).toBe(deleteError) + }) + }) + + test("should return true when collection exists", async () => { + mockGetCollection.mockResolvedValue({ + config: { + /* collection data */ + }, + } as any) + + const result = await vectorStore.collectionExists() + + expect(result).toBe(true) + expect(mockGetCollection).toHaveBeenCalledTimes(1) + expect(mockGetCollection).toHaveBeenCalledWith(expectedCollectionName) + }) + + test("should return false when collection does not exist (404 error)", async () => { + mockGetCollection.mockRejectedValue({ + response: { status: 404 }, + message: "Not found", + }) + + const result = await vectorStore.collectionExists() + + expect(result).toBe(false) + expect(mockGetCollection).toHaveBeenCalledTimes(1) + expect(mockGetCollection).toHaveBeenCalledWith(expectedCollectionName) + }) + + test("should return false and log warning for non-404 errors", async () => { + const genericError = new Error("Network error") + mockGetCollection.mockRejectedValue(genericError) + + const result = await vectorStore.collectionExists() + + expect(result).toBe(false) + expect(mockGetCollection).toHaveBeenCalledTimes(1) + }) + + describe("deleteCollection", () => { + test("should delete collection when it exists", async () => { + // Mock getCollection to simulate existing collection (collectionExists returns true) + mockGetCollection.mockResolvedValue({ config: {} } as any) + mockDeleteCollection.mockResolvedValue(true as any) + + await vectorStore.deleteCollection() + + expect(mockGetCollection).toHaveBeenCalledTimes(1) + expect(mockDeleteCollection).toHaveBeenCalledTimes(1) + expect(mockDeleteCollection).toHaveBeenCalledWith(expectedCollectionName) + }) + + test("should not attempt to delete collection when it does not exist", async () => { + // Mock getCollection to throw (collectionExists returns false) + mockGetCollection.mockRejectedValue(new Error("Not found")) + + await vectorStore.deleteCollection() + + expect(mockGetCollection).toHaveBeenCalledTimes(1) + expect(mockDeleteCollection).not.toHaveBeenCalled() + }) + + test("should log and re-throw error when deletion fails", async () => { + mockGetCollection.mockResolvedValue({ config: {} } as any) + const deleteError = new Error("Deletion failed") + mockDeleteCollection.mockRejectedValue(deleteError) + + await expect(vectorStore.deleteCollection()).rejects.toThrow(deleteError) + + expect(mockGetCollection).toHaveBeenCalledTimes(1) + expect(mockDeleteCollection).toHaveBeenCalledTimes(1) + }) + }) + + describe("upsertPoints", () => { + test("should correctly call qdrantClient.upsert with processed points", async () => { + const mockPoints = [ + { + id: "test-id-1", + vector: [0.1, 0.2, 0.3], + payload: { + filePath: "src/components/Button.tsx", + content: "export const Button = () => {}", + startLine: 1, + endLine: 3, + }, + }, + { + id: "test-id-2", + vector: [0.4, 0.5, 0.6], + payload: { + filePath: "src/utils/helpers.ts", + content: "export function helper() {}", + startLine: 5, + endLine: 7, + }, + }, + ] + + mockUpsert.mockResolvedValue({} as any) + + await vectorStore.upsertPoints(mockPoints) + + expect(mockUpsert).toHaveBeenCalledTimes(1) + expect(mockUpsert).toHaveBeenCalledWith(expectedCollectionName, { + points: [ + { + id: "test-id-1", + vector: [0.1, 0.2, 0.3], + payload: { + filePath: "src/components/Button.tsx", + content: "export const Button = () => {}", + startLine: 1, + endLine: 3, + pathSegments: { + "0": "src", + "1": "components", + "2": "Button.tsx", + }, + }, + }, + { + id: "test-id-2", + vector: [0.4, 0.5, 0.6], + payload: { + filePath: "src/utils/helpers.ts", + content: "export function helper() {}", + startLine: 5, + endLine: 7, + pathSegments: { + "0": "src", + "1": "utils", + "2": "helpers.ts", + }, + }, + }, + ], + wait: true, + }) + }) + + test("should handle points without filePath in payload", async () => { + const mockPoints = [ + { + id: "test-id-1", + vector: [0.1, 0.2, 0.3], + payload: { + content: "some content without filePath", + startLine: 1, + endLine: 3, + }, + }, + ] + + mockUpsert.mockResolvedValue({} as any) + + await vectorStore.upsertPoints(mockPoints) + + expect(mockUpsert).toHaveBeenCalledWith(expectedCollectionName, { + points: [ + { + id: "test-id-1", + vector: [0.1, 0.2, 0.3], + payload: { + content: "some content without filePath", + startLine: 1, + endLine: 3, + }, + }, + ], + wait: true, + }) + }) + + test("should handle empty input arrays", async () => { + mockUpsert.mockResolvedValue({} as any) + + await vectorStore.upsertPoints([]) + + expect(mockUpsert).toHaveBeenCalledWith(expectedCollectionName, { + points: [], + wait: true, + }) + }) + + test("should correctly process pathSegments for nested file paths", async () => { + const mockPoints = [ + { + id: "test-id-1", + vector: [0.1, 0.2, 0.3], + payload: { + filePath: "src/components/ui/forms/InputField.tsx", + content: "export const InputField = () => {}", + startLine: 1, + endLine: 3, + }, + }, + ] + + mockUpsert.mockResolvedValue({} as any) + + await vectorStore.upsertPoints(mockPoints) + + expect(mockUpsert).toHaveBeenCalledWith(expectedCollectionName, { + points: [ + { + id: "test-id-1", + vector: [0.1, 0.2, 0.3], + payload: { + filePath: "src/components/ui/forms/InputField.tsx", + content: "export const InputField = () => {}", + startLine: 1, + endLine: 3, + pathSegments: { + "0": "src", + "1": "components", + "2": "ui", + "3": "forms", + "4": "InputField.tsx", + }, + }, + }, + ], + wait: true, + }) + }) + + test("should handle error scenarios when qdrantClient.upsert fails", async () => { + const mockPoints = [ + { + id: "test-id-1", + vector: [0.1, 0.2, 0.3], + payload: { + filePath: "src/test.ts", + content: "test content", + startLine: 1, + endLine: 1, + }, + }, + ] + + const upsertError = new Error("Upsert failed") + mockUpsert.mockRejectedValue(upsertError) + + await expect(vectorStore.upsertPoints(mockPoints)).rejects.toThrow(upsertError) + + expect(mockUpsert).toHaveBeenCalledTimes(1) + }) + }) + + describe("search", () => { + test("should correctly call qdrantClient.query and transform results", async () => { + const queryVector = [0.1, 0.2, 0.3] + const mockQdrantResults = { + points: [ + { + id: "test-id-1", + score: 0.85, + payload: { + filePath: "src/test.ts", + codeChunk: "test code", + startLine: 1, + endLine: 5, + pathSegments: { "0": "src", "1": "test.ts" }, + }, + }, + { + id: "test-id-2", + score: 0.75, + payload: { + filePath: "src/utils.ts", + codeChunk: "utility code", + startLine: 10, + endLine: 15, + pathSegments: { "0": "src", "1": "utils.ts" }, + }, + }, + ], + } + + mockQuery.mockResolvedValue(mockQdrantResults) + + const results = await vectorStore.search(queryVector) + + expect(mockQuery).toHaveBeenCalledTimes(1) + const callArgs = mockQuery.mock.calls[0][1] + expect(callArgs).toMatchObject({ + query: queryVector, + score_threshold: DEFAULT_SEARCH_MIN_SCORE, + limit: DEFAULT_MAX_SEARCH_RESULTS, + params: { + hnsw_ef: 128, + exact: false, + }, + with_payload: { + include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + }, + }) + expect(callArgs.filter).toEqual({ + must_not: [{ key: "type", match: { value: "metadata" } }], + }) + + expect(results).toEqual(mockQdrantResults.points) + }) + + test("should apply filePathPrefix filter correctly", async () => { + const queryVector = [0.1, 0.2, 0.3] + const directoryPrefix = "src/components" + const mockQdrantResults = { + points: [ + { + id: "test-id-1", + score: 0.85, + payload: { + filePath: "src/components/Button.tsx", + codeChunk: "button code", + startLine: 1, + endLine: 5, + pathSegments: { "0": "src", "1": "components", "2": "Button.tsx" }, + }, + }, + ], + } + + mockQuery.mockResolvedValue(mockQdrantResults) + + const results = await vectorStore.search(queryVector, directoryPrefix) + + const callArgs2 = mockQuery.mock.calls[0][1] + expect(callArgs2).toMatchObject({ + query: queryVector, + score_threshold: DEFAULT_SEARCH_MIN_SCORE, + limit: DEFAULT_MAX_SEARCH_RESULTS, + params: { hnsw_ef: 128, exact: false }, + with_payload: { include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"] }, + }) + expect(callArgs2.filter).toEqual({ + must: [ + { key: "pathSegments.0", match: { value: "src" } }, + { key: "pathSegments.1", match: { value: "components" } }, + ], + must_not: [{ key: "type", match: { value: "metadata" } }], + }) + + expect(results).toEqual(mockQdrantResults.points) + }) + + test("should use custom minScore when provided", async () => { + const queryVector = [0.1, 0.2, 0.3] + const customMinScore = 0.8 + const mockQdrantResults = { points: [] } + + mockQuery.mockResolvedValue(mockQdrantResults) + + await vectorStore.search(queryVector, undefined, customMinScore) + + const callArgs3 = mockQuery.mock.calls[0][1] + expect(callArgs3).toMatchObject({ + query: queryVector, + score_threshold: customMinScore, + limit: DEFAULT_MAX_SEARCH_RESULTS, + params: { + hnsw_ef: 128, + exact: false, + }, + with_payload: { + include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + }, + }) + expect(callArgs3.filter).toEqual({ + must_not: [{ key: "type", match: { value: "metadata" } }], + }) + }) + + test("should use custom maxResults when provided", async () => { + const queryVector = [0.1, 0.2, 0.3] + const customMaxResults = 100 + const mockQdrantResults = { points: [] } + + mockQuery.mockResolvedValue(mockQdrantResults) + + await vectorStore.search(queryVector, undefined, undefined, customMaxResults) + + const callArgs4 = mockQuery.mock.calls[0][1] + expect(callArgs4).toMatchObject({ + query: queryVector, + score_threshold: DEFAULT_SEARCH_MIN_SCORE, + limit: customMaxResults, + params: { + hnsw_ef: 128, + exact: false, + }, + with_payload: { + include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + }, + }) + expect(callArgs4.filter).toEqual({ + must_not: [{ key: "type", match: { value: "metadata" } }], + }) + }) + + test("should filter out results with invalid payloads", async () => { + const queryVector = [0.1, 0.2, 0.3] + const mockQdrantResults = { + points: [ + { + id: "valid-result", + score: 0.85, + payload: { + filePath: "src/test.ts", + codeChunk: "test code", + startLine: 1, + endLine: 5, + }, + }, + { + id: "invalid-result-1", + score: 0.75, + payload: { + filePath: "src/invalid.ts", + }, + }, + { + id: "valid-result-2", + score: 0.55, + payload: { + filePath: "src/test2.ts", + codeChunk: "test code 2", + startLine: 10, + endLine: 15, + }, + }, + ], + } + + mockQuery.mockResolvedValue(mockQdrantResults) + + const results = await vectorStore.search(queryVector) + + expect(results).toHaveLength(2) + expect(results[0].id).toBe("valid-result") + expect(results[1].id).toBe("valid-result-2") + }) + + test("should filter out results with null or undefined payloads", async () => { + const queryVector = [0.1, 0.2, 0.3] + const mockQdrantResults = { + points: [ + { + id: "valid-result", + score: 0.85, + payload: { + filePath: "src/test.ts", + codeChunk: "test code", + startLine: 1, + endLine: 5, + }, + }, + { + id: "null-payload-result", + score: 0.75, + payload: null, + }, + { + id: "undefined-payload-result", + score: 0.65, + payload: undefined, + }, + { + id: "valid-result-2", + score: 0.55, + payload: { + filePath: "src/test2.ts", + codeChunk: "test code 2", + startLine: 10, + endLine: 15, + }, + }, + ], + } + + mockQuery.mockResolvedValue(mockQdrantResults) + + const results = await vectorStore.search(queryVector) + + expect(results).toHaveLength(2) + expect(results[0].id).toBe("valid-result") + expect(results[1].id).toBe("valid-result-2") + }) + + test("should handle scenarios where no results are found", async () => { + const queryVector = [0.1, 0.2, 0.3] + const mockQdrantResults = { points: [] } + + mockQuery.mockResolvedValue(mockQdrantResults) + + const results = await vectorStore.search(queryVector) + + expect(mockQuery).toHaveBeenCalledTimes(1) + expect(results).toEqual([]) + }) + + test("should handle complex directory prefix with multiple segments", async () => { + const queryVector = [0.1, 0.2, 0.3] + const directoryPrefix = "src/components/ui/forms" + const mockQdrantResults = { points: [] } + + mockQuery.mockResolvedValue(mockQdrantResults) + + await vectorStore.search(queryVector, directoryPrefix) + + const callArgs5 = mockQuery.mock.calls[0][1] + expect(callArgs5).toMatchObject({ + query: queryVector, + score_threshold: DEFAULT_SEARCH_MIN_SCORE, + limit: DEFAULT_MAX_SEARCH_RESULTS, + params: { + hnsw_ef: 128, + exact: false, + }, + with_payload: { + include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + }, + }) + expect(callArgs5.filter).toEqual({ + must: [ + { key: "pathSegments.0", match: { value: "src" } }, + { key: "pathSegments.1", match: { value: "components" } }, + { key: "pathSegments.2", match: { value: "ui" } }, + { key: "pathSegments.3", match: { value: "forms" } }, + ], + must_not: [{ key: "type", match: { value: "metadata" } }], + }) + }) + + test("should handle error scenarios when qdrantClient.query fails", async () => { + const queryVector = [0.1, 0.2, 0.3] + const queryError = new Error("Query failed") + mockQuery.mockRejectedValue(queryError) + + await expect(vectorStore.search(queryVector)).rejects.toThrow(queryError) + + expect(mockQuery).toHaveBeenCalledTimes(1) + }) + + test("should use constants DEFAULT_MAX_SEARCH_RESULTS and DEFAULT_SEARCH_MIN_SCORE correctly", async () => { + const queryVector = [0.1, 0.2, 0.3] + const mockQdrantResults = { points: [] } + + mockQuery.mockResolvedValue(mockQdrantResults) + + await vectorStore.search(queryVector) + + const callArgs = mockQuery.mock.calls[0][1] + expect(callArgs.limit).toBe(DEFAULT_MAX_SEARCH_RESULTS) + expect(callArgs.score_threshold).toBe(DEFAULT_SEARCH_MIN_SCORE) + }) + + describe("current directory path handling", () => { + test("should not apply filter when directoryPrefix is '.'", async () => { + const queryVector = [0.1, 0.2, 0.3] + const directoryPrefix = "." + const mockQdrantResults = { + points: [ + { + id: "test-id-1", + score: 0.85, + payload: { + filePath: "src/test.ts", + codeChunk: "test code", + startLine: 1, + endLine: 5, + pathSegments: { "0": "src", "1": "test.ts" }, + }, + }, + ], + } + + mockQuery.mockResolvedValue(mockQdrantResults) + + const results = await vectorStore.search(queryVector, directoryPrefix) + + const callArgs7 = mockQuery.mock.calls[0][1] + expect(callArgs7).toMatchObject({ + query: queryVector, + score_threshold: DEFAULT_SEARCH_MIN_SCORE, + limit: DEFAULT_MAX_SEARCH_RESULTS, + params: { + hnsw_ef: 128, + exact: false, + }, + with_payload: { + include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + }, + }) + expect(callArgs7.filter).toEqual({ + must_not: [{ key: "type", match: { value: "metadata" } }], + }) + + expect(results).toEqual(mockQdrantResults.points) + }) + + test("should not apply filter when directoryPrefix is './'", async () => { + const queryVector = [0.1, 0.2, 0.3] + const directoryPrefix = "./" + const mockQdrantResults = { points: [] } + + mockQuery.mockResolvedValue(mockQdrantResults) + + await vectorStore.search(queryVector, directoryPrefix) + + const callArgs6 = mockQuery.mock.calls[0][1] + expect(callArgs6).toMatchObject({ + query: queryVector, + score_threshold: DEFAULT_SEARCH_MIN_SCORE, + limit: DEFAULT_MAX_SEARCH_RESULTS, + params: { + hnsw_ef: 128, + exact: false, + }, + with_payload: { + include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + }, + }) + expect(callArgs6.filter).toEqual({ + must_not: [{ key: "type", match: { value: "metadata" } }], + }) + }) + + test("should not apply filter when directoryPrefix is empty string", async () => { + const queryVector = [0.1, 0.2, 0.3] + const directoryPrefix = "" + const mockQdrantResults = { points: [] } + + mockQuery.mockResolvedValue(mockQdrantResults) + + await vectorStore.search(queryVector, directoryPrefix) + + const callArgs8 = mockQuery.mock.calls[0][1] + expect(callArgs8).toMatchObject({ + query: queryVector, + score_threshold: DEFAULT_SEARCH_MIN_SCORE, + limit: DEFAULT_MAX_SEARCH_RESULTS, + params: { + hnsw_ef: 128, + exact: false, + }, + with_payload: { + include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + }, + }) + expect(callArgs8.filter).toEqual({ + must_not: [{ key: "type", match: { value: "metadata" } }], + }) + }) + + test("should not apply filter when directoryPrefix is '.\\' (Windows style)", async () => { + const queryVector = [0.1, 0.2, 0.3] + const directoryPrefix = ".\\" + const mockQdrantResults = { points: [] } + + mockQuery.mockResolvedValue(mockQdrantResults) + + await vectorStore.search(queryVector, directoryPrefix) + + const callArgs9 = mockQuery.mock.calls[0][1] + expect(callArgs9).toMatchObject({ + query: queryVector, + score_threshold: DEFAULT_SEARCH_MIN_SCORE, + limit: DEFAULT_MAX_SEARCH_RESULTS, + params: { + hnsw_ef: 128, + exact: false, + }, + with_payload: { + include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + }, + }) + expect(callArgs9.filter).toEqual({ + must_not: [{ key: "type", match: { value: "metadata" } }], + }) + }) + + test("should not apply filter when directoryPrefix has trailing slashes", async () => { + const queryVector = [0.1, 0.2, 0.3] + const directoryPrefix = ".///" + const mockQdrantResults = { points: [] } + + mockQuery.mockResolvedValue(mockQdrantResults) + + await vectorStore.search(queryVector, directoryPrefix) + + const callArgs10 = mockQuery.mock.calls[0][1] + expect(callArgs10).toMatchObject({ + query: queryVector, + score_threshold: DEFAULT_SEARCH_MIN_SCORE, + limit: DEFAULT_MAX_SEARCH_RESULTS, + params: { + hnsw_ef: 128, + exact: false, + }, + with_payload: { + include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + }, + }) + expect(callArgs10.filter).toEqual({ + must_not: [{ key: "type", match: { value: "metadata" } }], + }) + }) + + test("should still apply filter for relative paths like './src'", async () => { + const queryVector = [0.1, 0.2, 0.3] + const directoryPrefix = "./src" + const mockQdrantResults = { points: [] } + + mockQuery.mockResolvedValue(mockQdrantResults) + + await vectorStore.search(queryVector, directoryPrefix) + + const callArgs11 = mockQuery.mock.calls[0][1] + expect(callArgs11).toMatchObject({ + query: queryVector, + score_threshold: DEFAULT_SEARCH_MIN_SCORE, + limit: DEFAULT_MAX_SEARCH_RESULTS, + params: { + hnsw_ef: 128, + exact: false, + }, + with_payload: { + include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + }, + }) + expect(callArgs11.filter).toEqual({ + must: [ + { + key: "pathSegments.0", + match: { value: "src" }, + }, + ], + must_not: [{ key: "type", match: { value: "metadata" } }], + }) + }) + + test("should still apply filter for regular directory paths", async () => { + const queryVector = [0.1, 0.2, 0.3] + const directoryPrefix = "src" + const mockQdrantResults = { points: [] } + + mockQuery.mockResolvedValue(mockQdrantResults) + + await vectorStore.search(queryVector, directoryPrefix) + + const callArgs12 = mockQuery.mock.calls[0][1] + expect(callArgs12).toMatchObject({ + query: queryVector, + score_threshold: DEFAULT_SEARCH_MIN_SCORE, + limit: DEFAULT_MAX_SEARCH_RESULTS, + params: { + hnsw_ef: 128, + exact: false, + }, + with_payload: { + include: ["filePath", "codeChunk", "startLine", "endLine", "pathSegments"], + }, + }) + expect(callArgs12.filter).toEqual({ + must: [ + { + key: "pathSegments.0", + match: { value: "src" }, + }, + ], + must_not: [{ key: "type", match: { value: "metadata" } }], + }) + }) + }) + }) +}) diff --git a/packages/kilo-indexing/test/kilocode/tree-sitter/index.test.ts b/packages/kilo-indexing/test/kilocode/tree-sitter/index.test.ts new file mode 100644 index 00000000000..6cf539c1d4d --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/tree-sitter/index.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import { mkdtemp } from "fs/promises" +import { tmpdir } from "os" +import { join } from "path" + +const mockLoadParsers = mock() + +mock.module("../../../src/tree-sitter/languageParser", () => ({ + loadRequiredLanguageParsers: mockLoadParsers, +})) + +import { parseSourceCodeDefinitionsForFile } from "../../../src/tree-sitter/index" + +describe("parseSourceCodeDefinitionsForFile", () => { + beforeEach(() => { + mockLoadParsers.mockReset() + }) + + test("returns undefined for fallback-only extensions without AST definition support", async () => { + const dir = await mkdtemp(join(tmpdir(), "tree-sitter-index-")) + const file = join(dir, "build.gradle") + await Bun.write( + file, + `plugins { id("java") } +repositories { mavenCentral() } +dependencies { testImplementation("org.junit.jupiter:junit-jupiter:5.10.2") } +`, + ) + + mockLoadParsers.mockRejectedValue(new Error("Unsupported language: gradle")) + + const result = await parseSourceCodeDefinitionsForFile(file) + expect(result).toBeUndefined() + }) +}) diff --git a/packages/kilo-indexing/tsconfig.json b/packages/kilo-indexing/tsconfig.json new file mode 100644 index 00000000000..5dc34dc5552 --- /dev/null +++ b/packages/kilo-indexing/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./src", + "skipLibCheck": true, + "noUncheckedIndexedAccess": false, + "types": ["node"], + "lib": ["ESNext", "DOM", "DOM.Iterable"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml b/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml index 09c4c15fc3b..04fd98904c2 100644 --- a/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml +++ b/packages/kilo-jetbrains/.run/Run IDE (Backend).run.xml @@ -1,10 +1,11 @@ + diff --git a/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml b/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml index 477fcb519b8..34b198f4e0a 100644 --- a/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml +++ b/packages/kilo-jetbrains/.run/Run IDE (Frontend).run.xml @@ -1,10 +1,11 @@ + diff --git a/packages/kilo-jetbrains/AGENTS.md b/packages/kilo-jetbrains/AGENTS.md index d11def623b8..a25d7add95b 100644 --- a/packages/kilo-jetbrains/AGENTS.md +++ b/packages/kilo-jetbrains/AGENTS.md @@ -3,7 +3,7 @@ ## Architecture (Split Mode) - **Split-mode plugin** with three Gradle modules: `shared/`, `frontend/`, `backend/`. The module descriptors are `kilo.jetbrains.shared.xml`, `kilo.jetbrains.frontend.xml`, `kilo.jetbrains.backend.xml` — these must stay in sync with `plugin.xml`'s `` block. -- Reference template for the split-mode structure: https://github.com/nicewith/intellij-platform-modular-plugin-template +- Reference template for the split-mode structure: https://github.com/JetBrains/intellij-platform-modular-plugin-template - Official docs: https://plugins.jetbrains.com/docs/intellij/split-mode-for-remote-development.html - The JetBrains reference template mirrors our overall structure well: root project assembles the final plugin, `shared` holds contracts, `frontend` holds UI, and `backend` holds project-local logic. Copy its split-mode wiring and RPC layout, but **do not** copy its Compose UI approach. - Kotlin source goes under `{module}/src/main/kotlin/ai/kilocode/jetbrains/`. Package name is `ai.kilocode.jetbrains` (matches `group` in root `build.gradle.kts`). @@ -122,9 +122,9 @@ wired to `FakeSessionRpcApi`, and a set of shared helpers. **Two setups:** -| Setup | When to use | -| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `val (m, events, modelEvents) = prompted()` | New-session flow — sets app/workspace to ready, creates a controller with no ID, sends an initial prompt. `model.showMessages` is `true`. Start all event-driven tests from here. | +| Setup | When to use | +|---|---| +| `val (m, events, modelEvents) = prompted()` | New-session flow — sets app/workspace to ready, creates a controller with no ID, sends an initial prompt. `model.showMessages` is `true`. Start all event-driven tests from here. | | `controller("ses_test")` + manual `appRpc`/`projectRpc` setup + `flush()` | Existing-session flow — opens a specific session, triggers history load and `recoverPending()`. `model.showMessages` is `false`. Use for recovery and history tests. Pass `show = false` to `assertSession`. | **Core assertion helpers:** @@ -163,14 +163,14 @@ flush() // settle coroutines + drain ED **`FakeSessionRpcApi` configurable state:** -| Field | Purpose | -| ------------------------------------------------------------------ | ------------------------------------------------------ | -| `rpc.events` (`MutableSharedFlow`) | Emit `ChatEventDto` events the controller will receive | -| `rpc.statuses` (`MutableStateFlow>`) | Seed the status map read during `recoverPending()` | -| `rpc.history` | Messages returned by `messages()` (history load) | -| `rpc.pendingPermissionList` | Permissions returned during recovery | -| `rpc.pendingQuestionList` | Questions returned during recovery | -| `rpc.prompts`, `rpc.permissionReplies`, etc. | Call tracking for RPC side-effects | +| Field | Purpose | +|---|---| +| `rpc.events` (`MutableSharedFlow`) | Emit `ChatEventDto` events the controller will receive | +| `rpc.statuses` (`MutableStateFlow>`) | Seed the status map read during `recoverPending()` | +| `rpc.history` | Messages returned by `messages()` (history load) | +| `rpc.pendingPermissionList` | Permissions returned during recovery | +| `rpc.pendingQuestionList` | Questions returned during recovery | +| `rpc.prompts`, `rpc.permissionReplies`, etc. | Call tracking for RPC side-effects | **String format of `model.toString()`** (used by `assertModel` / `assertSession`): @@ -221,11 +221,11 @@ Official references: ### When to Use What -| Need | API | -| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | +| Need | API | +|---|---| | Dialogs, settings pages, forms, any layout with components | **Preferred**: [Kotlin UI DSL v2](https://plugins.jetbrains.com/docs/intellij/kotlin-ui-dsl-version-2.html) (`com.intellij.ui.dsl.builder`) | -| Tool window panels, action-driven UI, custom components | Standard Swing with IntelliJ Platform component replacements (see below) | -| Menus and toolbars | [Action System](https://plugins.jetbrains.com/docs/intellij/action-system.html) | +| Tool window panels, action-driven UI, custom components | Standard Swing with IntelliJ Platform component replacements (see below) | +| Menus and toolbars | [Action System](https://plugins.jetbrains.com/docs/intellij/action-system.html) | ### Kotlin UI DSL v2 (Preferred) @@ -257,11 +257,11 @@ panel { Every row uses one of three layouts. Default is `LABEL_ALIGNED` when a label is provided for the row, `INDEPENDENT` otherwise. -| Layout | Behavior | -| --------------- | ---------------------------------------------------- | -| `LABEL_ALIGNED` | Label column + content columns, aligned across rows | -| `INDEPENDENT` | All cells are independent, no cross-row alignment | -| `PARENT_GRID` | Cells align with the parent grid columns across rows | +| Layout | Behavior | +|---|---| +| `LABEL_ALIGNED` | Label column + content columns, aligned across rows | +| `INDEPENDENT` | All cells are independent, no cross-row alignment | +| `PARENT_GRID` | Cells align with the parent grid columns across rows | ```kotlin panel { @@ -290,37 +290,37 @@ panel { All cell factory methods available inside `row { }`: -| Method | Description | -| -------------------------------------------------- | ---------------------------------------------------------- | -| `checkBox("text")` | Checkbox | -| `threeStateCheckBox("text")` | Three-state checkbox | -| `radioButton("text", value)` | Radio button (must be inside `buttonsGroup {}`) | -| `button("text") {}` | Push button | -| `actionButton(action)` | Icon button bound to an `AnAction` | -| `actionsButton(action1, action2, ...)` | Dropdown actions button | -| `segmentedButton(items) { text = it }` | Segmented control | -| `tabbedPaneHeader(items)` | Tab header strip | -| `label("text")` | Static label | -| `text("html")` | Rich text with links, icons, line-width control | -| `link("text") {}` | Focusable clickable link | -| `browserLink("text", "url")` | Opens URL in browser | -| `dropDownLink("default", listOf(...))` | Dropdown link selector | -| `icon(AllIcons.*)` | Icon display | -| `contextHelp("description", "title")` | Help icon with popup | -| `textField()` | Text input | -| `passwordField()` | Password input | -| `textFieldWithBrowseButton()` | Text field + browse dialog | -| `expandableTextField()` | Expandable multi-line text field | -| `extendableTextField()` | Text field with extension icons | -| `intTextField(range)` | Integer input with validation | -| `spinner(intRange)` / `spinner(doubleRange, step)` | Numeric spinner | -| `slider(min, max, minorTick, majorTick)` | Slider (use `.labelTable()` for tick labels) | -| `textArea()` | Multi-line text (use `.rows(n)` and `.align(AlignX.FILL)`) | -| `comboBox(items)` | Combo box / dropdown | -| `comment("text")` | Gray comment text (standalone) | -| `cell(component)` | Wrap any arbitrary Swing component | -| `scrollCell(component)` | Wrap component in a scroll pane | -| `cell()` | Empty placeholder cell for grid alignment | +| Method | Description | +|---|---| +| `checkBox("text")` | Checkbox | +| `threeStateCheckBox("text")` | Three-state checkbox | +| `radioButton("text", value)` | Radio button (must be inside `buttonsGroup {}`) | +| `button("text") {}` | Push button | +| `actionButton(action)` | Icon button bound to an `AnAction` | +| `actionsButton(action1, action2, ...)` | Dropdown actions button | +| `segmentedButton(items) { text = it }` | Segmented control | +| `tabbedPaneHeader(items)` | Tab header strip | +| `label("text")` | Static label | +| `text("html")` | Rich text with links, icons, line-width control | +| `link("text") {}` | Focusable clickable link | +| `browserLink("text", "url")` | Opens URL in browser | +| `dropDownLink("default", listOf(...))` | Dropdown link selector | +| `icon(AllIcons.*)` | Icon display | +| `contextHelp("description", "title")` | Help icon with popup | +| `textField()` | Text input | +| `passwordField()` | Password input | +| `textFieldWithBrowseButton()` | Text field + browse dialog | +| `expandableTextField()` | Expandable multi-line text field | +| `extendableTextField()` | Text field with extension icons | +| `intTextField(range)` | Integer input with validation | +| `spinner(intRange)` / `spinner(doubleRange, step)` | Numeric spinner | +| `slider(min, max, minorTick, majorTick)` | Slider (use `.labelTable()` for tick labels) | +| `textArea()` | Multi-line text (use `.rows(n)` and `.align(AlignX.FILL)`) | +| `comboBox(items)` | Combo box / dropdown | +| `comment("text")` | Gray comment text (standalone) | +| `cell(component)` | Wrap any arbitrary Swing component | +| `scrollCell(component)` | Wrap component in a scroll pane | +| `cell()` | Empty placeholder cell for grid alignment | Key component examples: @@ -384,13 +384,13 @@ Note: when a row contains a `checkBox` or `radioButton`, the DSL automatically i Three types of comments, each with different placement and semantics: -| Type | Method | Placement | -| --------------------- | ----------------------------------- | -------------------- | -| Cell comment (bottom) | `cell.comment("text")` | Below the cell | -| Cell comment (right) | `cell.commentRight("text")` | Right of the cell | -| Cell context help | `cell.contextHelp("text", "title")` | Help icon with popup | -| Row comment | `row.rowComment("text")` | Below the entire row | -| Arbitrary comment | `comment("text")` | Standalone gray text | +| Type | Method | Placement | +|---|---|---| +| Cell comment (bottom) | `cell.comment("text")` | Below the cell | +| Cell comment (right) | `cell.commentRight("text")` | Right of the cell | +| Cell context help | `cell.contextHelp("text", "title")` | Help icon with popup | +| Row comment | `row.rowComment("text")` | Below the entire row | +| Arbitrary comment | `comment("text")` | Standalone gray text | ```kotlin panel { @@ -417,16 +417,16 @@ Comments support HTML with clickable links (pass a lambda for the click handler) #### Groups and Structure -| Method | Grid | Description | -| ------------------------------ | ----------- | ---------------------------------------------------------- | -| `panel {}` | Own grid | Sub-panel occupying full width | -| `rowsRange {}` | Parent grid | Grouped rows sharing parent grid — useful with `enabledIf` | -| `group("Title") {}` | Own grid | Titled section with vertical spacing before/after | -| `groupRowsRange("Title") {}` | Parent grid | Titled section sharing parent grid alignment | -| `collapsibleGroup("Title") {}` | Own grid | Expandable section (Tab-focusable, supports mnemonics) | -| `buttonsGroup("Title") {}` | — | Groups `radioButton` or `checkBox` under a title | -| `separator()` | — | Horizontal separator line | -| Row `panel {}` | Own grid | Sub-panel inside a cell | +| Method | Grid | Description | +|---|---|---| +| `panel {}` | Own grid | Sub-panel occupying full width | +| `rowsRange {}` | Parent grid | Grouped rows sharing parent grid — useful with `enabledIf` | +| `group("Title") {}` | Own grid | Titled section with vertical spacing before/after | +| `groupRowsRange("Title") {}` | Parent grid | Titled section sharing parent grid alignment | +| `collapsibleGroup("Title") {}` | Own grid | Expandable section (Tab-focusable, supports mnemonics) | +| `buttonsGroup("Title") {}` | — | Groups `radioButton` or `checkBox` under a title | +| `separator()` | — | Horizontal separator line | +| Row `panel {}` | Own grid | Sub-panel inside a cell | ```kotlin panel { @@ -534,15 +534,15 @@ panel { Bind component values to model properties. Values are applied on `DialogPanel.apply()`, checked with `.isModified()`, and reverted with `.reset()`. -| Method | Component | -| -------------------------------------------- | ------------ | -| `bindSelected(model::prop)` | checkBox | -| `bindText(model::prop)` | textField | -| `bindIntText(model::prop)` | intTextField | -| `bindItem(model::prop.toNullableProperty())` | comboBox | -| `bindValue(model::prop)` | slider | -| `bindIntValue(model::prop)` | spinner | -| `buttonsGroup {}.bind(model::prop)` | radio group | +| Method | Component | +|---|---| +| `bindSelected(model::prop)` | checkBox | +| `bindText(model::prop)` | textField | +| `bindIntText(model::prop)` | intTextField | +| `bindItem(model::prop.toNullableProperty())` | comboBox | +| `bindValue(model::prop)` | slider | +| `bindIntValue(model::prop)` | spinner | +| `buttonsGroup {}.bind(model::prop)` | radio group | ```kotlin enum class Theme { LIGHT, DARK } @@ -609,19 +609,19 @@ Activate validators by calling `dialogPanel.registerValidators(disposable)` afte #### Tips and Common Patterns -| Pattern | Usage | -| -------------------------- | --------------------------------------------------------------- | -| `.bold()` | Bold text on any cell | -| `.columns(COLUMNS_MEDIUM)` | Set preferred width of textField / comboBox / textArea | -| `.text("initial")` | Set initial text on text components | -| `.resizableColumn()` | Column fills remaining horizontal space | -| `cell()` | Empty placeholder cell for grid alignment | -| `.widthGroup("name")` | Equalize widths across rows (cannot combine with `AlignX.FILL`) | -| `.align(AlignX.FILL)` | Stretch component to fill available width | -| `.align(AlignY.TOP)` | Top-align component in its cell | -| `.applyToComponent { }` | Direct access to the underlying Swing component | -| `.selected(true)` | Default-select a radioButton when no bound value matches | -| `.gap(RightGap.COLUMNS)` | Column-level gap for multi-column layouts | +| Pattern | Usage | +|---|---| +| `.bold()` | Bold text on any cell | +| `.columns(COLUMNS_MEDIUM)` | Set preferred width of textField / comboBox / textArea | +| `.text("initial")` | Set initial text on text components | +| `.resizableColumn()` | Column fills remaining horizontal space | +| `cell()` | Empty placeholder cell for grid alignment | +| `.widthGroup("name")` | Equalize widths across rows (cannot combine with `AlignX.FILL`) | +| `.align(AlignX.FILL)` | Stretch component to fill available width | +| `.align(AlignY.TOP)` | Top-align component in its cell | +| `.applyToComponent { }` | Direct access to the underlying Swing component | +| `.selected(true)` | Default-select a radioButton when no bound value matches | +| `.gap(RightGap.COLUMNS)` | Column-level gap for multi-column layouts | ```kotlin panel { @@ -663,33 +663,33 @@ panel { ### Platform Components — Always Use Instead of Raw Swing -| Instead of | Use | Package | -| --------------------- | ---------------------- | ------------------------------- | -| `JLabel` | `JBLabel` | `com.intellij.ui.components` | -| `JTextField` | `JBTextField` | `com.intellij.ui.components` | -| `JTextArea` | `JBTextArea` | `com.intellij.ui.components` | -| `JList` | `JBList` | `com.intellij.ui.components` | -| `JScrollPane` | `JBScrollPane` | `com.intellij.ui.components` | -| `JTable` | `JBTable` | `com.intellij.ui.table` | -| `JTree` | `Tree` | `com.intellij.ui.treeStructure` | -| `JSplitPane` | `JBSplitter` | `com.intellij.ui` | -| `JTabbedPane` | `JBTabs` | `com.intellij.ui.tabs` | -| `JCheckBox` | `JBCheckBox` | `com.intellij.ui.components` | -| `Color` | `JBColor` | `com.intellij.ui` | -| `EmptyBorder` | `JBUI.Borders.empty()` | `com.intellij.util.ui` | -| Hardcoded pixel sizes | `JBUI.scale(px)` | `com.intellij.util.ui` | +| Instead of | Use | Package | +|---|---|---| +| `JLabel` | `JBLabel` | `com.intellij.ui.components` | +| `JTextField` | `JBTextField` | `com.intellij.ui.components` | +| `JTextArea` | `JBTextArea` | `com.intellij.ui.components` | +| `JList` | `JBList` | `com.intellij.ui.components` | +| `JScrollPane` | `JBScrollPane` | `com.intellij.ui.components` | +| `JTable` | `JBTable` | `com.intellij.ui.table` | +| `JTree` | `Tree` | `com.intellij.ui.treeStructure` | +| `JSplitPane` | `JBSplitter` | `com.intellij.ui` | +| `JTabbedPane` | `JBTabs` | `com.intellij.ui.tabs` | +| `JCheckBox` | `JBCheckBox` | `com.intellij.ui.components` | +| `Color` | `JBColor` | `com.intellij.ui` | +| `EmptyBorder` | `JBUI.Borders.empty()` | `com.intellij.util.ui` | +| Hardcoded pixel sizes | `JBUI.scale(px)` | `com.intellij.util.ui` | Inspection `Plugin DevKit | Code | Undesirable class usage` highlights when you use raw Swing where a platform replacement exists. ### Multi-line and Rich Text -| Need | Component | -| ----------------------------------------------------- | ------------------------------------------------------ | -| Rich HTML with modern CSS, icons, shortcuts | `JBHtmlPane` (`com.intellij.ui.components.JBHtmlPane`) | -| Simple multi-line label with HTML | `JBLabel` + `XmlStringUtil.wrapInHtml()` | -| Scrollable / wrapping HTML panel | `SwingHelper.createHtmlViewer()` | -| High-perf colored text fragments (trees/lists/tables) | `SimpleColoredComponent` | -| Plain-text newline splitting | `MultiLineLabel` — legacy, do not use in new code | +| Need | Component | +|---|---| +| Rich HTML with modern CSS, icons, shortcuts | `JBHtmlPane` (`com.intellij.ui.components.JBHtmlPane`) | +| Simple multi-line label with HTML | `JBLabel` + `XmlStringUtil.wrapInHtml()` | +| Scrollable / wrapping HTML panel | `SwingHelper.createHtmlViewer()` | +| High-perf colored text fragments (trees/lists/tables) | `SimpleColoredComponent` | +| Plain-text newline splitting | `MultiLineLabel` — legacy, do not use in new code | - Build HTML programmatically with `HtmlChunk`/`HtmlBuilder` (`com.intellij.openapi.util.text.HtmlChunk`). Avoid raw HTML string concatenation — it risks injection and breaks localization. - For simple wrapping/escaping: `XmlStringUtil.wrapInHtml(content)`, `XmlStringUtil.wrapInHtmlLines(lines...)`, `XmlStringUtil.escapeString(text)`. diff --git a/packages/kilo-jetbrains/README.md b/packages/kilo-jetbrains/README.md index 582ccce566e..56fbf578a11 100644 --- a/packages/kilo-jetbrains/README.md +++ b/packages/kilo-jetbrains/README.md @@ -60,6 +60,51 @@ On a fresh worktree, `runIde` now checks `backend/build/generated/cli/cli/` firs That bootstrap is local-development only. Production packaging still requires running `bun run build:production` so all platform binaries are present. +### Debug logging properties + +The plugin supports a few JVM system properties for local debugging. These are most useful with `runIde` in sandbox mode because the logs are mirrored to `kilo-dev.log` files for frontend and backend. + +`kilo.dev.log.level` + +- Controls the Kilo debug file logger level. +- Supported values: `DEBUG`, `INFO`, `WARN`, `ERROR`, `OFF` +- Default: `INFO` +- Use `DEBUG` to enable detailed chat tracing and lazy `log.debug { ... }` summaries. + +`kilo.dev.log.chat.content` + +- Controls how much chat text content appears in structured chat logs. +- Supported values: + - `off`: no text previews, metadata only + - `preview`: sanitized truncated previews + - `full`: sanitized full content +- Default: `off` + +`kilo.dev.log.chat.preview.max` + +- Maximum preview size when `kilo.dev.log.chat.content=preview` +- Default: `160` + +Where to find the log files: + +- In sandbox `runIde` runs, Kilo writes separate dev log files for each side under the IDE sandbox log directory reported by `PathManager.getLogDir()`. +- Frontend log file: `/kilo-frontend/kilo-dev.log` +- Backend log file: `/kilo-backend/kilo-dev.log` +- In practice these sit under the current `log_run*` sandbox logs for the active run. +- If you are unsure of the exact sandbox root, open the IDE log directory from the running sandbox instance and then look for the `kilo-frontend/` and `kilo-backend/` subdirectories. + +Recommended combinations: + +```text +-Dkilo.dev.log.level=DEBUG -Dkilo.dev.log.chat.content=off +``` + +```text +-Dkilo.dev.log.level=DEBUG -Dkilo.dev.log.chat.content=preview -Dkilo.dev.log.chat.preview.max=120 +``` + +Use `off` first. Switch to `preview` only when you need prompt or tool payload hints to diagnose a problem. Use `full` only for short local reproductions because logs can grow quickly. + --- ## Run Gradle directly diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt index 9cab6db5e0d..9f36144d284 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendAppService.kt @@ -2,8 +2,7 @@ package ai.kilocode.backend.app import ai.kilocode.backend.cli.CliServer import ai.kilocode.backend.cli.KiloBackendCliManager -import ai.kilocode.backend.util.IntellijLog -import ai.kilocode.backend.util.KiloLog +import ai.kilocode.log.KiloLog import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager import ai.kilocode.jetbrains.api.client.DefaultApi import ai.kilocode.jetbrains.api.infrastructure.ClientError @@ -62,7 +61,7 @@ class KiloBackendAppService private constructor( constructor(cs: CoroutineScope) : this( cs, KiloBackendCliManager(), - IntellijLog(KiloBackendAppService::class.java), + KiloLog.create(KiloBackendAppService::class.java), ) companion object { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt index 497008ca7fd..51f9a1522ae 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendChatManager.kt @@ -1,7 +1,8 @@ package ai.kilocode.backend.app import ai.kilocode.backend.cli.KiloCliDataParser -import ai.kilocode.backend.util.KiloLog +import ai.kilocode.log.ChatLogSummary +import ai.kilocode.log.KiloLog import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto import ai.kilocode.rpc.dto.MessageWithPartsDto @@ -74,12 +75,12 @@ class KiloBackendChatManager( watcher = cs.launch { sse.collect { event -> if (event.type in CHAT_EVENTS) { - log.debug("SSE chat event: type=${event.type}, data=${event.data.take(2000)}") val parsed = KiloCliDataParser.parseChatEvent(event.type, event.data) if (parsed != null) { + log.debug { ChatLogSummary.event(parsed) } _events.emit(parsed) } else { - log.warn("SSE parse returned null for type=${event.type}") + log.warn("SSE parse returned null for type=${event.type} bytes=${event.data.length}") } } } @@ -98,13 +99,18 @@ class KiloBackendChatManager( // ------ prompt ------ fun prompt(id: String, dir: String, prompt: PromptDto) { - log.info("prompt: session=$id, dir=$dir, parts=${prompt.parts.size}, agent=${prompt.agent}, model=${prompt.providerID}/${prompt.modelID}") + val meta = if (log.isDebugEnabled) { + "${ChatLogSummary.dir(dir)} ${ChatLogSummary.prompt(prompt)}" + } else { + "kind=prompt parts=${prompt.parts.size}" + } + log.info("${ChatLogSummary.sid(id)} kind=prompt $meta op=prompt_async") val http = requireClient() val url = requireBase() val body = KiloCliDataParser.buildPromptJson(prompt) val target = "$url/session/$id/prompt_async?directory=${encode(dir)}" - log.debug("prompt: POST $target, body=$body") + log.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.prompt(prompt)} ${ChatLogSummary.dir(dir)} op=prompt_async send=true" } val request = Request.Builder() .url(target) .post(body.toRequestBody(JSON_TYPE)) @@ -116,14 +122,15 @@ class KiloBackendChatManager( if (!response.isSuccessful) { val raw = response.body?.string() log.warn("prompt_async failed: HTTP $code") - log.debug("prompt_async error body: $raw") + raw?.let { log.debug { "${ChatLogSummary.sid(id)} kind=prompt op=prompt_async error=${ChatLogSummary.body(it)}" } } throw RuntimeException("prompt_async failed: HTTP $code") } + log.debug { "${ChatLogSummary.sid(id)} kind=prompt op=prompt_async ok=true code=$code" } } } catch (e: RuntimeException) { throw e } catch (e: Exception) { - log.warn("prompt: HTTP call threw exception", e) + log.warn("${ChatLogSummary.sid(id)} kind=prompt op=prompt_async dir=${ChatLogSummary.dir(dir)} failed message=${e.message}", e) throw RuntimeException("prompt_async HTTP call failed: ${e.message}", e) } } @@ -131,6 +138,7 @@ class KiloBackendChatManager( // ------ abort ------ fun abort(id: String, dir: String) { + log.debug { "${ChatLogSummary.sid(id)} kind=abort ${ChatLogSummary.dir(dir)} op=abort send=true" } val http = requireClient() val url = requireBase() @@ -142,7 +150,9 @@ class KiloBackendChatManager( http.newCall(request).execute().use { response -> if (!response.isSuccessful) { log.warn("abort failed: HTTP ${response.code}") + return } + log.debug { "${ChatLogSummary.sid(id)} kind=abort op=abort ok=true code=${response.code}" } } } @@ -151,6 +161,7 @@ class KiloBackendChatManager( fun messages(id: String, dir: String): List { val http = requireClient() val url = requireBase() + log.debug { "${ChatLogSummary.sid(id)} kind=history ${ChatLogSummary.dir(dir)} op=messages send=true" } val request = Request.Builder() .url("$url/session/$id/message?directory=${encode(dir)}") @@ -163,7 +174,9 @@ class KiloBackendChatManager( return emptyList() } val raw = response.body?.string() ?: return emptyList() - KiloCliDataParser.parseMessages(raw) + val parsed = KiloCliDataParser.parseMessages(raw) + log.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(parsed)} op=messages ok=true code=${response.code}" } + parsed } } @@ -193,37 +206,45 @@ class KiloBackendChatManager( // ------ permission / question ------ fun replyPermission(requestId: String, dir: String, reply: PermissionReplyDto) { + log.debug { "kind=permission rid=$requestId ${ChatLogSummary.dir(dir)} op=replyPermission reply=${reply.reply} send=true" } val body = KiloCliDataParser.buildPermissionReplyJson(reply) - post("/permission/$requestId/reply?directory=${encode(dir)}", body, "replyPermission") + post("/permission/$requestId/reply?directory=${encode(dir)}", body, "replyPermission", "kind=permission rid=$requestId") } fun savePermissionRules(requestId: String, dir: String, rules: PermissionAlwaysRulesDto) { + log.debug { "kind=permission rid=$requestId ${ChatLogSummary.dir(dir)} op=savePermissionRules approved=${rules.approvedAlways.size} denied=${rules.deniedAlways.size} send=true" } val body = KiloCliDataParser.buildPermissionAlwaysRulesJson(rules) - post("/permission/$requestId/always-rules?directory=${encode(dir)}", body, "savePermissionRules") + post("/permission/$requestId/always-rules?directory=${encode(dir)}", body, "savePermissionRules", "kind=permission rid=$requestId") } fun replyQuestion(requestId: String, dir: String, answers: QuestionReplyDto) { + log.debug { "kind=question rid=$requestId ${ChatLogSummary.dir(dir)} op=replyQuestion answers=${answers.answers.size} send=true" } val body = KiloCliDataParser.buildQuestionReplyJson(answers) - post("/question/$requestId/reply?directory=${encode(dir)}", body, "replyQuestion") + post("/question/$requestId/reply?directory=${encode(dir)}", body, "replyQuestion", "kind=question rid=$requestId") } fun rejectQuestion(requestId: String, dir: String) { - post("/question/$requestId/reject?directory=${encode(dir)}", "{}", "rejectQuestion") + log.debug { "kind=question rid=$requestId ${ChatLogSummary.dir(dir)} op=rejectQuestion send=true" } + post("/question/$requestId/reject?directory=${encode(dir)}", "{}", "rejectQuestion", "kind=question rid=$requestId") } fun pendingPermissions(dir: String): List { val raw = get("/permission?directory=${encode(dir)}", "pendingPermissions") ?: return emptyList() - return KiloCliDataParser.parsePermissionRequests(raw) + val parsed = KiloCliDataParser.parsePermissionRequests(raw) + log.debug { "kind=permission ${ChatLogSummary.dir(dir)} op=pendingPermissions ok=true count=${parsed.size}" } + return parsed } fun pendingQuestions(dir: String): List { val raw = get("/question?directory=${encode(dir)}", "pendingQuestions") ?: return emptyList() - return KiloCliDataParser.parseQuestionRequests(raw) + val parsed = KiloCliDataParser.parseQuestionRequests(raw) + log.debug { "kind=question ${ChatLogSummary.dir(dir)} op=pendingQuestions ok=true count=${parsed.size}" } + return parsed } // ------ utilities ------ - private fun post(path: String, body: String, op: String) { + private fun post(path: String, body: String, op: String, meta: String) { val http = requireClient() val url = requireBase() val request = Request.Builder() @@ -233,7 +254,9 @@ class KiloBackendChatManager( http.newCall(request).execute().use { response -> if (!response.isSuccessful) { log.warn("$op failed: HTTP ${response.code}") + return } + log.debug { "$meta op=$op ok=true code=${response.code}" } } } @@ -249,6 +272,7 @@ class KiloBackendChatManager( log.warn("$op failed: HTTP ${response.code}") null } else { + log.debug { "op=$op ok=true code=${response.code}" } response.body?.string() } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt index aec18db4144..e64249c3db1 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendConnectionService.kt @@ -3,8 +3,8 @@ package ai.kilocode.backend.app import ai.kilocode.backend.cli.KiloBackendHttpClients import ai.kilocode.backend.cli.KiloCliDataParser import ai.kilocode.backend.cli.CliServer -import ai.kilocode.backend.util.IntellijLog -import ai.kilocode.backend.util.KiloLog +import ai.kilocode.log.ChatLogSummary +import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -59,7 +59,7 @@ class KiloConnectionService( private val cs: CoroutineScope, private val server: CliServer, private val onReconnect: () -> Unit, - private val log: KiloLog = IntellijLog(KiloConnectionService::class.java), + private val log: KiloLog = KiloLog.create(KiloConnectionService::class.java), ) { companion object { @@ -220,6 +220,7 @@ class KiloConnectionService( override fun onEvent(src: EventSource, id: String?, type: String?, data: String) { lastEvent.set(System.currentTimeMillis()) val kind = type ?: KiloCliDataParser.extractEventType(data) + log.debug { "evt=$kind bytes=${data.length} hasId=${id != null} ${ChatLogSummary.body(data)}" } cs.launch { _events.emit(SseEvent(type = kind, data = data)) } } @@ -303,7 +304,7 @@ class KiloConnectionService( .build() http.newCall(req).execute().use { it.isSuccessful } } catch (e: Exception) { - log.info("Health check exception: ${e.message}") + log.warn("kind=health-check port=$port failed message=${e.message}", e) false } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt index 95d61bd2118..ebe10cbf3bc 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendSessionManager.kt @@ -1,7 +1,8 @@ package ai.kilocode.backend.app import ai.kilocode.backend.cli.KiloCliDataParser -import ai.kilocode.backend.util.KiloLog +import ai.kilocode.log.ChatLogSummary +import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import ai.kilocode.jetbrains.api.model.SessionStatus import ai.kilocode.rpc.dto.SessionDto @@ -63,6 +64,7 @@ class KiloBackendSessionManager( val pair = KiloCliDataParser.parseSessionStatus(event.data) if (pair != null) { _statuses.update { it + pair } + log.debug { "${ChatLogSummary.sid(pair.first)} evt=session.status ${ChatLogSummary.status(pair.second)}" } } } } @@ -117,8 +119,10 @@ class KiloBackendSessionManager( log.warn("Session create failed: HTTP ${response.code}, body=$raw") throw RuntimeException("Session create failed: HTTP ${response.code} — $raw") } - log.info("Session created: HTTP ${response.code}") - return KiloCliDataParser.parseSession(raw!!) + val dto = KiloCliDataParser.parseSession(raw!!) + val meta = if (log.isDebugEnabled) ChatLogSummary.dir(dir) else "kind=session" + log.info("${ChatLogSummary.sid(dto.id)} kind=session $meta created=true code=${response.code}") + return dto } } @@ -139,9 +143,10 @@ class KiloBackendSessionManager( val raw = requireClient().sessionStatus(directory = dir) val mapped = raw.mapValues { (_, v) -> statusDto(v) } _statuses.update { it + mapped } - log.info("Seeded ${mapped.size} session statuses for $dir") + val meta = if (log.isDebugEnabled) ChatLogSummary.dir(dir) else "kind=status" + log.info("kind=status $meta seeded=${mapped.size}") } catch (e: Exception) { - log.warn("Session status seed failed: ${e.message}", e) + log.warn("kind=status dir=${ChatLogSummary.dir(dir)} seed=true failed message=${e.message}", e) } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt index b3e9d844129..0c8851ae50a 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/cli/KiloBackendCliManager.kt @@ -1,7 +1,6 @@ package ai.kilocode.backend.cli -import ai.kilocode.backend.util.IntellijLog -import ai.kilocode.backend.util.KiloLog +import ai.kilocode.log.KiloLog import com.intellij.ide.plugins.PluginManagerCore import com.intellij.openapi.application.ApplicationInfo import com.intellij.openapi.application.PathManager @@ -30,7 +29,7 @@ import java.util.concurrent.TimeUnit * guard and volatile [process] field. */ class KiloBackendCliManager( - private val log: KiloLog = IntellijLog(KiloBackendCliManager::class.java), + private val log: KiloLog = KiloLog.create(KiloBackendCliManager::class.java), ) : CliServer { companion object { diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt index a32477c53c2..89d8935c2b8 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloSessionRpcApiImpl.kt @@ -6,6 +6,7 @@ import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.backend.app.KiloBackendChatManager import ai.kilocode.backend.app.KiloBackendSessionManager import ai.kilocode.backend.workspace.KiloBackendWorkspaceManager +import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto @@ -20,7 +21,7 @@ import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionListDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.components.service -import com.intellij.openapi.diagnostic.Logger +import ai.kilocode.log.KiloLog import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter @@ -35,7 +36,7 @@ import kotlinx.coroutines.flow.filter */ class KiloSessionRpcApiImpl : KiloSessionRpcApi { companion object { - private val LOG = Logger.getInstance(KiloSessionRpcApiImpl::class.java) + private val LOG = KiloLog.create(KiloSessionRpcApiImpl::class.java) } private val workspaces: KiloBackendWorkspaceManager @@ -109,7 +110,10 @@ class KiloSessionRpcApiImpl : KiloSessionRpcApi { is ChatEventDto.SessionDiffChanged -> event.sessionID is ChatEventDto.TodoUpdated -> event.sessionID } - sid == null || sid == id + val passes = sid == null || sid == id + if (passes) LOG.debug { "${ChatLogSummary.sid(id)} pass=true ${ChatLogSummary.eventBody(event)}" } + else LOG.debug { "${ChatLogSummary.sid(id)} pass=false srcSid=$sid ${ChatLogSummary.eventBody(event)}" } + passes } override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) = diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloLog.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloLog.kt deleted file mode 100644 index ca870fdbfd1..00000000000 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/util/KiloLog.kt +++ /dev/null @@ -1,22 +0,0 @@ -package ai.kilocode.backend.util - -import com.intellij.openapi.diagnostic.Logger - -interface KiloLog { - fun debug(msg: String) - fun info(msg: String) - fun warn(msg: String, t: Throwable? = null) - fun error(msg: String, t: Throwable? = null) -} - -internal class IntellijLog(cls: Class<*>) : KiloLog { - private val delegate = Logger.getInstance(cls) - override fun debug(msg: String) = delegate.debug(msg) - override fun info(msg: String) = delegate.info(msg) - override fun warn(msg: String, t: Throwable?) { - if (t != null) delegate.warn(msg, t) else delegate.warn(msg) - } - override fun error(msg: String, t: Throwable?) { - if (t != null) delegate.error(msg, t) else delegate.error(msg) - } -} diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index b05e5140238..f46f6715c9e 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -2,7 +2,7 @@ package ai.kilocode.backend.workspace import ai.kilocode.backend.app.KiloBackendSessionManager import ai.kilocode.backend.app.SseEvent -import ai.kilocode.backend.util.KiloLog +import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import ai.kilocode.jetbrains.api.model.Agent import ai.kilocode.rpc.dto.SessionDto diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt index c69dd734539..2af74bd151e 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt @@ -3,7 +3,7 @@ package ai.kilocode.backend.workspace import ai.kilocode.backend.app.KiloAppState import ai.kilocode.backend.app.KiloBackendSessionManager import ai.kilocode.backend.app.SseEvent -import ai.kilocode.backend.util.KiloLog +import ai.kilocode.log.KiloLog import ai.kilocode.jetbrains.api.client.DefaultApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt new file mode 100644 index 00000000000..88abb5a339a --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/cli/ChatLogSummaryTest.kt @@ -0,0 +1,177 @@ +package ai.kilocode.backend.cli + +import ai.kilocode.log.ChatLogSummary +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.PromptPartDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionOptionDto +import ai.kilocode.rpc.dto.QuestionRequestDto +import ai.kilocode.rpc.dto.SessionStatusDto +import ai.kilocode.rpc.dto.ToolRefDto +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ChatLogSummaryTest { + + @AfterTest + fun tearDown() { + System.clearProperty("kilo.dev.log.chat.content") + System.clearProperty("kilo.dev.log.chat.preview.max") + } + + @Test + fun `prompt summary hides content by default`() { + val text = "secret prompt body" + + val out = ChatLogSummary.prompt(text) + + assertTrue(out.contains("kind=prompt")) + assertTrue(out.contains("chars=18")) + assertFalse(out.contains("preview=")) + assertFalse(out.contains("secret")) + } + + @Test + fun `prompt summary includes truncated preview in preview mode`() { + System.setProperty("kilo.dev.log.chat.content", "preview") + System.setProperty("kilo.dev.log.chat.preview.max", "10") + + val out = ChatLogSummary.prompt("line one\nline two") + + assertTrue(out.contains("preview=\"line one l...\""), out) + } + + @Test + fun `event summary includes tool and ids`() { + val event = ChatEventDto.PartUpdated( + sessionID = "ses_1", + part = PartDto( + id = "prt_1", + sessionID = "ses_1", + messageID = "msg_1", + type = "tool", + tool = "grep", + callID = "call_1", + state = "running", + title = "Search project", + ), + ) + + val out = ChatLogSummary.event(event) + + assertTrue(out.contains("evt=message.part.updated"), out) + assertTrue(out.contains("sid=ses_1"), out) + assertTrue(out.contains("mid=msg_1"), out) + assertTrue(out.contains("pid=prt_1"), out) + assertTrue(out.contains("tool=grep"), out) + assertTrue(out.contains("call=call_1"), out) + assertTrue(out.contains("state=running"), out) + } + + @Test + fun `permission and question summaries include request correlation`() { + System.setProperty("kilo.dev.log.chat.content", "preview") + + val permission = ChatLogSummary.permission( + PermissionRequestDto( + id = "req_perm", + sessionID = "ses_1", + permission = "edit", + patterns = listOf("src/**/*.kt"), + tool = ToolRefDto("msg_1", "call_1"), + ) + ) + val question = ChatLogSummary.question( + QuestionRequestDto( + id = "req_q", + sessionID = "ses_1", + questions = listOf( + QuestionInfoDto( + question = "Pick a file", + header = "Files", + options = listOf(QuestionOptionDto("A", "desc")), + ) + ), + tool = ToolRefDto("msg_2", "call_2"), + ) + ) + + assertTrue(permission.contains("rid=req_perm"), permission) + assertTrue(permission.contains("call=call_1"), permission) + assertTrue(question.contains("rid=req_q"), question) + assertTrue(question.contains("options=1"), question) + assertTrue(question.contains("call=call_2"), question) + } + + @Test + fun `status summary includes retry and request ids`() { + val out = ChatLogSummary.status( + SessionStatusDto( + type = "offline", + message = "No connection", + attempt = 2, + next = 5000L, + requestID = "req_1", + ) + ) + + assertEquals( + "type=offline attempt=2 next=5000 rid=req_1", + out.substringBefore(" message="), + ) + assertTrue(out.contains("message=\"No connection\""), out) + } + + @Test + fun `prompt dto summary includes types and model`() { + System.setProperty("kilo.dev.log.chat.content", "preview") + + val out = ChatLogSummary.prompt( + PromptDto( + parts = listOf( + PromptPartDto(type = "text", text = "hello"), + PromptPartDto(type = "text", text = "world"), + ), + providerID = "kilo", + modelID = "gpt-5", + agent = "code", + ) + ) + + assertTrue(out.contains("parts=2"), out) + assertTrue(out.contains("types=text"), out) + assertTrue(out.contains("agent=code"), out) + assertTrue(out.contains("model=kilo/gpt-5"), out) + } + + @Test + fun `message updated summary includes role and model`() { + val out = ChatLogSummary.event( + ChatEventDto.MessageUpdated( + sessionID = "ses_1", + info = MessageDto( + id = "msg_1", + sessionID = "ses_1", + role = "assistant", + agent = "code", + providerID = "kilo", + modelID = "gpt-5", + time = MessageTimeDto(created = 0.0), + ), + ) + ) + + assertTrue(out.contains("mid=msg_1"), out) + assertTrue(out.contains("role=assistant"), out) + assertTrue(out.contains("agent=code"), out) + assertTrue(out.contains("model=kilo/gpt-5"), out) + } +} diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt index ec56afc29f4..a62ec89564b 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/testing/TestLog.kt @@ -1,14 +1,17 @@ package ai.kilocode.backend.testing -import ai.kilocode.backend.util.KiloLog +import ai.kilocode.log.KiloLog /** * Test logger that captures messages for assertions and prints to stdout. */ class TestLog : KiloLog { val messages = mutableListOf() + override var isDebugEnabled: Boolean = true - override fun debug(msg: String) { + override fun debug(block: () -> String) { + if (!isDebugEnabled) return + val msg = block() synchronized(messages) { messages.add("DEBUG: $msg") } println("[test] DEBUG: $msg") } diff --git a/packages/kilo-jetbrains/build.gradle.kts b/packages/kilo-jetbrains/build.gradle.kts index 4b3e02952db..3ecc8be04b6 100644 --- a/packages/kilo-jetbrains/build.gradle.kts +++ b/packages/kilo-jetbrains/build.gradle.kts @@ -1,5 +1,6 @@ import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType import org.jetbrains.intellij.platform.gradle.TestFrameworkType +import org.jetbrains.intellij.platform.gradle.tasks.RunIdeTask import org.jetbrains.intellij.platform.gradle.tasks.aware.SplitModeAware.SplitModeTarget group = "ai.kilocode.jetbrains" @@ -72,7 +73,15 @@ tasks.named("runIde") { } } +tasks.withType { + val level = providers.gradleProperty("kilo.dev.log.level").orNull ?: "DEBUG" + val content = providers.gradleProperty("kilo.dev.log.chat.content").orNull ?: "off" + val preview = providers.gradleProperty("kilo.dev.log.chat.preview.max").orNull ?: "160" + systemProperty("kilo.dev.log.level", level) + systemProperty("kilo.dev.log.chat.content", content) + systemProperty("kilo.dev.log.chat.preview.max", preview) +} + tasks.named("clean") { delete(layout.buildDirectory) } - diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt index 060588265c8..e44b34367dc 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/KiloToolWindowFactory.kt @@ -7,7 +7,7 @@ import ai.kilocode.client.app.KiloWorkspaceService import ai.kilocode.client.app.Workspace import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.components.service -import com.intellij.openapi.diagnostic.Logger +import ai.kilocode.log.KiloLog import com.intellij.openapi.project.DumbAware import com.intellij.openapi.project.Project import com.intellij.openapi.wm.ToolWindow @@ -30,7 +30,7 @@ import kotlinx.coroutines.withContext class KiloToolWindowFactory : ToolWindowFactory, DumbAware { companion object { - private val LOG = Logger.getInstance(KiloToolWindowFactory::class.java) + private val LOG = KiloLog.create(KiloToolWindowFactory::class.java) } override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt index da592dd32a5..01b9e201e3e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloAppService.kt @@ -7,7 +7,7 @@ import ai.kilocode.rpc.dto.HealthDto import ai.kilocode.rpc.dto.KiloAppStateDto import ai.kilocode.rpc.dto.KiloAppStatusDto import com.intellij.openapi.components.Service -import com.intellij.openapi.diagnostic.Logger +import ai.kilocode.log.KiloLog import fleet.rpc.client.durable import java.util.concurrent.atomic.AtomicBoolean import kotlinx.coroutines.CoroutineScope @@ -32,7 +32,7 @@ class KiloAppService internal constructor( constructor(cs: CoroutineScope) : this(cs, null) companion object { - private val LOG = Logger.getInstance(KiloAppService::class.java) + private val LOG = KiloLog.create(KiloAppService::class.java) private val init = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt index 50e80b05afe..063922fcdb4 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloSessionService.kt @@ -2,6 +2,7 @@ package ai.kilocode.client.app +import ai.kilocode.log.ChatLogSummary import ai.kilocode.rpc.KiloSessionRpcApi import ai.kilocode.rpc.dto.ChatEventDto import ai.kilocode.rpc.dto.ConfigUpdateDto @@ -16,7 +17,7 @@ import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.components.Service -import com.intellij.openapi.diagnostic.Logger +import ai.kilocode.log.KiloLog import com.intellij.openapi.project.Project import fleet.rpc.client.durable import kotlinx.coroutines.CoroutineScope @@ -46,7 +47,7 @@ class KiloSessionService internal constructor( constructor(project: Project, cs: CoroutineScope) : this(project, cs, null) companion object { - private val LOG = Logger.getInstance(KiloSessionService::class.java) + private val LOG = KiloLog.create(KiloSessionService::class.java) } private val _sessions = MutableStateFlow>(emptyList()) @@ -78,7 +79,7 @@ class KiloSessionService internal constructor( val result = call { list(dir) } _sessions.value = result.sessions } catch (e: Exception) { - LOG.warn("session list failed", e) + LOG.warn("kind=session-list dir=${ChatLogSummary.dir(dir)} failed message=${e.message}", e) } } } @@ -99,7 +100,7 @@ class KiloSessionService internal constructor( call { delete(id, dir) } refresh(dir) } catch (e: Exception) { - LOG.warn("session delete failed", e) + LOG.warn("${ChatLogSummary.sid(id)} kind=session delete=true dir=${ChatLogSummary.dir(dir)} failed message=${e.message}", e) } } } @@ -110,7 +111,7 @@ class KiloSessionService internal constructor( try { call { setDirectory(id, dir) } } catch (e: Exception) { - LOG.warn("setDirectory failed", e) + LOG.warn("${ChatLogSummary.sid(id)} kind=session setDirectory=true dir=${ChatLogSummary.dir(dir)} failed message=${e.message}", e) } } } @@ -119,10 +120,15 @@ class KiloSessionService internal constructor( /** Send a text prompt to a session. */ suspend fun prompt(id: String, dir: String, text: String) { - LOG.info("prompt: session=$id, dir=$dir, text=${text.take(80)}") + val meta = if (LOG.isDebugEnabled) { + "${ChatLogSummary.dir(dir)} ${ChatLogSummary.prompt(text)}" + } else { + "kind=prompt chars=${text.length}" + } + LOG.info("${ChatLogSummary.sid(id)} $meta") val dto = PromptDto(parts = listOf(PromptPartDto(type = "text", text = text))) call { prompt(id, dir, dto) } - LOG.info("prompt: RPC returned successfully") + LOG.info("${ChatLogSummary.sid(id)} kind=prompt ok=true") } /** Abort ongoing processing for a session. */ @@ -133,14 +139,23 @@ class KiloSessionService internal constructor( /** Load message history for a session. */ suspend fun messages(id: String, dir: String): List = call { messages(id, dir) } + .also { LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(it)} ${ChatLogSummary.dir(dir)}" } } /** Subscribe to streaming chat events for a session. */ fun events(id: String, dir: String): Flow { val api = rpc return if (api != null) flow { - api.events(id, dir).collect { emit(it) } + api.events(id, dir).collect { + LOG.debug { ChatLogSummary.event(it) } + emit(it) + } } else flow { - durable { KiloSessionRpcApi.getInstance().events(id, dir).collect { emit(it) } } + durable { + KiloSessionRpcApi.getInstance().events(id, dir).collect { + LOG.debug { ChatLogSummary.event(it) } + emit(it) + } + } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt index b0c065d2941..0909d2a4d74 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/app/KiloWorkspaceService.kt @@ -6,7 +6,7 @@ import ai.kilocode.rpc.KiloWorkspaceRpcApi import ai.kilocode.rpc.dto.KiloWorkspaceStateDto import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto import com.intellij.openapi.components.Service -import com.intellij.openapi.diagnostic.Logger +import ai.kilocode.log.KiloLog import fleet.rpc.client.durable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -33,7 +33,7 @@ class KiloWorkspaceService internal constructor( constructor(cs: CoroutineScope) : this(cs, null) companion object { - private val LOG = Logger.getInstance(KiloWorkspaceService::class.java) + private val LOG = KiloLog.create(KiloWorkspaceService::class.java) private val INIT = KiloWorkspaceStateDto(KiloWorkspaceStatusDto.PENDING) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt index e1aa9cd827d..bdc6303385f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionController.kt @@ -28,7 +28,8 @@ import ai.kilocode.rpc.dto.QuestionRequestDto import ai.kilocode.rpc.dto.SessionStatusDto import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.diagnostic.Logger +import ai.kilocode.log.ChatLogSummary +import ai.kilocode.log.KiloLog import com.intellij.openapi.util.Disposer import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job @@ -54,10 +55,13 @@ class SessionController( private val workspace: Workspace, private val app: KiloAppService, private val cs: CoroutineScope, + comp: java.awt.Component? = null, + private val flushMs: Long = EVENT_FLUSH_MS, + private val condense: Boolean = true, ) : Disposable { companion object { - private val LOG = Logger.getInstance(SessionController::class.java) + private val LOG = KiloLog.create(SessionController::class.java) } init { @@ -69,6 +73,7 @@ class SessionController( private val listeners = mutableListOf() private var sessionId: String? = id private val directory: String get() = workspace.directory + private val updates = SessionUpdateQueue(parent, comp, flushMs, ::handle, condense, id != null) { sessionId ?: "pending" } private var partType: String? = null private var tool: String? = null @@ -81,19 +86,26 @@ class SessionController( Disposer.register(parent) { listeners.remove(listener) } } + internal fun flushEvents() = updates.requestFlush(true) + fun prompt(text: String) { + val sid = sessionId ?: "pending" + LOG.debug { "${ChatLogSummary.sid(sid)} ${ChatLogSummary.prompt(text)} ${ChatLogSummary.dir(directory)}" } showMessages() cs.launch { try { val id = sessionId ?: run { val session = sessions.create(directory) sessionId = session.id + val meta = if (LOG.isDebugEnabled) ChatLogSummary.dir(directory) else "kind=session" + LOG.info("${ChatLogSummary.sid(session.id)} kind=session $meta created=true") subscribeEvents() session.id } sessions.prompt(id, directory, text) + LOG.debug { "${ChatLogSummary.sid(id)} kind=prompt dispatched=true" } } catch (e: Exception) { - LOG.warn("prompt failed", e) + LOG.warn("${ChatLogSummary.sid(sessionId ?: sid)} kind=prompt dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) edt { val msg = e.message ?: KiloBundle.message("session.error.prompt") model.setState(SessionState.Error(msg)) @@ -103,35 +115,39 @@ class SessionController( } fun abort() { + LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=abort" } val id = sessionId ?: return cs.launch { try { sessions.abort(id, directory) + LOG.debug { "${ChatLogSummary.sid(id)} kind=abort ok=true" } } catch (e: Exception) { - LOG.warn("abort failed", e) + LOG.warn("${ChatLogSummary.sid(id)} kind=abort dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } } fun selectAgent(name: String) { + LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=config agent=$name" } model.agent = name cs.launch { try { sessions.updateConfig(directory, ConfigUpdateDto(agent = name)) } catch (e: Exception) { - LOG.warn("selectAgent failed", e) + LOG.warn("${ChatLogSummary.sid(sessionId ?: "pending")} kind=config agent=$name dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } fire(SessionControllerEvent.WorkspaceReady) } fun selectModel(provider: String, id: String) { + LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=config model=$provider/$id" } model.model = "$provider/$id" cs.launch { try { sessions.updateConfig(directory, ConfigUpdateDto(model = "$provider/$id")) } catch (e: Exception) { - LOG.warn("selectModel failed", e) + LOG.warn("${ChatLogSummary.sid(sessionId ?: "pending")} kind=config model=$provider/$id dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } fire(SessionControllerEvent.WorkspaceReady) @@ -140,32 +156,38 @@ class SessionController( // ------ permission / question resolution ------ fun replyPermission(requestId: String, reply: PermissionReplyDto, rules: PermissionAlwaysRulesDto? = null) { + LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=permission rid=$requestId reply=${reply.reply}" } cs.launch { try { if (rules != null) sessions.savePermissionRules(requestId, directory, rules) sessions.replyPermission(requestId, directory, reply) + LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=permission rid=$requestId ok=true" } } catch (e: Exception) { - LOG.warn("replyPermission failed", e) + LOG.warn("${ChatLogSummary.sid(sessionId ?: "pending")} kind=permission rid=$requestId reply=${reply.reply} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } } fun replyQuestion(requestId: String, answers: QuestionReplyDto) { + LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size}" } cs.launch { try { sessions.replyQuestion(requestId, directory, answers) + LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId ok=true" } } catch (e: Exception) { - LOG.warn("replyQuestion failed", e) + LOG.warn("${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId answers=${answers.answers.size} dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } } fun rejectQuestion(requestId: String) { + LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId rejected=true" } cs.launch { try { sessions.rejectQuestion(requestId, directory) + LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId ok=true" } } catch (e: Exception) { - LOG.warn("rejectQuestion failed", e) + LOG.warn("${ChatLogSummary.sid(sessionId ?: "pending")} kind=question rid=$requestId rejected=true dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } } @@ -176,6 +198,10 @@ class SessionController( subscribeEvents() } + model.addListener(this) { event -> + LOG.debug { "session=$sessionId model: $event" } + } + app.connect() cs.launch { app.state.collect { state -> @@ -226,25 +252,37 @@ class SessionController( cs.launch { try { val history = sessions.messages(id, directory) - edt { + LOG.debug { "${ChatLogSummary.sid(id)} ${ChatLogSummary.history(history)}" } + runEdt { this@SessionController.model.loadHistory(history) if (!model.isEmpty()) showMessages() } recoverPending(id) } catch (e: Exception) { - LOG.warn("loadHistory failed", e) + LOG.warn("${ChatLogSummary.sid(id)} kind=history dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + } finally { + updates.holdFlush(false) + updates.requestFlush(true) } } } private fun subscribeEvents() { val id = sessionId ?: return + LOG.debug { "${ChatLogSummary.sid(id)} kind=subscription subscribe=true" } eventJob?.cancel() eventJob = cs.launch { - sessions.events(id, directory).collect { event -> - // Defensive session ID guard: skip events for other sessions - if (!matchesSession(event, id)) return@collect - edt { handle(event) } + try { + sessions.events(id, directory).collect { event -> + if (!matchesSession(event, id)) { + LOG.debug { "${ChatLogSummary.sid(id)} pass=false ${ChatLogSummary.eventBody(event)}" } + return@collect + } + LOG.debug { "${ChatLogSummary.sid(id)} pass=true ${ChatLogSummary.eventBody(event)}" } + updates.enqueue(event) + } + } finally { + LOG.debug { "${ChatLogSummary.sid(id)} kind=subscription subscribe=false" } } } } @@ -255,7 +293,16 @@ class SessionController( val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == id } val questions = sessions.pendingQuestions(directory).filter { it.sessionID == id } val status = sessions.statuses.value[id] - edt { + val branch = when { + permissions.isNotEmpty() -> "permission" + questions.isNotEmpty() -> "question" + status != null -> "status" + else -> "idle" + } + LOG.debug { + "${ChatLogSummary.sid(id)} kind=recovery permissions=${permissions.size} questions=${questions.size} status=${status?.type ?: "none"} branch=$branch" + } + runEdt { if (permissions.isNotEmpty()) { model.setState(SessionState.AwaitingPermission(toPermission(permissions.last()))) } else if (questions.isNotEmpty()) { @@ -265,7 +312,7 @@ class SessionController( } } } catch (e: Exception) { - LOG.warn("recoverPending failed", e) + LOG.warn("${ChatLogSummary.sid(id)} kind=recovery dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) } } @@ -276,6 +323,7 @@ class SessionController( * for "busy" because no more-specific state has arrived yet. */ private fun seedStatus(dto: SessionStatusDto) { + LOG.debug { "${ChatLogSummary.sid(sessionId ?: "pending")} evt=session.status ${ChatLogSummary.status(dto)}" } val state = when (dto.type) { "busy" -> SessionState.Busy(KiloBundle.message("session.status.considering")) "retry" -> SessionState.Retry( @@ -293,6 +341,7 @@ class SessionController( } private fun handle(event: ChatEventDto) { + LOG.debug { ChatLogSummary.event(event) } when (event) { is ChatEventDto.MessageUpdated -> { val added = model.upsertMessage(event.info) @@ -419,6 +468,10 @@ class SessionController( } } + private fun handle(events: List) { + for (event in events) handle(event) + } + private fun showMessages() { if (!model.showMessages) { model.showMessages = true @@ -443,6 +496,7 @@ class SessionController( } private fun fire(event: SessionControllerEvent, before: (() -> Unit)? = null) { + LOG.debug { "session=$sessionId controller: $event" } val application = ApplicationManager.getApplication() if (application.isDispatchThread) { before?.invoke() @@ -459,6 +513,15 @@ class SessionController( ApplicationManager.getApplication().invokeLater(block) } + private fun runEdt(block: () -> Unit) { + val application = ApplicationManager.getApplication() + if (application.isDispatchThread) { + block() + return + } + application.invokeAndWait(block) + } + override fun dispose() { eventJob?.cancel() cs.cancel() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt new file mode 100644 index 00000000000..cb6b85d7667 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionQueueCondenser.kt @@ -0,0 +1,135 @@ +package ai.kilocode.client.session + +import ai.kilocode.rpc.dto.ChatEventDto + +/** + * Reduces a batch of queued [ChatEventDto] events before they are flushed to + * the model, by merging consecutive same-key snapshot and text-delta events. + * + * ## Algorithm + * + * Events are scanned in arrival order. Three ordered accumulators hold + * mergeable events keyed by their identity. Any non-mergeable event acts as a + * **barrier** — all accumulated events are flushed into the output before the + * barrier event is appended. This preserves the original ordering while + * collapsing N updates into one per key per batch. + * + * ## What is merged + * + * - `ChatEventDto.PartDelta` where `field == "text"` and same + * `(sessionId, messageId, partId, field)` key. Text is concatenated. + * - `ChatEventDto.PartUpdated` for the same `(sessionId, messageId, partId)`. + * Latest snapshot wins. + * - `ChatEventDto.MessageUpdated` for the same `messageId`. + * Latest snapshot wins. + * - `ChatEventDto.SessionStatusChanged` for the same `sessionId`. + * Latest snapshot wins. + * - `ChatEventDto.SessionDiffChanged` for the same `sessionId`. + * Latest snapshot wins. + * + * ## What is not merged + * + * - `PartDelta` for non-text fields + * - `PartDelta` and `PartUpdated` do not merge across each other + * - No event merges across a barrier (TurnOpen, TurnClose, Error, etc.) + * + * ## Drain order + * + * When a barrier is encountered or the batch ends, accumulators drain in this + * order: state events first, then part updates, then text deltas. This ensures + * a message is always flushed before the part updates that depend on it. + */ +internal class SessionQueueCondenser { + + fun condense(events: List): List { + if (events.size < 2) return events + val out = mutableListOf() + val deltas = LinkedHashMap() + val parts = LinkedHashMap() + val states = LinkedHashMap() + + fun drainDeltas() { + if (deltas.isEmpty()) return + out.addAll(deltas.values) + deltas.clear() + } + + fun drainParts() { + if (parts.isEmpty()) return + out.addAll(parts.values) + parts.clear() + } + + fun drainStates() { + if (states.isEmpty()) return + out.addAll(states.values) + states.clear() + } + + fun drain() { + drainStates() + drainParts() + drainDeltas() + } + + for (event in events) { + when (event) { + is ChatEventDto.PartDelta -> { + val key = event.key() + if (key == null) { + drain() + out.add(event) + continue + } + drainParts() + drainStates() + val prev = deltas[key] + deltas[key] = if (prev != null) prev.merge(event) else event + } + + is ChatEventDto.PartUpdated -> { + drainDeltas() + drainStates() + parts[event.key()] = event + } + + is ChatEventDto.MessageUpdated -> { + drainDeltas() + drainParts() + states["MU:${event.info.id}"] = event + } + + is ChatEventDto.SessionStatusChanged -> { + drainDeltas() + drainParts() + states["SC:${event.sessionID}"] = event + } + + is ChatEventDto.SessionDiffChanged -> { + drainDeltas() + drainParts() + states["SDC:${event.sessionID}"] = event + } + + else -> { + drain() + out.add(event) + } + } + } + + drain() + return out + } + + private fun ChatEventDto.PartDelta.key(): String? { + if (field != "text") return null + return "$sessionID:$messageID:$partID:$field" + } + + private fun ChatEventDto.PartUpdated.key(): String = + "$sessionID:${part.messageID}:${part.id}" + + private fun ChatEventDto.PartDelta.merge(next: ChatEventDto.PartDelta): ChatEventDto.PartDelta = + ChatEventDto.PartDelta(next.sessionID, next.messageID, next.partID, next.field, delta + next.delta) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 9393b5fb2af..b9b4856c4d2 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -6,19 +6,39 @@ import ai.kilocode.client.app.Workspace import ai.kilocode.client.session.model.SessionModelEvent import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.ui.LabelPicker -import ai.kilocode.client.session.ui.MessageListUi +import ai.kilocode.client.session.ui.PermissionPanel import ai.kilocode.client.session.ui.PromptPanel +import ai.kilocode.client.session.ui.QuestionPanel +import ai.kilocode.client.session.ui.SessionPanel import ai.kilocode.client.session.ui.StatusPanel import com.intellij.openapi.Disposable import com.intellij.openapi.project.Project +import com.intellij.openapi.util.registry.Registry +import ai.kilocode.log.ChatLogSummary +import ai.kilocode.log.KiloLog import com.intellij.ui.components.JBScrollPane import com.intellij.util.ui.JBUI import kotlinx.coroutines.CoroutineScope import java.awt.BorderLayout import java.awt.CardLayout +import javax.swing.BoxLayout import javax.swing.JPanel -/** Main session panel — reacts to [SessionController] events. */ +/** + * Top-level session UI — a thin composition root. + * + * Responsibilities: + * - Creates and wires [SessionController], [SessionPanel], [StatusPanel], + * [PromptPanel], [QuestionPanel], [PermissionPanel]. + * - Switches between the status (loading) card and the transcript card via + * [SessionControllerEvent.ViewChanged]. + * - Delegates all transcript and dock updates to the panels themselves via + * [SessionModelEvent] listeners (no inline rendering logic here). + * - Scrolls to the bottom on new content. + * + * Views must never call RPC or services directly; everything goes through + * the controller. + */ class SessionUi( project: Project, workspace: Workspace, @@ -30,21 +50,46 @@ class SessionUi( companion object { private const val STATUS = "status" private const val MESSAGES = "messages" + private val LOG = KiloLog.create(SessionUi::class.java) } - private val controller = SessionController(this, null, sessions, workspace, app, cs) - private val status = StatusPanel(this, controller) - private val messages = MessageListUi(this, controller.model) + private val flushMs = Registry.intValue("kilo.session.flushMs", EVENT_FLUSH_MS.toInt()) + .takeIf { it > 0 } + ?.toLong() + ?: EVENT_FLUSH_MS + + private val controller = SessionController( + this, null, sessions, workspace, app, cs, this, + flushMs = flushMs, + condense = Registry.`is`("kilo.session.condense", true), + ) + + // ------ card switch ------ private val cards = CardLayout() private val center = JPanel(cards) - private val scroll = JBScrollPane(messages).apply { + // ------ status (loading) panel ------ + + private val status = StatusPanel(this, controller) + + // ------ transcript ------ + + private val transcript = SessionPanel(controller.model, this) + + private val scroll = JBScrollPane(transcript).apply { border = JBUI.Borders.empty() verticalScrollBarPolicy = JBScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED horizontalScrollBarPolicy = JBScrollPane.HORIZONTAL_SCROLLBAR_NEVER } + // ------ dock panels (above prompt) ------ + + private val question = QuestionPanel(controller) + private val permission = PermissionPanel(controller) + + // ------ prompt ------ + private val prompt = PromptPanel( project = project, onSend = { text -> send(text) }, @@ -52,63 +97,110 @@ class SessionUi( ) init { + // South area: question dock, permission dock, and prompt stacked vertically. + // BoxLayout(Y_AXIS) collapses invisible panels to zero height automatically, + // so hiding a dock doesn't leave an empty gap above the prompt. + val south = JPanel().apply { + layout = BoxLayout(this, BoxLayout.Y_AXIS) + isOpaque = false + add(question) + add(permission) + add(prompt) + } + center.add(status, STATUS) center.add(scroll, MESSAGES) cards.show(center, STATUS) add(center, BorderLayout.CENTER) - add(prompt, BorderLayout.SOUTH) + add(south, BorderLayout.SOUTH) + + // ------ picker wiring ------ prompt.mode.onSelect = { item -> controller.selectAgent(item.id) } prompt.model.onSelect = picker@{ item -> - val group = item.group - if (group == null) return@picker + val group = item.group ?: return@picker controller.selectModel(group, item.id) } - // Lifecycle events from the manager (app/workspace state, view switching) + // ------ controller lifecycle events ------ + controller.addListener(this) { event -> when (event) { is SessionControllerEvent.WorkspaceReady -> { - val c = controller.model - prompt.mode.setItems(c.agents.map { LabelPicker.Item(it.name, it.display) }, c.agent) - val items = c.models.map { LabelPicker.Item(it.id, it.display, it.provider) } - val selected = c.model?.let { full -> items.firstOrNull { "${it.group}/${it.id}" == full }?.id } + val m = controller.model + prompt.mode.setItems(m.agents.map { LabelPicker.Item(it.name, it.display) }, m.agent) + val items = m.models.map { LabelPicker.Item(it.id, it.display, it.provider) } + val selected = m.model?.let { full -> items.firstOrNull { "${it.group}/${it.id}" == full }?.id } prompt.model.setItems(items, selected) - prompt.setReady(c.isReady()) + prompt.setReady(m.isReady()) } - is SessionControllerEvent.ViewChanged -> cards.show(center, if (event.show) MESSAGES else STATUS) + is SessionControllerEvent.ViewChanged -> + cards.show(center, if (event.show) MESSAGES else STATUS) is SessionControllerEvent.AppChanged, - is SessionControllerEvent.WorkspaceChanged -> prompt.setReady(controller.model.isReady()) + is SessionControllerEvent.WorkspaceChanged -> + prompt.setReady(controller.model.isReady()) } } - // Model events — state drives the prompt busy state + // ------ model events — prompt state + dock + auto-scroll ------ + controller.model.addListener(this) { event -> when (event) { - is SessionModelEvent.StateChanged -> { - val busy = event.state.isBusy() - prompt.setBusy(busy) - scrollToBottom() - } - is SessionModelEvent.MessageAdded, + is SessionModelEvent.StateChanged -> onState(event.state) + + is SessionModelEvent.TurnAdded, + is SessionModelEvent.TurnUpdated, is SessionModelEvent.ContentAdded, - is SessionModelEvent.ContentUpdated, - is SessionModelEvent.ContentDelta -> scrollToBottom() + is SessionModelEvent.ContentDelta, is SessionModelEvent.HistoryLoaded -> scrollToBottom() - else -> {} + + is SessionModelEvent.TurnRemoved, + is SessionModelEvent.MessageAdded, + is SessionModelEvent.MessageUpdated, + is SessionModelEvent.MessageRemoved, + is SessionModelEvent.ContentUpdated, + is SessionModelEvent.ContentRemoved, + is SessionModelEvent.DiffUpdated, + is SessionModelEvent.TodosUpdated, + is SessionModelEvent.Compacted, + is SessionModelEvent.Cleared -> Unit } } } + // ------ private helpers ------ + private fun send(text: String) { if (text.isBlank()) return + LOG.debug { + "${ChatLogSummary.prompt(text)} agent=${controller.model.agent ?: "none"} model=${controller.model.model ?: "none"} ready=${controller.ready}" + } controller.prompt(text) prompt.clear() } + private fun onState(state: SessionState) { + prompt.setBusy(state.isBusy()) + when (state) { + is SessionState.AwaitingQuestion -> { + permission.hidePanel() + question.show(state.question) + } + is SessionState.AwaitingPermission -> { + question.hidePanel() + permission.show(state.permission) + } + else -> { + question.hidePanel() + permission.hidePanel() + } + } + scrollToBottom() + } + private fun scrollToBottom() { val bar = scroll.verticalScrollBar bar.value = bar.maximum @@ -118,7 +210,6 @@ class SessionUi( } private fun SessionState.isBusy(): Boolean = when (this) { - is SessionState.Idle -> false - is SessionState.Error -> false + is SessionState.Idle, is SessionState.Error -> false else -> true } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt new file mode 100644 index 00000000000..2c02e7b3349 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUpdateQueue.kt @@ -0,0 +1,128 @@ +package ai.kilocode.client.session + +import ai.kilocode.log.ChatLogSummary +import ai.kilocode.log.KiloLog +import ai.kilocode.rpc.dto.ChatEventDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.util.Disposer +import java.awt.Component +import java.awt.event.HierarchyEvent +import java.awt.event.HierarchyListener +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +internal const val EVENT_FLUSH_MS = 150L + +internal class SessionUpdateQueue( + parent: Disposable, + private val comp: Component?, + private val flushMs: Long = EVENT_FLUSH_MS, + private val fire: (List) -> Unit, + private val condense: Boolean = true, + hold: Boolean, + private val sid: () -> String, +) : Disposable { + companion object { + private val LOG = KiloLog.create(SessionUpdateQueue::class.java) + } + + private val app = ApplicationManager.getApplication() + private val condenser = SessionQueueCondenser() + private val pending = mutableListOf() + private val lock = Any() + private val exec: ScheduledExecutorService? = if (flushMs == Long.MAX_VALUE) null else Executors.newSingleThreadScheduledExecutor() + private val visible = AtomicBoolean(comp?.isShowing ?: true) + private val watch = comp?.let { + HierarchyListener { event -> + if (event.changeFlags and HierarchyEvent.SHOWING_CHANGED.toLong() == 0L) return@HierarchyListener + onVisible(it.isShowing) + } + } + private var last = 0L + private var hold = hold + + init { + Disposer.register(parent, this) + if (comp != null && watch != null) comp.addHierarchyListener(watch) + exec?.scheduleAtFixedRate( + { + if (!visible.get()) return@scheduleAtFixedRate + requestFlush(false, "tick") + }, + flushMs, + flushMs, + TimeUnit.MILLISECONDS, + ) + } + + fun enqueue(event: ChatEventDto) { + val size = synchronized(lock) { + pending.add(event) + pending.size + } + LOG.debug { "${ChatLogSummary.sid(sid())} enqueue pending=$size visible=${visible.get()}" } + if (!visible.get()) return + requestFlush(false, "enqueue") + } + + fun holdFlush(hold: Boolean) { + edt { + LOG.debug { "${ChatLogSummary.sid(sid())} hold=$hold" } + this.hold = hold + } + } + + fun requestFlush(forced: Boolean, source: String = "api") { + if (!forced && !visible.get()) return + edt { flushNow(forced, source) } + } + + override fun dispose() { + val size = synchronized(lock) { pending.size } + LOG.debug { "${ChatLogSummary.sid(sid())} dispose pending=$size" } + exec?.shutdownNow() + if (comp != null && watch != null) comp.removeHierarchyListener(watch) + if (app.isDispatchThread) { + synchronized(lock) { pending.clear() } + return + } + app.invokeLater { synchronized(lock) { pending.clear() } } + } + + private fun flushNow(forced: Boolean, source: String) { + if (hold) return + if (!visible.get()) return + val now = System.currentTimeMillis() + if (!forced && now - last < flushMs) return + val batch = synchronized(lock) { + if (pending.isEmpty()) return + pending.toList().also { pending.clear() } + } + val before = batch.size + val types = batch.groupBy { it::class.simpleName } + .entries.joinToString(",") { (k, v) -> "$k:${v.size}" } + val out = if (condense) condenser.condense(batch) else batch + last = now + LOG.debug { "${ChatLogSummary.sid(sid())} flush source=$source forced=$forced pending=$before condensed=${out.size} saved=${before - out.size} types=$types" } + fire(out) + } + + private fun onVisible(show: Boolean) { + val prev = visible.getAndSet(show) + if (prev == show) return + LOG.debug { "${ChatLogSummary.sid(sid())} visible=$show" } + if (!show) return + requestFlush(true, "visible") + } + + private fun edt(block: () -> Unit) { + if (app.isDispatchThread) { + block() + return + } + app.invokeLater(block) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt index 6d90a918382..4fec5b07bb1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModel.kt @@ -17,10 +17,26 @@ import com.intellij.openapi.util.Disposer * * **EDT-only access** — no synchronization. [ai.kilocode.client.session.SessionController] guarantees all * reads and writes happen on the EDT. + * + * In addition to the flat message list, the model maintains a derived + * **turn grouping**: a [Turn] starts with each user message and collects + * the following assistant messages. Leading assistant messages (before the + * first user message) form their own standalone turn. + * + * Turn grouping is recomputed after every message add/remove, and the + * diff is emitted as [SessionModelEvent.TurnAdded], [SessionModelEvent.TurnUpdated], + * or [SessionModelEvent.TurnRemoved] events *after* the message event that + * triggered the change. */ class SessionModel { + companion object { + /** Part types that are internal server markers and must never be stored or rendered. */ + val SILENT_PART_TYPES = setOf("step-start", "step-finish") + } + private val entries = LinkedHashMap() + private val turnEntries = LinkedHashMap() var app: KiloAppStateDto = KiloAppStateDto(KiloAppStatusDto.DISCONNECTED) var version: String? = null @@ -57,6 +73,10 @@ class SessionModel { fun content(messageId: String, contentId: String): Content? = entries[messageId]?.parts?.get(contentId) + fun turns(): Collection = turnEntries.values + + fun turn(id: String): Turn? = turnEntries[id] + fun isEmpty(): Boolean = entries.isEmpty() fun isReady(): Boolean = app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY @@ -76,6 +96,7 @@ class SessionModel { val msg = Message(dto) entries[dto.id] = msg fire(SessionModelEvent.MessageAdded(msg)) + regroup() return true } @@ -85,12 +106,14 @@ class SessionModel { val msg = Message(dto) entries[dto.id] = msg fire(SessionModelEvent.MessageAdded(msg)) + regroup() return msg } fun removeMessage(id: String) { if (entries.remove(id) == null) return fire(SessionModelEvent.MessageRemoved(id)) + regroup() } fun removeContent(messageId: String, contentId: String) { @@ -100,6 +123,7 @@ class SessionModel { } fun updateContent(messageId: String, dto: PartDto) { + if (dto.type in SILENT_PART_TYPES) return val msg = entries[messageId] ?: return val existing = msg.parts[dto.id] if (existing != null) { @@ -159,16 +183,19 @@ class SessionModel { for (msg in history) { val item = Message(msg.info) for (part in msg.parts) { + if (part.type in SILENT_PART_TYPES) continue val content = fromDto(part, part.text) item.parts[content.id] = content } entries[msg.info.id] = item } + rebuildTurnsSilently() fire(SessionModelEvent.HistoryLoaded) } fun clear() { entries.clear() + turnEntries.clear() state = SessionState.Idle diff = emptyList() todos = emptyList() @@ -176,6 +203,100 @@ class SessionModel { fire(SessionModelEvent.Cleared) } + // ------ turn grouping ------ + + /** + * Recompute the turn grouping after a message was added or removed. + * Diffs against the current [turnEntries] and fires [SessionModelEvent.TurnAdded], + * [SessionModelEvent.TurnUpdated], or [SessionModelEvent.TurnRemoved] as needed. + */ + private fun regroup() { + val groups = computeGroups() + val grouped = groups.associate { it } + val prev = turnEntries.keys.toList() + val next = groups.map { it.first } + + // Turns that no longer exist + for (id in prev) { + if (id !in grouped) { + turnEntries.remove(id) + fire(SessionModelEvent.TurnRemoved(id)) + } + } + + // Build the new ordered map; fire Added/Updated as needed + val rebuilt = LinkedHashMap() + for ((id, ids) in groups) { + val existing = turnEntries[id] + if (existing == null) { + val turn = Turn(id).also { t -> ids.forEach { t.add(it) } } + rebuilt[id] = turn + fire(SessionModelEvent.TurnAdded(turn)) + } else { + if (existing.messageIds != ids) { + val turn = Turn(id).also { t -> ids.forEach { t.add(it) } } + rebuilt[id] = turn + fire(SessionModelEvent.TurnUpdated(turn)) + } else { + rebuilt[id] = existing + } + } + } + + turnEntries.clear() + turnEntries.putAll(rebuilt) + } + + /** + * Rebuild turns from the current message list *without* firing any events. + * Used by [loadHistory] and [clear] so the derived state stays consistent + * without generating spurious turn events (the caller fires a single bulk event). + */ + private fun rebuildTurnsSilently() { + turnEntries.clear() + for ((_, ids) in computeGroups()) { + val turn = Turn(ids.first()) + ids.forEach { turn.add(it) } + turnEntries[turn.id] = turn + } + } + + /** + * Compute the canonical turn grouping from the current message insertion order. + * Each group is a Pair of (turnId, orderedMessageIds). + * + * Rules: + * - A user message always starts a new turn (turn id = user message id). + * - Assistant messages following a user message belong to that turn. + * - Leading assistant messages (before any user message) anchor their own turn + * (turn id = first assistant message id in that leading block). + */ + private fun computeGroups(): List>> { + val result = mutableListOf>>() + var cur: MutableList? = null + var curId: String? = null + + for (msg in entries.values) { + if (msg.info.role == "user") { + if (curId != null && cur != null) result.add(curId to cur) + curId = msg.info.id + cur = mutableListOf(msg.info.id) + } else { + if (cur == null) { + curId = msg.info.id + cur = mutableListOf(msg.info.id) + } else { + cur.add(msg.info.id) + } + } + } + + if (curId != null && cur != null) result.add(curId to cur) + return result.map { (id, ids) -> id to ids.toList() } + } + + // ------ private helpers ------ + private fun updateExisting(messageId: String, existing: Content, dto: PartDto) { when (existing) { is Text -> { @@ -220,6 +341,24 @@ class SessionModel { for (l in listeners) l.onEvent(event) } + // ------ string representations ------ + + /** + * Compact turn-grouping summary for test assertions. + * + * Format: one line per turn → `turn#: #, ...` + */ + fun toTurnsString(): String { + if (turnEntries.isEmpty()) return "(no turns)" + return turnEntries.values.joinToString("\n") { turn -> + val msgs = turn.messageIds.joinToString(", ") { id -> + val msg = entries[id] + if (msg != null) "${msg.info.role}#$id" else "?#$id" + } + "turn#${turn.id}: $msgs" + } + } + override fun toString(): String { val out = mutableListOf() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt index 6ddb5be4f26..40482f45155 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionModelEvent.kt @@ -8,6 +8,13 @@ import ai.kilocode.rpc.dto.TodoDto * * Events carry the data needed for rendering so UI can update without * reading back from the model except for [HistoryLoaded]. + * + * **Message events** cover individual message and part mutations. + * **Turn events** ([TurnAdded], [TurnUpdated], [TurnRemoved]) cover the + * derivative turn-grouping structure that [SessionModel] maintains on top + * of the flat message list. Turn events fire *after* the message event + * that caused the regrouping, so by the time a turn event arrives the + * message is already present in (or absent from) the model. */ sealed class SessionModelEvent { data class MessageAdded(val info: Message) : SessionModelEvent() { @@ -46,6 +53,35 @@ sealed class SessionModelEvent { data object HistoryLoaded : SessionModelEvent() data object Cleared : SessionModelEvent() + // ------ Turn grouping events ------ + + /** + * A new conversational turn was created. Fires after [MessageAdded] + * when a user message starts a new turn, or when a leading assistant + * message anchors a standalone turn. + */ + data class TurnAdded(val turn: Turn) : SessionModelEvent() { + override fun toString() = "TurnAdded ${turn.id} [${turn.messageIds.joinToString(", ")}]" + } + + /** + * An existing turn's message list changed (message added to or removed + * from an existing turn). Fires after the message event that caused the + * change. The [turn] carries the new, complete message-id list. + */ + data class TurnUpdated(val turn: Turn) : SessionModelEvent() { + override fun toString() = "TurnUpdated ${turn.id} [${turn.messageIds.joinToString(", ")}]" + } + + /** + * A turn was removed because all its messages are gone or because a + * user-message anchor was removed and the remaining messages were + * absorbed into an adjacent turn. + */ + data class TurnRemoved(val id: String) : SessionModelEvent() { + override fun toString() = "TurnRemoved $id" + } + fun interface Listener { fun onEvent(event: SessionModelEvent) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Turn.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Turn.kt new file mode 100644 index 00000000000..fbd4d99d057 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/Turn.kt @@ -0,0 +1,36 @@ +package ai.kilocode.client.session.model + +/** + * A conversational turn: one user message (the "anchor") and the + * consecutive assistant messages that follow it, up to the next user message. + * + * When no user message precedes the first assistant message(s) the turn uses + * the first assistant message id as its own anchor. + * + * Maintained by [SessionModel]. Listeners receive [SessionModelEvent.TurnAdded], + * [SessionModelEvent.TurnUpdated], and [SessionModelEvent.TurnRemoved] whenever + * the grouping changes. + */ +class Turn(val id: String) { + private val _ids = mutableListOf() + + val messageIds: List get() = _ids.toList() + + internal fun add(id: String) { + _ids.add(id) + } + + internal fun setAll(ids: List) { + _ids.clear() + _ids.addAll(ids) + } + + override fun toString() = "Turn#$id [${_ids.joinToString(", ")}]" + + override fun equals(other: Any?): Boolean { + if (other !is Turn) return false + return id == other.id && _ids == other._ids + } + + override fun hashCode() = 31 * id.hashCode() + _ids.hashCode() +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt index f7a106ffbbc..b7e98135703 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/MessageListUi.kt @@ -78,7 +78,10 @@ class MessageListUi( is SessionModelEvent.Cleared -> onCleared() is SessionModelEvent.DiffUpdated, is SessionModelEvent.TodosUpdated, - is SessionModelEvent.Compacted -> Unit + is SessionModelEvent.Compacted, + is SessionModelEvent.TurnAdded, + is SessionModelEvent.TurnUpdated, + is SessionModelEvent.TurnRemoved -> Unit } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt new file mode 100644 index 00000000000..0c105e5da22 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PermissionPanel.kt @@ -0,0 +1,88 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.session.SessionController +import ai.kilocode.client.session.model.Permission +import ai.kilocode.rpc.dto.PermissionReplyDto +import com.intellij.icons.AllIcons +import com.intellij.ui.JBColor +import com.intellij.ui.dsl.builder.RightGap +import com.intellij.ui.dsl.builder.RowLayout +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import javax.swing.JPanel +import javax.swing.border.MatteBorder + +/** + * Docked permission panel — shown above the prompt when the session is in + * [ai.kilocode.client.session.model.SessionState.AwaitingPermission]. + * + * The inner layout is built via Kotlin UI DSL inside [show] so it reflects + * the current permission's tool, patterns, and optional message. + * + * Layout (mirrors VS Code's PermissionDock): + * ``` + * ┌─────────────────────────────────────────┐ + * │ ⚠ Permission request │ + * │ Tool: edit • Patterns: *.kt │ + * │ │ + * │ [Allow] [Deny] │ + * └─────────────────────────────────────────┘ + * ``` + */ +class PermissionPanel( + private val controller: SessionController, +) : JPanel(BorderLayout()) { + + private lateinit var requestId: String + + init { + isOpaque = false + border = JBUI.Borders.compound( + MatteBorder(JBUI.scale(1), 0, 0, 0, JBColor.border()), + JBUI.Borders.empty(JBUI.scale(6), JBUI.scale(8)), + ) + isVisible = false + } + + /** Populate the panel for [permission] and make it visible. */ + fun show(permission: Permission) { + requestId = permission.id + val patterns = permission.patterns.joinToString(", ").ifEmpty { "*" } + + removeAll() + add(panel { + row { + icon(AllIcons.General.Warning).gap(RightGap.SMALL) + label("Permission request").bold() + } + row { + label("Tool: ${permission.name} \u2022 Patterns: $patterns") + } + val msg = permission.message + if (!msg.isNullOrBlank()) { + row { + comment(msg) + } + } + row { + button("Allow") { decide("once") }.gap(RightGap.SMALL) + button("Deny") { decide("reject") } + }.layout(RowLayout.INDEPENDENT) + }, BorderLayout.CENTER) + + isVisible = true + revalidate() + repaint() + } + + /** Hide this panel. */ + fun hidePanel() { + isVisible = false + } + + private fun decide(reply: String) { + controller.replyPermission(requestId, PermissionReplyDto(reply = reply)) + hidePanel() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt new file mode 100644 index 00000000000..75619241f77 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/ProgressPanel.kt @@ -0,0 +1,62 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionState +import com.intellij.openapi.Disposable +import com.intellij.ui.AnimatedIcon +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.FlowLayout + +/** + * Progress footer rendered at the bottom of the session transcript while the + * agent is working. + * + * Reacts to [SessionModelEvent.StateChanged]: + * - [SessionState.Busy] → shows an animated spinner and [SessionState.Busy.text] + * - Any other state → hidden + * + * Owned by [SessionPanel], which always re-anchors it as the last child so it + * appears below all turn views inside the scroll pane. + */ +class ProgressPanel( + model: SessionModel, + parent: Disposable, +) : SessionLayoutPanel() { + + private val label = JBLabel().apply { + foreground = UIUtil.getContextHelpForeground() + font = JBUI.Fonts.label() + } + + init { + isOpaque = false + isVisible = false + layout = FlowLayout(FlowLayout.LEFT, JBUI.scale(6), 0) + border = JBUI.Borders.empty(JBUI.scale(6), 0, JBUI.scale(4), 0) + + add(JBLabel(AnimatedIcon.Default())) + add(label) + + model.addListener(parent) { event -> + if (event is SessionModelEvent.StateChanged) onState(event.state) + } + } + + /** Exposed for test assertions. */ + fun labelText(): String = label.text + + private fun onState(state: SessionState) { + when (state) { + is SessionState.Busy -> { + label.text = state.text + isVisible = true + } + else -> isVisible = false + } + revalidate() + repaint() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PromptPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PromptPanel.kt index 0bc46fc5a1b..5ee337a3d5c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PromptPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/PromptPanel.kt @@ -1,6 +1,8 @@ package ai.kilocode.client.session.ui import ai.kilocode.client.plugin.KiloBundle +import ai.kilocode.log.ChatLogSummary +import ai.kilocode.log.KiloLog import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.project.Project import com.intellij.openapi.util.IconLoader @@ -38,6 +40,7 @@ class PromptPanel( ) : JPanel(BorderLayout()) { companion object { + private val LOG = KiloLog.create(PromptPanel::class.java) private val SEND_ICON: Icon = IconLoader.getIcon("/icons/send.svg", PromptPanel::class.java) private val STOP_ICON: Icon = IconLoader.getIcon("/icons/stop.svg", PromptPanel::class.java) private const val EDITOR_LINES = 3 @@ -56,13 +59,13 @@ class PromptPanel( ed.scrollPane.horizontalScrollBarPolicy = ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER ed.contentComponent.addKeyListener(object : KeyAdapter() { - override fun keyPressed(e: KeyEvent) { - if (e.keyCode == KeyEvent.VK_ENTER && !e.isShiftDown) { - e.consume() - submit() - } - } - }) + override fun keyPressed(e: KeyEvent) { + if (e.keyCode == KeyEvent.VK_ENTER && !e.isShiftDown) { + e.consume() + submit("enter") + } + } + }) } } @@ -74,7 +77,10 @@ class PromptPanel( isEnabled = false maximumSize = Dimension(JBUI.scale(28), Short.MAX_VALUE.toInt()) preferredSize = Dimension(JBUI.scale(28), JBUI.scale(24)) - addActionListener { if (busy) onAbort() else submit() } + addActionListener { + if (busy) onAbort() + else submit("button") + } } @Volatile @@ -122,9 +128,10 @@ class PromptPanel( editor.requestFocusInWindow() } - private fun submit() { + private fun submit(src: String) { if (busy) return val txt = text() + LOG.debug { "${ChatLogSummary.prompt(txt)} src=$src busy=$busy" } if (txt.isNotEmpty()) { onSend(txt) } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt new file mode 100644 index 00000000000..fcd83a223c1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/QuestionPanel.kt @@ -0,0 +1,95 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.session.SessionController +import ai.kilocode.client.session.model.Question +import ai.kilocode.rpc.dto.QuestionReplyDto +import com.intellij.ui.JBColor +import com.intellij.ui.dsl.builder.RightGap +import com.intellij.ui.dsl.builder.RowLayout +import com.intellij.ui.dsl.builder.panel +import com.intellij.util.ui.JBUI +import java.awt.BorderLayout +import javax.swing.JPanel +import javax.swing.border.MatteBorder + +/** + * Docked question panel — shown above the prompt when the session is in + * [ai.kilocode.client.session.model.SessionState.AwaitingQuestion]. + * + * The inner layout is rebuilt via Kotlin UI DSL each time [show] is called, + * so the option buttons always match the current question. + * + * Layout (mirrors VS Code's QuestionDock): + * ``` + * ┌─────────────────────────────────────────┐ + * │ ❔
│ + * │ │ + * │ [Option A] [Option B] [Dismiss] │ + * └─────────────────────────────────────────┘ + * ``` + */ +class QuestionPanel( + private val controller: SessionController, +) : JPanel(BorderLayout()) { + + private var requestId: String? = null + + init { + isOpaque = false + border = JBUI.Borders.compound( + MatteBorder(JBUI.scale(1), 0, 0, 0, JBColor.border()), + JBUI.Borders.empty(JBUI.scale(6), JBUI.scale(8)), + ) + isVisible = false + } + + /** Populate the panel for the first item in [question] and make it visible. */ + fun show(question: Question) { + val item = question.items.firstOrNull() ?: run { + hidePanel() + return + } + requestId = question.id + + removeAll() + add(panel { + row { + label("\u2753 ${item.header}").bold() + } + row { + label(item.question) + } + row { + for (opt in item.options) { + button(opt.label) { reply(listOf(listOf(opt.label))) } + .gap(RightGap.SMALL) + .applyToComponent { toolTipText = opt.description } + } + button("Dismiss") { reject() } + }.layout(RowLayout.INDEPENDENT) + }, BorderLayout.CENTER) + + isVisible = true + revalidate() + repaint() + } + + /** Hide this panel. */ + fun hidePanel() { + requestId = null + removeAll() + isVisible = false + } + + private fun reply(answers: List>) { + val id = requestId ?: return + controller.replyQuestion(id, QuestionReplyDto(answers)) + hidePanel() + } + + private fun reject() { + val id = requestId ?: return + controller.rejectQuestion(id) + hidePanel() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt new file mode 100644 index 00000000000..0f1c3b87cd0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionLayout.kt @@ -0,0 +1,90 @@ +package ai.kilocode.client.session.ui + +import com.intellij.util.ui.JBUI +import java.awt.Component +import java.awt.Container +import java.awt.Dimension +import java.awt.LayoutManager +import javax.swing.JPanel + +/** + * A vertical, width-aware layout manager for the session transcript. + * + * Standard Swing layout managers (BoxLayout, GridLayout, etc.) compute + * preferred sizes independently of width, which breaks [JBHtmlPane]-backed + * components: they report an incorrect height until their width is fixed. + * + * This layout: + * 1. Uses the parent's *actual* width as the available width for all children. + * 2. Calls `setSize(w, …)` on each child before reading `preferredSize.height` + * so that HTML components reflow and report the correct height. + * 3. Stacks children top-to-bottom with a configurable [gap]. + * 4. Skips invisible children. + * + * Pair with [SessionLayoutPanel] (or any [JPanel] that implements [Scrollable] + * with `getScrollableTracksViewportWidth = true`) so the viewport constrains + * the panel width and the layout always has a valid width to work with. + */ +class SessionLayout(private val gap: Int = JBUI.scale(4)) : LayoutManager { + + override fun addLayoutComponent(name: String, comp: Component) {} + override fun removeLayoutComponent(comp: Component) {} + + override fun preferredLayoutSize(parent: Container): Dimension { + val ins = parent.insets + val w = maxOf(0, parent.width - ins.left - ins.right) + var h = ins.top + ins.bottom + var first = true + for (comp in parent.components) { + if (!comp.isVisible) continue + if (!first) h += gap + first = false + // Pre-size to available width so HTML panes reflow before we measure + comp.setSize(w, comp.height.coerceAtLeast(1)) + h += comp.preferredSize.height + } + return Dimension(w + ins.left + ins.right, h) + } + + override fun minimumLayoutSize(parent: Container): Dimension = preferredLayoutSize(parent) + + override fun layoutContainer(parent: Container) { + val ins = parent.insets + val w = maxOf(0, parent.width - ins.left - ins.right) + var y = ins.top + var first = true + for (comp in parent.components) { + if (!comp.isVisible) continue + if (!first) y += gap + first = false + // Fix width first so HTML reflows, then read the resulting height + comp.setSize(w, comp.height.coerceAtLeast(1)) + val h = comp.preferredSize.height + comp.setBounds(ins.left, y, w, h) + y += h + } + } +} + +/** + * A [JPanel] pre-configured with [SessionLayout] and the [Scrollable] interface. + * + * Setting `getScrollableTracksViewportWidth = true` tells the enclosing + * [JScrollPane] to force the panel's width to match the viewport, giving + * [SessionLayout] a valid width to measure against. + */ +open class SessionLayoutPanel(gap: Int = JBUI.scale(4)) : JPanel(SessionLayout(gap)), javax.swing.Scrollable { + override fun getScrollableTracksViewportWidth() = true + override fun getScrollableTracksViewportHeight() = false + override fun getPreferredScrollableViewportSize(): Dimension = preferredSize + override fun getScrollableUnitIncrement( + visibleRect: java.awt.Rectangle, + @Suppress("UNUSED_PARAMETER") orientation: Int, + @Suppress("UNUSED_PARAMETER") direction: Int, + ): Int = JBUI.scale(16) + override fun getScrollableBlockIncrement( + visibleRect: java.awt.Rectangle, + @Suppress("UNUSED_PARAMETER") orientation: Int, + @Suppress("UNUSED_PARAMETER") direction: Int, + ): Int = visibleRect.height +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionPanel.kt new file mode 100644 index 00000000000..7a44c4531ef --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionPanel.kt @@ -0,0 +1,233 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.views.MessageView +import ai.kilocode.client.session.views.TurnView +import com.intellij.openapi.Disposable +import com.intellij.util.ui.JBUI + +/** + * Scrollable transcript panel that maps the model's turn grouping to + * [TurnView] children and keeps secondary indexes for fast message lookup. + * + * **Primary index**: `turnId -> TurnView` — one entry per top-level transcript item. + * **Secondary indexes**: + * - `messageId -> TurnView` — which turn does a message live in? + * - `messageId -> MessageView` — the nested renderer for that message. + * + * The panel reacts to [SessionModelEvent.TurnAdded], [TurnUpdated], and + * [TurnRemoved] for structural changes, and to [ContentAdded], [ContentUpdated], + * [ContentRemoved], [ContentDelta] for fine-grained part updates. + * + * [HistoryLoaded] and [Cleared] both trigger a full rebuild from the model. + * + * A [ProgressPanel] is always kept as the last child — it appears at the + * bottom of the transcript inside the scroll pane and shows a spinner while + * the session is busy. + * + * All method calls must happen on the EDT. + */ +class SessionPanel( + private val model: SessionModel, + parent: Disposable, +) : SessionLayoutPanel() { + + private val turnViews = LinkedHashMap() + private val msgToTurn = HashMap() + private val msgToView = HashMap() + + /** Progress footer — always the last child inside the scroll. */ + val progress = ProgressPanel(model, parent) + + init { + isOpaque = false + border = JBUI.Borders.empty(JBUI.scale(4), JBUI.scale(8)) + + model.addListener(parent) { event -> + when (event) { + is SessionModelEvent.TurnAdded -> onTurnAdded(event.turn) + is SessionModelEvent.TurnUpdated -> onTurnUpdated(event.turn) + is SessionModelEvent.TurnRemoved -> onTurnRemoved(event.id) + + is SessionModelEvent.ContentAdded -> + msgToView[event.messageId]?.upsertPart(event.content) + + is SessionModelEvent.ContentUpdated -> + msgToView[event.messageId]?.upsertPart(event.content) + + is SessionModelEvent.ContentRemoved -> + msgToView[event.messageId]?.removePart(event.contentId) + + is SessionModelEvent.ContentDelta -> { + // Use the full current content from the model rather than + // an incremental append. This avoids the double-write that + // occurs when ContentAdded and ContentDelta both fire for + // the same first delta (the model auto-creates the content + // on first appendDelta and fires both events in sequence). + val content = model.content(event.messageId, event.contentId) + if (content != null) msgToView[event.messageId]?.upsertPart(content) + } + + is SessionModelEvent.HistoryLoaded -> rebuild() + is SessionModelEvent.Cleared -> clear() + + // Message events: structural changes are handled via turn events above. + // State/diff/todos changes are handled by other panels in SessionUi. + is SessionModelEvent.MessageAdded, + is SessionModelEvent.MessageUpdated, + is SessionModelEvent.MessageRemoved, + is SessionModelEvent.StateChanged, + is SessionModelEvent.DiffUpdated, + is SessionModelEvent.TodosUpdated, + is SessionModelEvent.Compacted -> Unit + } + } + + // Populate from any turns already present (e.g. existing session opened before panel was created) + rebuild() + } + + // ------ public lookup API ------ + + /** Find the [MessageView] for a message by id, or null if not present. */ + fun findMessage(id: String): MessageView? = msgToView[id] + + /** Find the [TurnView] that contains a message. */ + fun findTurn(messageId: String): TurnView? = msgToTurn[messageId] + + /** Number of top-level turns currently displayed. */ + fun turnCount(): Int = turnViews.size + + /** Ordered turn ids — stable for test assertions. */ + fun turnIds(): List = turnViews.keys.toList() + + // ------ dump helpers for tests ------ + + /** + * Compact structural dump: one line per turn, each listing its messages. + * + * Example: + * ``` + * turn#u1: user#u1, assistant#a1 + * turn#u2: user#u2 + * ``` + */ + fun dump(): String = turnViews.values.joinToString("\n") { tv -> + "turn#${tv.id}: ${tv.dump()}" + } + + /** + * Detailed dump: turns → messages → part view labels. + */ + fun dumpDetailed(): String = buildString { + for (tv in turnViews.values) { + appendLine("turn#${tv.id}") + for (mid in tv.messageIds()) { + val mv = tv.messageView(mid)!! + appendLine(" ${mv.role}#$mid") + for (pid in mv.partIds()) { + appendLine(" ${mv.part(pid)!!.dumpLabel()}") + } + } + } + }.trimEnd() + + // ------ private event handlers ------ + + private fun onTurnAdded(turn: ai.kilocode.client.session.model.Turn) { + val tv = TurnView(turn.id) + turnViews[turn.id] = tv + for (msgId in turn.messageIds) { + val msg = model.message(msgId) ?: continue + val mv = tv.addMessage(msg) + register(msgId, tv, mv) + } + add(tv) + anchorFooter() + refresh() + } + + private fun onTurnUpdated(turn: ai.kilocode.client.session.model.Turn) { + val tv = turnViews[turn.id] ?: return + val prev = tv.messageIds().toSet() + val next = turn.messageIds + + // Remove messages no longer in this turn + for (id in prev) { + if (id !in next) { + tv.removeMessage(id) + unregister(id) + } + } + + // Add new messages (appended at the end of the turn) + for (id in next) { + if (id in prev) continue + val msg = model.message(id) ?: continue + val mv = tv.addMessage(msg) + register(id, tv, mv) + } + + refresh() + } + + private fun onTurnRemoved(id: String) { + val tv = turnViews.remove(id) ?: return + for (msgId in tv.messageIds()) unregister(msgId) + remove(tv) + anchorFooter() + refresh() + } + + private fun rebuild() { + turnViews.clear() + msgToTurn.clear() + msgToView.clear() + removeAll() + + for (turn in model.turns()) { + val tv = TurnView(turn.id) + turnViews[turn.id] = tv + for (msgId in turn.messageIds) { + val msg = model.message(msgId) ?: continue + val mv = tv.addMessage(msg) + register(msgId, tv, mv) + } + add(tv) + } + + anchorFooter() + refresh() + } + + private fun clear() { + turnViews.clear() + msgToTurn.clear() + msgToView.clear() + removeAll() + anchorFooter() + refresh() + } + + /** Re-insert [progress] as the last child so it always renders after all turn views. */ + private fun anchorFooter() { + remove(progress) + add(progress) + } + + private fun register(msgId: String, tv: TurnView, mv: MessageView) { + msgToTurn[msgId] = tv + msgToView[msgId] = mv + } + + private fun unregister(msgId: String) { + msgToTurn.remove(msgId) + msgToView.remove(msgId) + } + + private fun refresh() { + revalidate() + repaint() + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt new file mode 100644 index 00000000000..8703df3ed08 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/CompactionView.kt @@ -0,0 +1,69 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Compaction +import ai.kilocode.client.session.model.Content +import com.intellij.ui.JBColor +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout +import java.awt.GridBagConstraints +import java.awt.GridBagLayout +import javax.swing.JPanel +import javax.swing.SwingConstants + +/** + * Renders a [Compaction] part as a horizontal divider with a centred label, + * mirroring VS Code's compaction marker. + * + * Layout: + * ``` + * ───────────── context compacted ───────────── + * ``` + */ +class CompactionView(@Suppress("UNUSED_PARAMETER") compaction: Compaction) : PartView() { + + override val contentId: String = compaction.id + + init { + layout = BorderLayout() + isOpaque = false + border = JBUI.Borders.empty(JBUI.scale(6), 0) + + val text = JBLabel("context compacted").apply { + foreground = UIUtil.getContextHelpForeground() + font = JBUI.Fonts.smallFont() + horizontalAlignment = SwingConstants.CENTER + border = JBUI.Borders.empty(0, JBUI.scale(8)) + } + + val line = { JPanel().apply { + background = JBColor.border() + isOpaque = true + preferredSize = java.awt.Dimension(0, JBUI.scale(1)) + } } + + val row = JPanel(GridBagLayout()).apply { + isOpaque = false + val gc = GridBagConstraints() + + gc.fill = GridBagConstraints.HORIZONTAL + gc.weightx = 1.0 + add(line(), gc) + + gc.weightx = 0.0 + gc.fill = GridBagConstraints.NONE + add(text, gc) + + gc.fill = GridBagConstraints.HORIZONTAL + gc.weightx = 1.0 + add(line(), gc) + } + + add(row, BorderLayout.CENTER) + } + + override fun update(content: Content) {} // compaction has no mutable state + + override fun dumpLabel() = "CompactionView#$contentId" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/GenericView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/GenericView.kt new file mode 100644 index 00000000000..fb4d90bbafc --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/GenericView.kt @@ -0,0 +1,39 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Generic +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout + +/** + * Fallback renderer for part types that have no dedicated view. + * + * Rather than silently dropping unknown content (which could lead to + * confusing empty gaps), this shows a dim label with the raw type name. + * This makes it easy to spot new part types that need a proper renderer. + */ +class GenericView(content: Generic) : PartView() { + + override val contentId: String = content.id + + private val label = JBLabel("[${content.type}]").apply { + foreground = UIUtil.getContextHelpForeground() + font = JBUI.Fonts.smallFont() + border = JBUI.Borders.empty(2, 0) + } + + init { + layout = BorderLayout() + isOpaque = false + add(label, BorderLayout.CENTER) + } + + override fun update(content: Content) {} // generic content has no updatable state + + /** Exposed for tests. */ + fun labelText(): String = label.text + + override fun dumpLabel() = "GenericView#$contentId(${label.text})" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt new file mode 100644 index 00000000000..0c58dc8844c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/MessageView.kt @@ -0,0 +1,86 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Message +import com.intellij.ui.JBColor +import com.intellij.util.ui.JBUI +import javax.swing.border.MatteBorder + +/** + * A single message container inside a [TurnView]. + * + * Holds an ordered map of [PartView]s keyed by part id. The layout is + * driven by [ai.kilocode.client.session.ui.SessionLayout] so that each + * part view gets the full available width and height is computed correctly + * for HTML-backed views. + * + * Styling: + * - User messages: 1 px top separator + extra top padding to create a + * visual turn boundary. + * - Assistant messages: light padding only. + */ +class MessageView(val msg: Message) : ai.kilocode.client.session.ui.SessionLayoutPanel() { + + val role: String get() = msg.info.role + + private val parts = LinkedHashMap() + + init { + isOpaque = false + border = if (msg.info.role == "user") { + JBUI.Borders.compound( + MatteBorder(JBUI.scale(1), 0, 0, 0, JBColor.border()), + JBUI.Borders.empty(JBUI.scale(8), 0, JBUI.scale(4), 0), + ) + } else { + JBUI.Borders.empty(JBUI.scale(4), 0) + } + + // Populate content that already exists (e.g. after loadHistory) + for ((_, content) in msg.parts) { + val view = ViewFactory.create(content) + parts[content.id] = view + add(view) + } + } + + /** Add or update the renderer for [content]. */ + fun upsertPart(content: Content) { + val existing = parts[content.id] + if (existing != null) { + existing.update(content) + revalidate() + repaint() + return + } + val view = ViewFactory.create(content) + parts[content.id] = view + add(view) + revalidate() + repaint() + } + + /** Remove the renderer for [contentId] if present. */ + fun removePart(contentId: String) { + val view = parts.remove(contentId) ?: return + remove(view) + revalidate() + repaint() + } + + /** Append a streaming delta to the renderer for [contentId]. */ + fun appendDelta(contentId: String, delta: String) { + parts[contentId]?.appendDelta(delta) + revalidate() + repaint() + } + + /** Look up a renderer by part id. */ + fun part(id: String): PartView? = parts[id] + + /** Ordered part ids — stable for test assertions. */ + fun partIds(): List = parts.keys.toList() + + /** Compact dump for test assertions. */ + fun dump(): String = parts.values.joinToString(", ") { it.dumpLabel() } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PartView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PartView.kt new file mode 100644 index 00000000000..b86da78bd01 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/PartView.kt @@ -0,0 +1,35 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Content +import javax.swing.JPanel + +/** + * Base class for all part renderers. + * + * Each subclass wraps one [Content] subtype and knows how to display + * and update it. Subclasses extend [JPanel] so they can be added directly + * to [MessageView] without an extra component wrapper. + * + * All methods must be called on the EDT. + */ +abstract class PartView : JPanel() { + + /** Stable [Content.id] this renderer was created for. */ + abstract val contentId: String + + /** + * Apply a full content update — replace, not append. + * Called when [ai.kilocode.client.session.model.SessionModelEvent.ContentUpdated] fires. + */ + abstract fun update(content: Content) + + /** + * Append a streaming delta to the existing content. + * Only meaningful for text-bearing renderers ([TextView], [ReasoningView]); + * others ignore deltas by default. + */ + open fun appendDelta(delta: String) {} + + /** Readable name for test dumps. */ + open fun dumpLabel(): String = javaClass.simpleName +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt new file mode 100644 index 00000000000..eb67cc19c4e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ReasoningView.kt @@ -0,0 +1,51 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Reasoning +import ai.kilocode.client.ui.md.MdView +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout + +/** + * Renders a [Reasoning] part as markdown using [MdView]. + * + * Styled more subtly than [TextView] — uses the context-help foreground + * colour to indicate that this is internal model reasoning rather than + * the final assistant answer. + * + * Supports streaming appends like [TextView]. + */ +class ReasoningView(reasoning: Reasoning) : PartView() { + + override val contentId: String = reasoning.id + + val md: MdView = MdView.html() + + init { + layout = BorderLayout() + isOpaque = false + border = JBUI.Borders.empty(0, JBUI.scale(8), 0, 0) + + // Dim the text so it reads as subordinate context, not the main reply + md.foreground = UIUtil.getContextHelpForeground() + md.opaque = false + + add(md.component, BorderLayout.CENTER) + if (reasoning.content.isNotEmpty()) md.set(reasoning.content.toString()) + } + + override fun update(content: Content) { + if (content !is Reasoning) return + md.set(content.content.toString()) + } + + override fun appendDelta(delta: String) { + md.append(delta) + } + + /** Current markdown source — used by tests. */ + fun markdown(): String = md.markdown() + + override fun dumpLabel() = "ReasoningView#$contentId" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt new file mode 100644 index 00000000000..a5ce59144f0 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TextView.kt @@ -0,0 +1,39 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Text +import ai.kilocode.client.ui.md.MdView +import java.awt.BorderLayout + +/** + * Renders a [Text] part as markdown using [MdView]. + * + * Supports both full-replacement ([update]) and streaming append ([appendDelta]). + */ +class TextView(text: Text) : PartView() { + + override val contentId: String = text.id + + val md: MdView = MdView.html() + + init { + layout = BorderLayout() + isOpaque = false + add(md.component, BorderLayout.CENTER) + if (text.content.isNotEmpty()) md.set(text.content.toString()) + } + + override fun update(content: Content) { + if (content !is Text) return + md.set(content.content.toString()) + } + + override fun appendDelta(delta: String) { + md.append(delta) + } + + /** Current markdown source — used by tests to assert rendered content. */ + fun markdown(): String = md.markdown() + + override fun dumpLabel() = "TextView#$contentId" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt new file mode 100644 index 00000000000..21a2f1abef1 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ToolView.kt @@ -0,0 +1,62 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import com.intellij.ui.components.JBLabel +import com.intellij.util.ui.JBUI +import com.intellij.util.ui.UIUtil +import java.awt.BorderLayout + +/** + * Renders a [Tool] part as a compact one-line header. + * + * Shows a state icon, the tool name, and when available a descriptive + * title — mirroring VS Code's basic-tool card without the expandable + * output slot (output details can be added later when the RPC carries + * structured tool output). + * + * State icons: + * - ⏳ PENDING + * - ▶ RUNNING + * - ✓ COMPLETED + * - ✗ ERROR + */ +class ToolView(tool: Tool) : PartView() { + + override val contentId: String = tool.id + + private val label = JBLabel().apply { + foreground = UIUtil.getContextHelpForeground() + font = JBUI.Fonts.smallFont() + border = JBUI.Borders.empty(2, 0) + } + + init { + layout = BorderLayout() + isOpaque = false + add(label, BorderLayout.CENTER) + render(tool) + } + + override fun update(content: Content) { + if (content !is Tool) return + render(content) + } + + /** Exposed for tests to assert the displayed label text. */ + fun labelText(): String = label.text + + private fun render(tool: Tool) { + val icon = when (tool.state) { + ToolExecState.PENDING -> "\u23F3" // ⏳ + ToolExecState.RUNNING -> "\u25B6" // ▶ + ToolExecState.COMPLETED -> "\u2713" // ✓ + ToolExecState.ERROR -> "\u2717" // ✗ + } + val display = tool.title?.takeIf { it.isNotBlank() } ?: tool.name + label.text = "$icon $display" + } + + override fun dumpLabel() = "ToolView#$contentId(${label.text})" +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt new file mode 100644 index 00000000000..6cfd9287883 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/TurnView.kt @@ -0,0 +1,46 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Message + +/** + * Top-level transcript item representing one conversational turn. + * + * A turn contains one user [MessageView] (the "anchor") and the consecutive + * assistant [MessageView]s that follow it. The turn id matches the user anchor + * message id, or the first assistant message id when no user message precedes. + * + * Children are stacked by [ai.kilocode.client.session.ui.SessionLayout]. + */ +class TurnView(val id: String) : ai.kilocode.client.session.ui.SessionLayoutPanel() { + + private val messages = LinkedHashMap() + + init { + isOpaque = false + } + + /** Add a new [MessageView] for [msg] at the end of this turn. */ + fun addMessage(msg: Message): MessageView { + val view = MessageView(msg) + messages[msg.info.id] = view + add(view) + revalidate() + return view + } + + /** Remove the [MessageView] for [msgId] if present. */ + fun removeMessage(msgId: String) { + val view = messages.remove(msgId) ?: return + remove(view) + revalidate() + } + + /** Look up a nested [MessageView] by message id. */ + fun messageView(id: String): MessageView? = messages[id] + + /** Ordered message ids currently displayed — stable for test assertions. */ + fun messageIds(): List = messages.keys.toList() + + /** Compact dump for test assertions. */ + fun dump(): String = messages.entries.joinToString(", ") { (id, mv) -> "${mv.role}#$id" } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt new file mode 100644 index 00000000000..2015f1a97ee --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/ViewFactory.kt @@ -0,0 +1,26 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Compaction +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Generic +import ai.kilocode.client.session.model.Reasoning +import ai.kilocode.client.session.model.Text +import ai.kilocode.client.session.model.Tool + +/** + * Creates the appropriate [PartView] for a given [Content] subtype. + * + * Adding a new content type means: + * 1. Add a subclass of [Content] in the model. + * 2. Add a [PartView] subclass in this package. + * 3. Add a branch here — the exhaustive `when` will surface the gap as a compile error. + */ +object ViewFactory { + fun create(content: Content): PartView = when (content) { + is Text -> TextView(content) + is Reasoning -> ReasoningView(content) + is Tool -> ToolView(content) + is Compaction -> CompactionView(content) + is Generic -> GenericView(content) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt index 146075250e5..97d20ea0e6e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/ui/md/MdView.kt @@ -1,5 +1,6 @@ package ai.kilocode.client.ui.md +import ai.kilocode.log.KiloLog import com.intellij.ui.components.JBHtmlPane import com.intellij.ui.components.JBHtmlPaneConfiguration import com.intellij.ui.components.JBHtmlPaneStyleConfiguration @@ -77,6 +78,17 @@ abstract class MdView private constructor() { @Suppress("UnstableApiUsage") private class HtmlImpl : MdView() { + companion object { + private val LOG = KiloLog.create(HtmlImpl::class.java) + + private fun hex(c: Color): String = String.format("#%02x%02x%02x", c.red, c.green, c.blue) + + private fun css(text: String): String = text + .replace("\\", "\\\\") + .replace("'", "\\'") + .replace("\n", " ") + .replace("\r", " ") + } private val listeners = mutableListOf() private val source = StringBuilder() @@ -262,7 +274,11 @@ abstract class MdView private constructor() { val sheet = StyleSheet() val rules = buildOverrideRulesString() if (rules.isNotEmpty()) { - try { sheet.addRule(rules) } catch (_: Exception) {} + try { + sheet.addRule(rules) + } catch (err: Exception) { + LOG.warn("kind=markdown css=true failed message=${err.message} rules=$rules", err) + } } return sheet } @@ -274,13 +290,13 @@ abstract class MdView private constructor() { foregroundOverride?.let { body.add("color: ${hex(it)}") } if (!opaqueState) body.add("background: transparent") fontOverride?.let { - body.add("font-family: '${it.family}', sans-serif") + body.add("font-family: '${css(it.family)}', sans-serif") body.add("font-size: ${it.size}pt") } if (body.isNotEmpty()) rules.append("body { ${body.joinToString("; ")} } ") linkColorOverride?.let { rules.append("a { color: ${hex(it)} } ") } - codeFontOverride?.let { rules.append("tt, code, samp, pre { font-family: '${it}', monospace } ") } + codeFontOverride?.let { rules.append("tt, code, samp, pre { font-family: '${css(it)}', monospace } ") } preBgOverride?.let { rules.append("pre { background: ${hex(it)} } ") } preFgOverride?.let { rules.append("pre { color: ${hex(it)} } ") } codeBgOverride?.let { rules.append("code { background: ${hex(it)} } ") } @@ -290,9 +306,5 @@ abstract class MdView private constructor() { return rules.toString().trim() } - - companion object { - private fun hex(c: Color): String = String.format("#%02x%02x%02x", c.red, c.green, c.blue) - } } } diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml index d1815656660..5922e16ebd1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml +++ b/packages/kilo-jetbrains/frontend/src/main/resources/kilo.jetbrains.frontend.xml @@ -11,6 +11,17 @@ anchor="left" icon="/icons/kilo.svg" factoryClass="ai.kilocode.client.KiloToolWindowFactory"/> + + + diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ChatLoggingFlowTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ChatLoggingFlowTest.kt new file mode 100644 index 00000000000..3a8e1f0c777 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ChatLoggingFlowTest.kt @@ -0,0 +1,69 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.QuestionInfoDto +import ai.kilocode.rpc.dto.QuestionRequestDto +import ai.kilocode.rpc.dto.SessionStatusDto +import kotlinx.coroutines.flow.flow + +class ChatLoggingFlowTest : SessionControllerTestBase() { + + fun `test prompt creates session and subscribes before dispatch`() { + projectRpc.state.value = workspaceReady() + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + val m = controller() + flush() + + edt { m.prompt("hello") } + flush() + + assertEquals(1, rpc.creates) + assertEquals(1, rpc.prompts.size) + assertEquals("ses_test", rpc.prompts[0].first) + } + + fun `test subscribeEvents drops foreign session events`() { + rpc.eventFlow = { _, _ -> + flow { + emit(ChatEventDto.TurnOpen("ses_other")) + emit(ChatEventDto.TurnOpen("ses_test")) + } + } + val m = controller("ses_test") + val modelEvents = collectModelEvents(m) + flush() + + val states = modelEvents.filterIsInstance() + assertEquals(1, states.size) + assertEquals("StateChanged Busy", states.joinToString("\n")) + assertTrue(m.model.state is SessionState.Busy) + } + + fun `test recoverPending prefers permission over question and status`() { + rpc.pendingPermissionList += PermissionRequestDto( + id = "req_1", + sessionID = "ses_test", + permission = "edit", + patterns = listOf("*.kt"), + ) + rpc.pendingQuestionList += QuestionRequestDto( + id = "req_q", + sessionID = "ses_test", + questions = listOf(QuestionInfoDto("Pick", "Header")), + ) + rpc.statuses.value = mapOf( + "ses_test" to SessionStatusDto(type = "offline", requestID = "req_offline") + ) + + val m = controller("ses_test") + flush() + + assertTrue(m.model.state is SessionState.AwaitingPermission) + assertEquals("req_1", (m.model.state as SessionState.AwaitingPermission).permission.id) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt index 174c05879a2..a9a7de5c9df 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ListenerLifecycleTest.kt @@ -46,16 +46,12 @@ class ListenerLifecycleTest : SessionControllerTestBase() { edt { m.prompt("go") } flush() + assertEquals(events1, events2) assertControllerEvents(""" ViewChanged show AppChanged WorkspaceChanged """, events1) - assertControllerEvents(""" - ViewChanged show - AppChanged - WorkspaceChanged - """, events2) } fun `test session status idle fires StateChanged to Idle`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt index 3290a336b1c..a53b0655c16 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/MessageListTest.kt @@ -11,7 +11,11 @@ class MessageListTest : SessionControllerTestBase() { emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) - assertModelEvents("MessageAdded msg1", modelEvents) + // MessageAdded fires first; TurnAdded fires after regroup + assertModelEvents(""" + MessageAdded msg1 + TurnAdded msg1 [msg1] + """, modelEvents) assertNotNull(m.model.message("msg1")) } @@ -50,6 +54,7 @@ class MessageListTest : SessionControllerTestBase() { assertModelEvents(""" MessageAdded msg1 + TurnAdded msg1 [msg1] ContentAdded msg1/prt1 """, modelEvents) assertModel( @@ -90,7 +95,7 @@ class MessageListTest : SessionControllerTestBase() { emit(ChatEventDto.PartRemoved("ses_test", "msg1", "prt1")) assertNull(m.model.message("msg1")!!.parts["prt1"]) - assertModelEvents("ContentRemoved msg1/prt1", modelEvents) + assertModelEvents("ContentRemoved msg1/prt1", modelEvents) // no regroup for content ops } fun `test PartRemoved unknown part is noop`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressPanelTest.kt new file mode 100644 index 00000000000..2162a5df508 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressPanelTest.kt @@ -0,0 +1,88 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.Permission +import ai.kilocode.client.session.model.PermissionMeta +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.ProgressPanel +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +/** + * Verifies [ProgressPanel] show/hide behaviour driven by direct [SessionModel] + * state mutations — no controller or RPC involved. + */ +@Suppress("UnstableApiUsage") +class ProgressPanelTest : BasePlatformTestCase() { + + private lateinit var model: SessionModel + private lateinit var parent: Disposable + private lateinit var panel: ProgressPanel + + override fun setUp() { + super.setUp() + parent = Disposer.newDisposable("test") + model = SessionModel() + panel = ProgressPanel(model, parent) + } + + override fun tearDown() { + try { + Disposer.dispose(parent) + } finally { + super.tearDown() + } + } + + fun `test panel is hidden initially`() { + assertFalse(panel.isVisible) + } + + fun `test panel shows on Busy with text`() { + model.setState(SessionState.Busy("Thinking\u2026")) + + assertTrue(panel.isVisible) + assertEquals("Thinking\u2026", panel.labelText()) + } + + fun `test panel hides on Idle`() { + model.setState(SessionState.Busy("Thinking\u2026")) + model.setState(SessionState.Idle) + + assertFalse(panel.isVisible) + } + + fun `test panel shows updated text on second Busy`() { + model.setState(SessionState.Busy("Thinking\u2026")) + model.setState(SessionState.Busy("Writing response\u2026")) + + assertTrue(panel.isVisible) + assertEquals("Writing response\u2026", panel.labelText()) + } + + fun `test panel hides on Error state`() { + model.setState(SessionState.Busy("Thinking\u2026")) + model.setState(SessionState.Error("something went wrong")) + + assertFalse(panel.isVisible) + } + + fun `test panel hides on AwaitingPermission`() { + model.setState(SessionState.Busy("Thinking\u2026")) + model.setState(SessionState.AwaitingPermission(stub())) + + assertFalse(panel.isVisible) + } + + // ------ helpers ------ + + private fun stub() = Permission( + id = "perm1", + sessionId = "ses", + name = "edit", + patterns = emptyList(), + always = emptyList(), + meta = PermissionMeta(raw = emptyMap()), + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressTrackingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressTrackingTest.kt new file mode 100644 index 00000000000..f4db69ddd54 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ProgressTrackingTest.kt @@ -0,0 +1,160 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.MessageErrorDto + +/** + * Verifies that: + * 1. `step-start` and `step-finish` parts are silently dropped at the model level. + * 2. [SessionState.Busy.text] carries the right progress string throughout a turn. + * 3. [SessionModelEvent.StateChanged] fires with correct [SessionState.Busy] text + * as parts arrive. + * 4. Progress is cleared (state → Idle) on terminal events. + */ +class ProgressTrackingTest : SessionControllerTestBase() { + + // ------ silent part types ------ + + fun `test step-start part is silently dropped from model`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("p1", "ses_test", "msg1", "step-start"))) + + assertNull(m.model.message("msg1")!!.parts["p1"]) + assertFalse(modelEvents.any { it is SessionModelEvent.ContentAdded }) + } + + fun `test step-finish part is silently dropped from model`() { + val (m, _, modelEvents) = prompted() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("p2", "ses_test", "msg1", "step-finish"))) + + assertNull(m.model.message("msg1")!!.parts["p2"]) + assertFalse(modelEvents.any { it is SessionModelEvent.ContentAdded }) + } + + // ------ progress text per turn event ------ + + fun `test TurnOpen sets state to Busy with considering text`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + + assertSession( + """ + [code] [kilo/gpt-5] [busy] [considering next steps] + """, + m, + ) + } + + fun `test PartUpdated reasoning sets Busy text to thinking`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("p1", "ses_test", "msg1", "reasoning"))) + + assertSession( + """ + assistant#msg1 + reasoning#p1: + + + [code] [kilo/gpt-5] [busy] [thinking] + """, + m, + ) + } + + fun `test PartUpdated bash tool sets Busy text to running commands`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("p1", "ses_test", "msg1", "tool", tool = "bash"))) + + assertSession( + """ + assistant#msg1 + tool#p1 bash [PENDING] + + [code] [kilo/gpt-5] [busy] [running commands] + """, + m, + ) + } + + fun `test PartUpdated edit tool sets Busy text to making edits`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("p1", "ses_test", "msg1", "tool", tool = "edit"))) + + val state = m.model.state as SessionState.Busy + assertTrue(state.text.contains("edits", ignoreCase = true)) + } + + // ------ terminal events clear progress ------ + + fun `test TurnClose transitions to Idle`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.TurnClose("ses_test", "completed")) + + assertSession( + """ + [code] [kilo/gpt-5] [idle] + """, + m, + ) + } + + fun `test Error clears progress`() { + val (m, _, _) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.Error("ses_test", MessageErrorDto(type = "timeout", message = "Timed out"))) + + assertSession( + """ + [code] [kilo/gpt-5] [error] [Timed out] + """, + m, + ) + } + + // ------ StateChanged event stream ------ + + fun `test StateChanged Busy fires with considering text on TurnOpen`() { + val (_, _, modelEvents) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + + val busyEvents = modelEvents.filterIsInstance() + .filter { it.state is SessionState.Busy } + assertFalse(busyEvents.isEmpty()) + val text = (busyEvents.last().state as SessionState.Busy).text + assertTrue(text.contains("Considering", ignoreCase = true)) + } + + fun `test StateChanged Busy fires with updated text on PartUpdated`() { + val (_, _, modelEvents) = prompted() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("p1", "ses_test", "msg1", "tool", tool = "bash"))) + + val last = modelEvents.filterIsInstance() + .lastOrNull { it.state is SessionState.Busy } + assertNotNull(last) + val text = (last!!.state as SessionState.Busy).text + assertTrue(text.contains("Running", ignoreCase = true)) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt index d4410271665..8e79cec7dc9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionControllerTestBase.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.app.KiloAppService import ai.kilocode.client.app.KiloSessionService import ai.kilocode.client.session.model.SessionModel import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.testing.FakeAppRpcApi import ai.kilocode.client.testing.FakeWorkspaceRpcApi import ai.kilocode.client.testing.FakeSessionRpcApi @@ -27,6 +28,7 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.util.ui.UIUtil +import java.awt.event.HierarchyEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel @@ -41,6 +43,45 @@ import kotlinx.coroutines.runBlocking */ abstract class SessionControllerTestBase : BasePlatformTestCase() { + protected data class Snapshot( + val body: String, + val turns: String, + val state: SessionState, + val diff: List, + val todos: List, + val compacted: Int, + ) { + override fun toString(): String = buildString { + appendLine("state=$state") + appendLine("turns=$turns") + appendLine("diff=$diff") + appendLine("todos=$todos") + appendLine("compacted=$compacted") + append("body=\n$body") + } + } + + private class Root : javax.swing.JPanel() { + private var shown = true + override fun isShowing(): Boolean = shown + fun showState(show: Boolean) { + val prev = shown + shown = show + if (prev == show) return + val event = HierarchyEvent( + this, + HierarchyEvent.HIERARCHY_CHANGED, + this, + this.parent, + HierarchyEvent.SHOWING_CHANGED.toLong(), + ) + hierarchyListeners.forEach { it.hierarchyChanged(event) } + } + } + + private val controllers = mutableListOf() + private val roots = mutableMapOf() + protected lateinit var rpc: FakeSessionRpcApi protected lateinit var appRpc: FakeAppRpcApi protected lateinit var projectRpc: FakeWorkspaceRpcApi @@ -79,8 +120,27 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { // ------ Controller creation ------ - protected fun controller(id: String? = null) = - SessionController(parent, id, sessions, workspace, app, scope) + protected fun controller(id: String? = null) = controller(id, Long.MAX_VALUE) + + protected fun controller(id: String? = null, flushMs: Long): SessionController { + return controller(id, flushMs, true) + } + + protected fun controller(id: String? = null, flushMs: Long, condense: Boolean): SessionController { + val root = Root() + val m = SessionController(parent, id, sessions, workspace, app, scope, root, flushMs, condense) + controllers.add(m) + roots[m] = root + return m + } + + protected fun hide(m: SessionController) { + edt { (roots[m] ?: error("missing root")).showState(false) } + } + + protected fun show(m: SessionController) { + edt { (roots[m] ?: error("missing root")).showState(true) } + } // ------ Event collection ------ @@ -109,10 +169,19 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { // ------ EDT + coroutine helpers ------ - /** Let coroutines settle, then drain all pending EDT events. */ + /** Let coroutines settle without forcing buffered controller delivery. */ + protected fun settle() = runBlocking { + repeat(5) { + delay(100) + edt { UIUtil.dispatchAllInvocationEvents() } + } + } + + /** Let coroutines settle, force buffered controller delivery, then drain EDT. */ protected fun flush() = runBlocking { repeat(5) { delay(100) + controllers.forEach { it.flushEvents() } edt { UIUtil.dispatchAllInvocationEvents() } } } @@ -154,13 +223,24 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { } protected fun assertControllerEvents(expected: String, events: List) { - assertEquals(expected.trimIndent().trim(), events.joinToString("\n")) + val exp = expected.trimIndent().lines().map { it.trim() }.filter { it.isNotEmpty() }.sorted() + val act = events.map { it.toString() }.sorted() + assertEquals(exp.joinToString("\n"), act.joinToString("\n")) } protected fun assertModelEvents(expected: String, events: List) { assertEquals(expected.trimIndent().trim(), events.joinToString("\n")) } + protected fun snapshot(c: SessionController) = Snapshot( + body = c.model.toString().trim(), + turns = c.model.toTurnsString().trim(), + state = c.model.state, + diff = c.model.diff.toList(), + todos = c.model.todos.toList(), + compacted = c.model.compactionCount, + ) + // ------ DTO factories ------ protected fun msg(id: String, sid: String, role: String) = MessageDto( @@ -177,6 +257,8 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { type: String, text: String? = null, tool: String? = null, + state: String? = null, + title: String? = null, ) = PartDto( id = id, sessionID = sid, @@ -184,6 +266,8 @@ abstract class SessionControllerTestBase : BasePlatformTestCase() { type = type, text = text, tool = tool, + state = state, + title = title, ) protected fun workspaceReady( diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt new file mode 100644 index 00000000000..0e5cf03e171 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionQueueCondenserTest.kt @@ -0,0 +1,351 @@ +package ai.kilocode.client.session + +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.SessionStatusDto +import junit.framework.TestCase + +class SessionQueueCondenserTest : TestCase() { + + private val condenser = SessionQueueCondenser() + + private fun delta(msg: String, part: String, text: String) = + ChatEventDto.PartDelta("ses", msg, part, "text", text) + + private fun updated( + msg: String, + part: String, + type: String, + text: String? = null, + tool: String? = null, + state: String? = null, + title: String? = null, + ) = ChatEventDto.PartUpdated( + "ses", + PartDto(part, "ses", msg, type, text = text, tool = tool, state = state, title = title), + ) + + private fun nonDelta(msg: String) = + ChatEventDto.TurnOpen(msg) + + fun `test empty list returns empty`() { + assertEquals(emptyList(), condenser.condense(emptyList())) + } + + fun `test single event returned unchanged`() { + val event = delta("m1", "p1", "hi") + assertEquals(listOf(event), condenser.condense(listOf(event))) + } + + fun `test two deltas for same part are merged`() { + val result = condenser.condense(listOf( + delta("m1", "p1", "hello "), + delta("m1", "p1", "world"), + )) + assertEquals(1, result.size) + assertEquals("hello world", (result[0] as ChatEventDto.PartDelta).delta) + } + + fun `test many deltas for same part are all merged`() { + val result = condenser.condense(listOf( + delta("m1", "p1", "a"), + delta("m1", "p1", "b"), + delta("m1", "p1", "c"), + )) + assertEquals(1, result.size) + assertEquals("abc", (result[0] as ChatEventDto.PartDelta).delta) + } + + fun `test deltas for different parts are kept separate`() { + val result = condenser.condense(listOf( + delta("m1", "p1", "foo"), + delta("m1", "p2", "bar"), + )) + assertEquals(2, result.size) + assertEquals("foo", (result[0] as ChatEventDto.PartDelta).delta) + assertEquals("bar", (result[1] as ChatEventDto.PartDelta).delta) + } + + fun `test non-text field deltas are not merged`() { + val d1 = ChatEventDto.PartDelta("ses", "m1", "p1", "tool_call", "chunk1") + val d2 = ChatEventDto.PartDelta("ses", "m1", "p1", "tool_call", "chunk2") + val result = condenser.condense(listOf(d1, d2)) + assertEquals(2, result.size) + } + + fun `test non-delta event flushes pending deltas before it`() { + val barrier = nonDelta("turn1") + val result = condenser.condense(listOf( + delta("m1", "p1", "x"), + delta("m1", "p1", "y"), + barrier, + delta("m1", "p1", "z"), + )) + assertEquals(3, result.size) + assertEquals("xy", (result[0] as ChatEventDto.PartDelta).delta) + assertEquals(barrier, result[1]) + assertEquals("z", (result[2] as ChatEventDto.PartDelta).delta) + } + + fun `test deltas after barrier are merged independently`() { + val result = condenser.condense(listOf( + delta("m1", "p1", "a"), + nonDelta("t"), + delta("m1", "p1", "b"), + delta("m1", "p1", "c"), + )) + assertEquals(3, result.size) + assertEquals("a", (result[0] as ChatEventDto.PartDelta).delta) + assertEquals("bc", (result[2] as ChatEventDto.PartDelta).delta) + } + + fun `test deltas for different messages are kept separate`() { + val result = condenser.condense(listOf( + delta("m1", "p1", "hi"), + delta("m2", "p1", "there"), + )) + assertEquals(2, result.size) + assertEquals("hi", (result[0] as ChatEventDto.PartDelta).delta) + assertEquals("there", (result[1] as ChatEventDto.PartDelta).delta) + } + + fun `test merged delta uses session and part ids from last event`() { + val result = condenser.condense(listOf( + delta("m1", "p1", "first"), + delta("m1", "p1", "second"), + )) + val event = result.single() as ChatEventDto.PartDelta + assertEquals("ses", event.sessionID) + assertEquals("m1", event.messageID) + assertEquals("p1", event.partID) + } + + fun `test consecutive same part updates keep only latest snapshot`() { + val result = condenser.condense(listOf( + updated("m1", "p1", "tool", tool = "bash", state = "pending"), + updated("m1", "p1", "tool", tool = "bash", state = "completed", title = "Install deps"), + )) + + assertEquals(1, result.size) + val event = result.single() as ChatEventDto.PartUpdated + assertEquals("completed", event.part.state) + assertEquals("Install deps", event.part.title) + } + + fun `test consecutive text part updates keep final text`() { + val result = condenser.condense(listOf( + updated("m1", "p1", "text", text = "hel"), + updated("m1", "p1", "text", text = "hello"), + )) + + assertEquals(1, result.size) + val event = result.single() as ChatEventDto.PartUpdated + assertEquals("hello", event.part.text) + } + + fun `test part updates for different parts are kept separate`() { + val result = condenser.condense(listOf( + updated("m1", "p1", "tool", tool = "bash"), + updated("m1", "p2", "tool", tool = "edit"), + )) + + assertEquals(2, result.size) + assertEquals("p1", (result[0] as ChatEventDto.PartUpdated).part.id) + assertEquals("p2", (result[1] as ChatEventDto.PartUpdated).part.id) + } + + fun `test part updates for different messages are kept separate`() { + val result = condenser.condense(listOf( + updated("m1", "p1", "tool", tool = "bash"), + updated("m2", "p1", "tool", tool = "bash"), + )) + + assertEquals(2, result.size) + assertEquals("m1", (result[0] as ChatEventDto.PartUpdated).part.messageID) + assertEquals("m2", (result[1] as ChatEventDto.PartUpdated).part.messageID) + } + + fun `test barrier flushes pending part updates before it`() { + val barrier = nonDelta("turn1") + val result = condenser.condense(listOf( + updated("m1", "p1", "tool", tool = "bash", state = "pending"), + updated("m1", "p1", "tool", tool = "bash", state = "running"), + barrier, + updated("m1", "p1", "tool", tool = "bash", state = "completed"), + )) + + assertEquals(3, result.size) + assertEquals("running", (result[0] as ChatEventDto.PartUpdated).part.state) + assertEquals(barrier, result[1]) + assertEquals("completed", (result[2] as ChatEventDto.PartUpdated).part.state) + } + + fun `test delta acts as barrier for part updates`() { + val result = condenser.condense(listOf( + updated("m1", "p1", "text", text = "he"), + delta("m1", "p1", "l"), + updated("m1", "p1", "text", text = "hello"), + )) + + assertEquals(3, result.size) + assertEquals("he", (result[0] as ChatEventDto.PartUpdated).part.text) + assertEquals("l", (result[1] as ChatEventDto.PartDelta).delta) + assertEquals("hello", (result[2] as ChatEventDto.PartUpdated).part.text) + } + + fun `test merged part update matches latest payload exactly`() { + val first = updated("m1", "p1", "tool", tool = "bash", state = "pending") + val last = updated("m1", "p1", "tool", tool = "edit", state = "running", title = "Apply patch") + + val result = condenser.condense(listOf(first, last)) + + assertEquals(listOf(last), result) + } + + // ------ MessageUpdated coalescing ------ + + fun `test consecutive message updates for same id keep only latest`() { + val first = msgUpdated("m1", role = "assistant") + val last = msgUpdated("m1", role = "assistant", cost = 0.02) + + val result = condenser.condense(listOf(first, last)) + + assertEquals(1, result.size) + assertEquals(last, result[0]) + } + + fun `test message updates for different ids are kept separate`() { + val result = condenser.condense(listOf( + msgUpdated("m1"), + msgUpdated("m2"), + )) + + assertEquals(2, result.size) + assertEquals("m1", (result[0] as ChatEventDto.MessageUpdated).info.id) + assertEquals("m2", (result[1] as ChatEventDto.MessageUpdated).info.id) + } + + fun `test barrier flushes pending message updates before it`() { + val barrier = nonDelta("turn1") + val result = condenser.condense(listOf( + msgUpdated("m1"), + barrier, + msgUpdated("m1", cost = 0.05), + )) + + assertEquals(3, result.size) + assertNull((result[0] as ChatEventDto.MessageUpdated).info.cost) + assertEquals(barrier, result[1]) + assertEquals(0.05, (result[2] as ChatEventDto.MessageUpdated).info.cost) + } + + // ------ SessionStatusChanged coalescing ------ + + fun `test consecutive status changes keep only latest`() { + val busy = statusChanged("busy") + val idle = statusChanged("idle") + + val result = condenser.condense(listOf(busy, idle)) + + assertEquals(1, result.size) + assertEquals("idle", (result[0] as ChatEventDto.SessionStatusChanged).status.type) + } + + fun `test status changes for different sessions kept separate`() { + val result = condenser.condense(listOf( + ChatEventDto.SessionStatusChanged("ses1", SessionStatusDto("busy")), + ChatEventDto.SessionStatusChanged("ses2", SessionStatusDto("idle")), + )) + + assertEquals(2, result.size) + assertEquals("ses1", (result[0] as ChatEventDto.SessionStatusChanged).sessionID) + assertEquals("ses2", (result[1] as ChatEventDto.SessionStatusChanged).sessionID) + } + + fun `test barrier flushes pending status change before it`() { + val barrier = nonDelta("turn1") + val result = condenser.condense(listOf( + statusChanged("busy"), + barrier, + statusChanged("idle"), + )) + + assertEquals(3, result.size) + assertEquals("busy", (result[0] as ChatEventDto.SessionStatusChanged).status.type) + assertEquals(barrier, result[1]) + assertEquals("idle", (result[2] as ChatEventDto.SessionStatusChanged).status.type) + } + + // ------ SessionDiffChanged coalescing ------ + + fun `test consecutive diff changes keep only latest`() { + val first = ChatEventDto.SessionDiffChanged("ses", listOf(DiffFileDto("a.kt", 1, 0))) + val last = ChatEventDto.SessionDiffChanged("ses", listOf(DiffFileDto("b.kt", 2, 1))) + + val result = condenser.condense(listOf(first, last)) + + assertEquals(1, result.size) + assertEquals(last, result[0]) + } + + // ------ State-event / content-event drain ordering ------ + + fun `test mixed batch with two message updates same and status change is condensed`() { + val result = condenser.condense(listOf( + msgUpdated("m1"), + msgUpdated("m1", cost = 0.02), + statusChanged("busy"), + statusChanged("idle"), + ChatEventDto.SessionDiffChanged("ses", listOf(DiffFileDto("x.kt", 1, 0))), + )) + + // 2 MU → 1, 2 SSC → 1, 1 SDC → 1 = 3 total + assertEquals(3, result.size) + assertEquals(0.02, (result[0] as ChatEventDto.MessageUpdated).info.cost) + assertEquals("idle", (result[1] as ChatEventDto.SessionStatusChanged).status.type) + assertTrue(result[2] is ChatEventDto.SessionDiffChanged) + } + + fun `test message update is emitted before part update for same message`() { + // Server always sends MessageUpdated before PartUpdated for a new message. + // Condensing must preserve that semantic ordering. + val result = condenser.condense(listOf( + msgUpdated("m1"), + updated("m1", "p1", "text", text = "hello"), + )) + + assertEquals(2, result.size) + assertTrue(result[0] is ChatEventDto.MessageUpdated) + assertTrue(result[1] is ChatEventDto.PartUpdated) + } + + fun `test part updates for same part coalesce across interleaved message update`() { + // Both PUs are for the same part but separated by a MU. + // MU drains and flushes the first PU, so they do NOT merge. + val result = condenser.condense(listOf( + updated("m1", "p1", "tool", state = "running"), + msgUpdated("m1", cost = 0.01), + updated("m1", "p1", "tool", state = "completed"), + )) + + // running is flushed when MU arrives, completed is a new batch → cannot merge + assertEquals(3, result.size) + assertEquals("running", (result[0] as ChatEventDto.PartUpdated).part.state) + assertNotNull(result[1] as? ChatEventDto.MessageUpdated) + assertEquals("completed", (result[2] as ChatEventDto.PartUpdated).part.state) + } + + // ------ helpers ------ + + private fun msgUpdated(id: String, role: String = "assistant", cost: Double? = null) = + ChatEventDto.MessageUpdated( + "ses", + MessageDto(id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0), cost = cost), + ) + + private fun statusChanged(type: String) = + ChatEventDto.SessionStatusChanged("ses", SessionStatusDto(type)) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt new file mode 100644 index 00000000000..343271cdf7f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUiUpdateTest.kt @@ -0,0 +1,204 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.client.session.ui.SessionPanel +import ai.kilocode.client.session.views.TextView +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PartDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +/** + * Integration test: mutate [SessionModel] directly on the EDT and verify + * that [SessionPanel] reflects the changes without any end-to-end RPC flow. + * + * This tests the full model → event → view update pipeline in isolation. + */ +@Suppress("UnstableApiUsage") +class SessionUiUpdateTest : BasePlatformTestCase() { + + private lateinit var model: SessionModel + private lateinit var parent: Disposable + private lateinit var panel: SessionPanel + + override fun setUp() { + super.setUp() + parent = Disposer.newDisposable("test") + model = SessionModel() + panel = SessionPanel(model, parent) + } + + override fun tearDown() { + try { + Disposer.dispose(parent) + } finally { + super.tearDown() + } + } + + // ------ streaming text ------ + + fun `test streaming text delta is reflected in panel`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "hello ")) + model.appendDelta("a1", "p1", "world") + + val tv = panel.findMessage("a1")!!.part("p1") as TextView + assertEquals("hello world", tv.markdown()) + } + + fun `test multiple deltas build up content correctly`() { + model.upsertMessage(msg("a1", "assistant")) + for (token in listOf("**T", "ok", "en**", " stream")) { + model.appendDelta("a1", "p1", token) + } + + val tv = panel.findMessage("a1")!!.part("p1") as TextView + assertEquals("**Token** stream", tv.markdown()) + assertTrue(tv.md.html().contains("")) + } + + // ------ full message lifecycle ------ + + fun `test message added then parts then message updated`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "initial")) + + // Second MessageUpdated (e.g. token/cost meta) should not lose parts + model.upsertMessage(msg("a1", "assistant")) + + val mv = panel.findMessage("a1")!! + assertEquals(listOf("p1"), mv.partIds()) + } + + // ------ tool lifecycle ------ + + fun `test tool state transitions are reflected in ToolView`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", toolPart("t1", "a1", "bash", "pending")) + model.updateContent("a1", toolPart("t1", "a1", "bash", "running")) + model.updateContent("a1", toolPart("t1", "a1", "bash", "completed")) + + val tv = panel.findMessage("a1")!!.part("t1") as ai.kilocode.client.session.views.ToolView + assertTrue(tv.labelText().contains("\u2713")) // ✓ completed + } + + // ------ multiple turns update correctly ------ + + fun `test content goes to correct turn when multiple turns exist`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.upsertMessage(msg("u2", "user")) + model.upsertMessage(msg("a2", "assistant")) + + model.updateContent("a1", part("p1", "a1", "text", text = "turn1")) + model.updateContent("a2", part("p2", "a2", "text", text = "turn2")) + + val t1text = (panel.findMessage("a1")!!.part("p1") as TextView).markdown() + val t2text = (panel.findMessage("a2")!!.part("p2") as TextView).markdown() + + assertEquals("turn1", t1text) + assertEquals("turn2", t2text) + } + + // ------ compaction marker ------ + + fun `test compaction part appears in panel`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", PartDto("cp1", "ses", "a1", "compaction")) + + val mv = panel.findMessage("a1")!! + assertEquals(listOf("cp1"), mv.partIds()) + assertTrue(mv.part("cp1") is ai.kilocode.client.session.views.CompactionView) + } + + // ------ generic fallback ------ + + fun `test unknown part type falls back to GenericView`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", PartDto("g1", "ses", "a1", "snapshot")) + + val mv = panel.findMessage("a1")!! + val gv = mv.part("g1") + assertNotNull(gv) + assertTrue(gv is ai.kilocode.client.session.views.GenericView) + assertTrue((gv as ai.kilocode.client.session.views.GenericView).labelText().contains("snapshot")) + } + + // ------ silent part types ------ + + fun `test step-start part is not rendered in panel`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", PartDto("g1", "ses", "a1", "step-start")) + model.updateContent("a1", PartDto("g2", "ses", "a1", "step-finish")) + + val mv = panel.findMessage("a1")!! + assertTrue(mv.partIds().isEmpty()) + } + + // ------ history load ------ + + fun `test loadHistory populates panel with correct turn structure and content`() { + model.loadHistory(listOf( + MessageWithPartsDto( + msg("u1", "user"), + listOf(part("pu1", "u1", "text", text = "can you help")), + ), + MessageWithPartsDto( + msg("a1", "assistant"), + listOf(part("pa1", "a1", "text", text = "sure")), + ), + )) + + assertEquals(1, panel.turnCount()) + assertEquals("turn#u1: user#u1, assistant#a1", panel.dump()) + + val userText = (panel.findMessage("u1")!!.part("pu1") as TextView).markdown() + val assistantText = (panel.findMessage("a1")!!.part("pa1") as TextView).markdown() + assertEquals("can you help", userText) + assertEquals("sure", assistantText) + } + + // ------ regrouping triggered by remove ------ + + fun `test removing user anchor updates panel structure`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.upsertMessage(msg("u2", "user")) + + model.removeMessage("u1") + + // a1 now forms its own standalone turn; u2 still has its own + assertEquals(2, panel.turnCount()) + assertNotNull(panel.findMessage("a1")) + assertEquals("turn#a1: assistant#a1", panel.findTurn("a1")?.let { "turn#${it.id}: ${it.dump()}" }) + } + + // ------ idle state hides docks ------ + + fun `test StateChanged to Idle is handled without crash`() { + model.setState(SessionState.Busy("thinking")) + model.setState(SessionState.Idle) + // SessionPanel itself does not render state — just ensure no exception + assertTrue(true) + } + + // ------ helpers ------ + + private fun msg(id: String, role: String) = MessageDto( + id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0), + ) + + private fun part(id: String, mid: String, type: String, text: String? = null) = PartDto( + id = id, sessionID = "ses", messageID = mid, type = type, text = text, + ) + + private fun toolPart(id: String, mid: String, tool: String, state: String) = PartDto( + id = id, sessionID = "ses", messageID = mid, type = "tool", tool = tool, state = state, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt new file mode 100644 index 00000000000..04dc2ad4f3e --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/SessionUpdateQueueTest.kt @@ -0,0 +1,354 @@ +package ai.kilocode.client.session + +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import ai.kilocode.client.session.model.SessionModelEvent +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.SessionStatusDto +import ai.kilocode.rpc.dto.TodoDto + +class SessionUpdateQueueTest : SessionControllerTestBase() { + + fun `test hidden controller buffers until shown`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = 250L) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + hide(m) + emit(ChatEventDto.TurnOpen("ses_test"), flush = false) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false) + settle() + + assertTrue(modelEvents.isEmpty()) + assertEquals(SessionState.Idle, m.model.state) + + show(m) + settle() + + assertModelEvents(""" + StateChanged Busy + MessageAdded msg1 + TurnAdded msg1 [msg1] + """, modelEvents) + assertTrue(m.model.state is SessionState.Busy) + } + + fun `test hidden controller condenses while hidden but does not flush`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = 250L) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + hide(m) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false) + repeat(4) { i -> + emit(ChatEventDto.PartDelta("ses_test", "msg1", "txt1", "text", " chunk$i"), flush = false) + } + emit(ChatEventDto.PartUpdated("ses_test", part("tool1", "ses_test", "msg1", "tool", tool = "bash", state = "running")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("tool1", "ses_test", "msg1", "tool", tool = "bash", state = "completed", title = "Run build")), flush = false) + settle() + + assertTrue(modelEvents.isEmpty()) + assertEquals(SessionState.Idle, m.model.state) + + show(m) + settle() + + assertModelEvents(""" + MessageAdded msg1 + TurnAdded msg1 [msg1] + ContentAdded msg1/txt1 + ContentDelta msg1/txt1 + ContentAdded msg1/tool1 + """, modelEvents) + assertModel( + """ + assistant#msg1 + text#txt1: + chunk0 chunk1 chunk2 chunk3 + tool#tool1 bash [COMPLETED] Run build + """, + m, + ) + } + + fun `test hidden cadence does not flush until shown`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = 50L) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + hide(m) + emit(ChatEventDto.TurnOpen("ses_test"), flush = false) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false) + settle() + + assertTrue(modelEvents.isEmpty()) + assertEquals(SessionState.Idle, m.model.state) + + show(m) + settle() + + assertModelEvents(""" + StateChanged Busy + MessageAdded msg1 + TurnAdded msg1 [msg1] + """, modelEvents) + } + + fun `test hidden controller flushes on show without new event`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = 250L) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + hide(m) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant")), flush = false) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "txt1", "text", "hello "), flush = false) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "txt1", "text", "world"), flush = false) + settle() + + assertTrue(modelEvents.isEmpty()) + + show(m) + settle() + + assertModelEvents(""" + MessageAdded msg1 + TurnAdded msg1 [msg1] + ContentAdded msg1/txt1 + ContentDelta msg1/txt1 + """, modelEvents) + assertModel( + """ + assistant#msg1 + text#txt1: + hello world + """, + m, + ) + } + + fun `test buffered deltas coalesce into one model delta`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + modelEvents.clear() + + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "hello "), flush = false) + emit(ChatEventDto.PartDelta("ses_test", "msg1", "prt1", "text", "world"), flush = false) + settle() + flush() + + assertEquals(1, modelEvents.count { it is SessionModelEvent.ContentAdded }) + val delta = modelEvents.filterIsInstance() + assertEquals(1, delta.size) + assertModel( + """ + assistant#msg1 + text#prt1: + hello world + """, + m, + ) + assertEquals(listOf("hello world"), delta.map { it.delta }) + } + + fun `test visible controller flushes after cadence`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = 50L) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + emit(ChatEventDto.TurnOpen("ses_test"), flush = false) + flush() + + assertTrue(modelEvents.any { it is SessionModelEvent.StateChanged }) + assertTrue(m.model.state is SessionState.Busy) + } + + fun `test buffered part updates for new part collapse to one content add`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + modelEvents.clear() + + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "pending")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "completed")), flush = false) + settle() + flush() + + assertEquals(1, modelEvents.count { it is SessionModelEvent.ContentAdded }) + assertEquals(0, modelEvents.count { it is SessionModelEvent.ContentUpdated }) + val tool = m.model.message("msg1")!!.parts["prt1"] as Tool + assertEquals(ToolExecState.COMPLETED, tool.state) + } + + fun `test buffered part updates for existing part collapse to one content update`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "pending"))) + modelEvents.clear() + + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "running")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "completed", title = "Install deps")), flush = false) + settle() + flush() + + assertEquals(0, modelEvents.count { it is SessionModelEvent.ContentAdded }) + assertEquals(1, modelEvents.count { it is SessionModelEvent.ContentUpdated }) + val tool = m.model.message("msg1")!!.parts["prt1"] as Tool + assertEquals(ToolExecState.COMPLETED, tool.state) + assertEquals("Install deps", tool.title) + } + + fun `test buffered same part tool updates keep only final busy text`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + emit(ChatEventDto.TurnOpen("ses_test")) + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + modelEvents.clear() + + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "read", state = "running")), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "running")), flush = false) + settle() + flush() + + val busy = modelEvents.filterIsInstance() + .filter { it.state is SessionState.Busy } + assertEquals(1, busy.size) + val state = busy.single().state as SessionState.Busy + assertTrue(state.text.contains("commands", ignoreCase = true)) + } + + fun `test barrier prevents part update merge across turn close`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test", flushMs = Long.MAX_VALUE) + val modelEvents = collectModelEvents(m) + flush() + modelEvents.clear() + + emit(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + emit(ChatEventDto.TurnOpen("ses_test")) + modelEvents.clear() + + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "running")), flush = false) + emit(ChatEventDto.TurnClose("ses_test", "completed"), flush = false) + emit(ChatEventDto.PartUpdated("ses_test", part("prt1", "ses_test", "msg1", "tool", tool = "bash", state = "completed")), flush = false) + settle() + flush() + + assertModelEvents(""" + ContentAdded msg1/prt1 + StateChanged Busy + StateChanged Idle + ContentUpdated msg1/prt1 + """, modelEvents) + assertEquals(SessionState.Idle, m.model.state) + } + + fun `test condensed and raw controller end with same final state on large corpus`() { + appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + + val events = corpus() + val condensed = runCorpus(events, true) + val raw = runCorpus(events, false) + val a = snapshot(condensed) + val b = snapshot(raw) + + if (a != b) fail("condensed=\n$a\nraw=\n$b") + assertEquals(SessionState.Idle, a.state) + assertTrue(a.body.contains("assistant#msg1")) + assertTrue(a.body.contains("assistant#msg2")) + assertTrue(a.body.contains("diff: src/A.kt src/B.kt")) + assertTrue(a.body.contains("todo: [completed] ship feature")) + assertEquals(4, a.compacted) + } + + private fun corpus(): List = buildList { + add(ChatEventDto.TurnOpen("ses_test")) + add(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant"))) + add(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant").copy(cost = 0.01))) + add(ChatEventDto.MessageUpdated("ses_test", msg("msg1", "ses_test", "assistant").copy(cost = 0.02))) + add(ChatEventDto.PartUpdated("ses_test", part("tool1", "ses_test", "msg1", "tool", tool = "read", state = "running"))) + add(ChatEventDto.PartUpdated("ses_test", part("tool1", "ses_test", "msg1", "tool", tool = "read", state = "running", title = "Read files"))) + add(ChatEventDto.PartUpdated("ses_test", part("tool1", "ses_test", "msg1", "tool", tool = "read", state = "completed", title = "Read files"))) + add(ChatEventDto.PartUpdated("ses_test", part("snap1", "ses_test", "msg1", "text", text = "he"))) + repeat(8) { i -> + add(ChatEventDto.PartDelta("ses_test", "msg1", "txt1", "text", " chunk$i")) + } + add(ChatEventDto.PartUpdated("ses_test", part("snap1", "ses_test", "msg1", "text", text = "hello"))) + add(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("busy"))) + add(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("retry", message = "retrying", attempt = 2, next = 10L))) + add(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("offline", message = "offline", requestID = "req1"))) + add(ChatEventDto.SessionStatusChanged("ses_test", SessionStatusDto("idle"))) + add(ChatEventDto.SessionDiffChanged("ses_test", listOf(DiffFileDto("src/A.kt", 1, 0)))) + add(ChatEventDto.SessionDiffChanged("ses_test", emptyList())) + add(ChatEventDto.SessionDiffChanged("ses_test", listOf(DiffFileDto("src/A.kt", 2, 1), DiffFileDto("src/B.kt", 4, 0)))) + add(ChatEventDto.TodoUpdated("ses_test", listOf(TodoDto("draft plan", "in_progress", "high")))) + add(ChatEventDto.TodoUpdated("ses_test", listOf(TodoDto("ship feature", "completed", "high")))) + add(ChatEventDto.SessionCompacted("ses_test")) + add(ChatEventDto.MessageUpdated("ses_test", msg("msg2", "ses_test", "assistant"))) + add(ChatEventDto.MessageUpdated("ses_test", msg("msg2", "ses_test", "assistant").copy(cost = 0.02))) + add(ChatEventDto.PartUpdated("ses_test", part("tool2", "ses_test", "msg2", "tool", tool = "edit", state = "running"))) + add(ChatEventDto.PartUpdated("ses_test", part("tool2", "ses_test", "msg2", "tool", tool = "edit", state = "completed", title = "Patch file"))) + repeat(6) { i -> + add(ChatEventDto.PartDelta("ses_test", "msg2", "txt2", "text", " body$i")) + } + add(ChatEventDto.TurnClose("ses_test", "completed")) + add(ChatEventDto.TurnOpen("ses_test")) + add(ChatEventDto.MessageUpdated("ses_test", msg("msg3", "ses_test", "assistant"))) + add(ChatEventDto.MessageUpdated("ses_test", msg("msg3", "ses_test", "assistant").copy(cost = 0.03))) + add(ChatEventDto.PartUpdated("ses_test", part("tail", "ses_test", "msg3", "text", text = "tail start"))) + repeat(5) { i -> + add(ChatEventDto.PartDelta("ses_test", "msg3", "tail", "text", " extra$i")) + } + add(ChatEventDto.SessionCompacted("ses_test")) + add(ChatEventDto.SessionIdle("ses_test")) + } + + private fun runCorpus(events: List, condense: Boolean): SessionController { + val m = controller("ses_test", flushMs = Long.MAX_VALUE, condense = condense) + flush() + for (event in events) emit(event, flush = false) + settle() + flush() + return m + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt index d82f05b2f2a..98719ec3c36 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/StatusComputationTest.kt @@ -35,6 +35,7 @@ class StatusComputationTest : SessionControllerTestBase() { assertModelEvents(""" MessageAdded msg1 + TurnAdded msg1 [msg1] StateChanged Busy StateChanged Idle ContentAdded msg1/prt1 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt index 50cad2007ce..be261a08fbe 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/SessionModelTest.kt @@ -51,14 +51,16 @@ class SessionModelTest : UsefulTestCase() { assertTrue(model.isReady()) } - fun `test addMessage stores entry and fires MessageAdded`() { + fun `test addMessage stores entry and fires MessageAdded then TurnAdded`() { model.addMessage(msg("m1", "user")) val item = model.message("m1") assertNotNull(item) - assertEquals(1, events.size) + // MessageAdded then TurnAdded (regroup fires immediately after) + assertEquals(2, events.size) val event = events[0] as SessionModelEvent.MessageAdded assertEquals("m1", event.info.info.id) + assertTrue(events[1] is SessionModelEvent.TurnAdded) } fun `test addMessage duplicate is ignored`() { @@ -71,15 +73,17 @@ class SessionModelTest : UsefulTestCase() { assertTrue(events.isEmpty()) } - fun `test removeMessage removes entry and fires MessageRemoved`() { + fun `test removeMessage removes entry and fires MessageRemoved then TurnRemoved`() { model.addMessage(msg("m1", "assistant")) events.clear() model.removeMessage("m1") assertNull(model.message("m1")) - assertEquals(1, events.size) + // MessageRemoved + TurnRemoved after regroup + assertEquals(2, events.size) assertEquals("m1", (events[0] as SessionModelEvent.MessageRemoved).id) + assertEquals("m1", (events[1] as SessionModelEvent.TurnRemoved).id) } fun `test removeMessage unknown id is noop`() { @@ -152,15 +156,35 @@ class SessionModelTest : UsefulTestCase() { assertTrue(model.message("m1")!!.parts["p1"] is Compaction) } - fun `test updateContent unknown type stored as Generic`() { + fun `test updateContent silently drops step-start parts`() { model.addMessage(msg("m1", "assistant")) events.clear() model.updateContent("m1", part("p1", "m1", "step-start")) + assertNull(model.message("m1")!!.parts["p1"]) + assertTrue(events.isEmpty()) + } + + fun `test updateContent silently drops step-finish parts`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "step-finish")) + + assertNull(model.message("m1")!!.parts["p1"]) + assertTrue(events.isEmpty()) + } + + fun `test updateContent unknown type stored as Generic`() { + model.addMessage(msg("m1", "assistant")) + events.clear() + + model.updateContent("m1", part("p1", "m1", "snapshot")) + val p = model.message("m1")!!.parts["p1"] assertTrue("Expected Generic fallback but got: ${p?.javaClass?.simpleName}", p is Generic) - assertEquals("step-start", (p as Generic).type) + assertEquals("snapshot", (p as Generic).type) assertTrue(events.single() is SessionModelEvent.ContentAdded) } @@ -351,13 +375,27 @@ class SessionModelTest : UsefulTestCase() { assertEquals("snapshot", (entry.parts["p2"] as Generic).type) } + fun `test loadHistory silently drops step-start and step-finish parts`() { + val text = PartDto(id = "p1", sessionID = "s1", messageID = "m1", type = "text", text = "visible") + val stepStart = PartDto(id = "p2", sessionID = "s1", messageID = "m1", type = "step-start") + val stepFinish = PartDto(id = "p3", sessionID = "s1", messageID = "m1", type = "step-finish") + + model.loadHistory(listOf(MessageWithPartsDto(msg("m1", "assistant"), listOf(text, stepStart, stepFinish)))) + + val entry = model.message("m1")!! + assertEquals(listOf("p1"), entry.parts.keys.toList()) + } + fun `test upsertMessage adds new message and returns true`() { val added = model.upsertMessage(msg("m1", "user")) assertTrue(added) assertNotNull(model.message("m1")) - val event = events.single() as SessionModelEvent.MessageAdded + // upsertMessage fires MessageAdded then TurnAdded + assertEquals(2, events.size) + val event = events.filterIsInstance().single() assertEquals("m1", event.info.info.id) + assertTrue(events.any { it is SessionModelEvent.TurnAdded }) } fun `test upsertMessage updates existing message and returns false`() { @@ -514,7 +552,7 @@ class SessionModelTest : UsefulTestCase() { model.addListener(child) { extra.add(it) } model.addMessage(msg("m1", "user")) - assertEquals(1, extra.size) + assertEquals(2, extra.size) // MessageAdded + TurnAdded Disposer.dispose(child) extra.clear() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnGroupingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnGroupingTest.kt new file mode 100644 index 00000000000..6c4bf72981c --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/model/TurnGroupingTest.kt @@ -0,0 +1,364 @@ +package ai.kilocode.client.session.model + +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PartDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.UsefulTestCase + +/** + * Hard model-level tests for turn grouping inside [SessionModel]. + * + * Every test that adds/removes messages also asserts both the derived + * turn structure ([assertTurns]) and the exact event stream + * ([assertTurnEvents] / [assertAllEvents]). + * + * Naming convention for message ids: u1, u2, u3 = user messages + * a1, a2, a3 = assistant messages + */ +class TurnGroupingTest : UsefulTestCase() { + + private lateinit var model: SessionModel + private lateinit var parent: Disposable + private lateinit var events: MutableList + + override fun setUp() { + super.setUp() + parent = Disposer.newDisposable("test") + model = SessionModel() + events = mutableListOf() + model.addListener(parent) { events.add(it) } + } + + override fun tearDown() { + try { + Disposer.dispose(parent) + } finally { + super.tearDown() + } + } + + // ------ empty model ------ + + fun `test empty model has no turns`() { + assertTurns("(no turns)") + assertTrue(model.turns().isEmpty()) + } + + // ------ single messages ------ + + fun `test adding user message creates turn and fires TurnAdded`() { + model.upsertMessage(msg("u1", "user")) + + assertTurns("turn#u1: user#u1") + assertTurnEvents("TurnAdded u1 [u1]") + } + + fun `test adding assistant message before any user message creates standalone turn`() { + model.upsertMessage(msg("a1", "assistant")) + + assertTurns("turn#a1: assistant#a1") + assertTurnEvents("TurnAdded a1 [a1]") + } + + // ------ building up a turn ------ + + fun `test assistant message after user message is added to user's turn`() { + model.upsertMessage(msg("u1", "user")) + events.clear() + + model.upsertMessage(msg("a1", "assistant")) + + assertTurns("turn#u1: user#u1, assistant#a1") + assertTurnEvents("TurnUpdated u1 [u1, a1]") + } + + fun `test multiple assistant messages appended to same turn`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + events.clear() + + model.upsertMessage(msg("a2", "assistant")) + + assertTurns("turn#u1: user#u1, assistant#a1, assistant#a2") + assertTurnEvents("TurnUpdated u1 [u1, a1, a2]") + } + + // ------ multiple turns ------ + + fun `test second user message starts a new turn`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + events.clear() + + model.upsertMessage(msg("u2", "user")) + + assertTurns(""" + turn#u1: user#u1, assistant#a1 + turn#u2: user#u2 + """) + assertTurnEvents("TurnAdded u2 [u2]") + } + + fun `test two complete turns`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.upsertMessage(msg("u2", "user")) + model.upsertMessage(msg("a2", "assistant")) + + assertTurns(""" + turn#u1: user#u1, assistant#a1 + turn#u2: user#u2, assistant#a2 + """) + assertEquals(2, model.turns().size) + } + + fun `test leading assistant messages followed by user message splits into two turns`() { + model.upsertMessage(msg("a1", "assistant")) + model.upsertMessage(msg("a2", "assistant")) + events.clear() + + model.upsertMessage(msg("u1", "user")) + + assertTurns(""" + turn#a1: assistant#a1, assistant#a2 + turn#u1: user#u1 + """) + assertTurnEvents("TurnAdded u1 [u1]") + } + + // ------ removals ------ + + fun `test removing assistant from single-message turn removes the turn`() { + model.upsertMessage(msg("a1", "assistant")) + events.clear() + + model.removeMessage("a1") + + assertTurns("(no turns)") + assertTurnEvents("TurnRemoved a1") + } + + fun `test removing user anchor from a two-message turn creates standalone assistant turn`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + events.clear() + + model.removeMessage("u1") + + assertTurns("turn#a1: assistant#a1") + // Old turn u1 is gone; new standalone turn a1 appears + assertTurnEvents(""" + TurnRemoved u1 + TurnAdded a1 [a1] + """) + } + + fun `test removing assistant from two-message turn updates turn`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + events.clear() + + model.removeMessage("a1") + + assertTurns("turn#u1: user#u1") + assertTurnEvents("TurnUpdated u1 [u1]") + } + + fun `test removing middle user message merges turns`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.upsertMessage(msg("u2", "user")) + model.upsertMessage(msg("a2", "assistant")) + events.clear() + + model.removeMessage("u2") + + // a2 falls into u1's turn; u2's turn disappears + assertTurns("turn#u1: user#u1, assistant#a1, assistant#a2") + assertTurnEvents(""" + TurnRemoved u2 + TurnUpdated u1 [u1, a1, a2] + """) + } + + fun `test removing user anchor when another user follows causes regrouping`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.upsertMessage(msg("u2", "user")) + events.clear() + + model.removeMessage("u1") + + // a1 is now the leading assistant; it anchors its own standalone turn. + // u2 still anchors its own turn. + assertTurns(""" + turn#a1: assistant#a1 + turn#u2: user#u2 + """) + assertTurnEvents(""" + TurnRemoved u1 + TurnAdded a1 [a1] + """) + } + + // ------ deprecated addMessage ------ + + fun `test deprecated addMessage also triggers regrouping`() { + model.addMessage(msg("u1", "user")) + model.addMessage(msg("a1", "assistant")) + + assertTurns("turn#u1: user#u1, assistant#a1") + } + + fun `test deprecated addMessage ignores duplicate`() { + model.addMessage(msg("u1", "user")) + events.clear() + + model.addMessage(msg("u1", "user")) + + // Duplicate ignored — no new turn event + assertTrue(turnEvents().isEmpty()) + assertEquals(1, model.turns().size) + } + + // ------ loadHistory ------ + + fun `test loadHistory populates turns silently then fires HistoryLoaded`() { + model.loadHistory(listOf( + withParts(msg("u1", "user")), + withParts(msg("a1", "assistant")), + withParts(msg("u2", "user")), + withParts(msg("a2", "assistant")), + )) + + assertTurns(""" + turn#u1: user#u1, assistant#a1 + turn#u2: user#u2, assistant#a2 + """) + // HistoryLoaded is the only event — no per-turn events + assertEquals(1, events.size) + assertTrue(events.single() is SessionModelEvent.HistoryLoaded) + } + + fun `test loadHistory with leading assistant messages groups them correctly`() { + model.loadHistory(listOf( + withParts(msg("a1", "assistant")), + withParts(msg("a2", "assistant")), + withParts(msg("u1", "user")), + )) + + assertTurns(""" + turn#a1: assistant#a1, assistant#a2 + turn#u1: user#u1 + """) + assertEquals(1, events.size) // only HistoryLoaded + } + + fun `test loadHistory clears previous turns`() { + model.upsertMessage(msg("u1", "user")) + events.clear() + + model.loadHistory(listOf(withParts(msg("u2", "user")))) + + assertTurns("turn#u2: user#u2") + assertEquals(1, events.size) // only HistoryLoaded + } + + fun `test loadHistory with empty list produces no turns`() { + model.upsertMessage(msg("u1", "user")) + events.clear() + + model.loadHistory(emptyList()) + + assertTurns("(no turns)") + assertEquals(1, events.size) // only HistoryLoaded + } + + // ------ clear ------ + + fun `test clear removes all turns silently`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + events.clear() + + model.clear() + + assertTurns("(no turns)") + // Cleared is the only event — no per-turn events + assertEquals(1, events.size) + assertTrue(events.single() is SessionModelEvent.Cleared) + } + + // ------ idempotency ------ + + fun `test no TurnUpdated when same assistant arrives in same turn twice`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + events.clear() + + // Same assistant message arrives again (e.g. metadata update) + // upsertMessage update path: fires MessageUpdated but no regroup + model.upsertMessage(msg("a1", "assistant")) + + assertTrue(turnEvents().isEmpty()) + } + + // ------ turn lookup ------ + + fun `test turn() retrieves by id`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + + val t = model.turn("u1") + assertNotNull(t) + assertEquals(listOf("u1", "a1"), t!!.messageIds) + } + + fun `test turns() preserves insertion order`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.upsertMessage(msg("u2", "user")) + model.upsertMessage(msg("a2", "assistant")) + + val ids = model.turns().map { it.id } + assertEquals(listOf("u1", "u2"), ids) + } + + // ------ helpers ------ + + private fun msg(id: String, role: String) = MessageDto( + id = id, + sessionID = "ses", + role = role, + time = MessageTimeDto(created = 0.0), + ) + + private fun withParts(dto: MessageDto) = MessageWithPartsDto(info = dto, parts = emptyList()) + + private fun part(id: String, mid: String, type: String) = PartDto( + id = id, sessionID = "ses", messageID = mid, type = type, + ) + + // Turn-specific event helpers + + private fun turnEvents(): List = events.filter { + it is SessionModelEvent.TurnAdded || + it is SessionModelEvent.TurnUpdated || + it is SessionModelEvent.TurnRemoved + } + + private fun assertTurns(expected: String) { + assertEquals(expected.trimIndent().trim(), model.toTurnsString().trim()) + } + + private fun assertTurnEvents(expected: String) { + assertEquals(expected.trimIndent().trim(), turnEvents().joinToString("\n")) + } + + private fun assertAllEvents(expected: String) { + assertEquals(expected.trimIndent().trim(), events.joinToString("\n")) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt new file mode 100644 index 00000000000..9f16b995293 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/QuestionPanelTest.kt @@ -0,0 +1,89 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.app.KiloAppService +import ai.kilocode.client.app.KiloSessionService +import ai.kilocode.client.app.KiloWorkspaceService +import ai.kilocode.client.app.Workspace +import ai.kilocode.client.session.SessionController +import ai.kilocode.client.session.model.Question +import ai.kilocode.client.session.model.QuestionItem +import ai.kilocode.client.session.model.QuestionOption +import ai.kilocode.client.testing.FakeAppRpcApi +import ai.kilocode.client.testing.FakeSessionRpcApi +import ai.kilocode.client.testing.FakeWorkspaceRpcApi +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.KiloWorkspaceStateDto +import ai.kilocode.rpc.dto.KiloWorkspaceStatusDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import javax.swing.JPanel + +@Suppress("UnstableApiUsage") +class QuestionPanelTest : BasePlatformTestCase() { + + private lateinit var parent: Disposable + private lateinit var scope: CoroutineScope + private lateinit var rpc: FakeSessionRpcApi + private lateinit var app: KiloAppService + private lateinit var workspaces: KiloWorkspaceService + private lateinit var workspace: Workspace + private lateinit var controller: SessionController + private lateinit var panel: QuestionPanel + + override fun setUp() { + super.setUp() + parent = Disposer.newDisposable("question-panel") + scope = CoroutineScope(SupervisorJob()) + rpc = FakeSessionRpcApi() + val sessions = KiloSessionService(project, scope, rpc) + val appRpc = FakeAppRpcApi().also { it.state.value = KiloAppStateDto(KiloAppStatusDto.READY) } + val workspaceRpc = FakeWorkspaceRpcApi().also { + it.state.value = KiloWorkspaceStateDto(status = KiloWorkspaceStatusDto.READY) + } + app = KiloAppService(scope, appRpc) + workspaces = KiloWorkspaceService(scope, workspaceRpc) + workspace = workspaces.workspace("/test") + val root = JPanel() + controller = SessionController(parent, "ses_test", sessions, workspace, app, scope, root) + panel = QuestionPanel(controller) + } + + override fun tearDown() { + try { + Disposer.dispose(parent) + scope.cancel() + } finally { + super.tearDown() + } + } + + fun `test empty question hides panel and clears stale request id`() { + panel.show( + Question( + id = "req_old", + items = listOf( + QuestionItem( + question = "Pick one", + header = "Header", + options = listOf(QuestionOption("Yes", "desc")), + multiple = false, + custom = true, + ) + ), + ) + ) + assertTrue(panel.isVisible) + + panel.show(Question(id = "req_new", items = emptyList())) + + assertFalse(panel.isVisible) + assertEquals(0, panel.componentCount) + assertTrue(rpc.questionReplies.isEmpty()) + assertTrue(rpc.questionRejects.isEmpty()) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt new file mode 100644 index 00000000000..c6961ccab20 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionLayoutTest.kt @@ -0,0 +1,129 @@ +package ai.kilocode.client.session.ui + +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.util.ui.JBUI +import java.awt.Dimension +import javax.swing.JLabel +import javax.swing.JPanel + +/** + * Tests for [SessionLayout]. + * + * Uses a plain [JPanel] as container to test the layout manager in isolation. + * Components are sized manually since there is no real screen / Swing event loop + * involved during tests. + */ +@Suppress("UnstableApiUsage") +class SessionLayoutTest : BasePlatformTestCase() { + + private fun panel(gap: Int = 0, width: Int = 400): JPanel { + return JPanel(SessionLayout(gap)).apply { + setSize(width, 2000) + } + } + + // ---- basic stacking ------ + + fun `test single component is placed at the top`() { + val p = panel(width = 300) + val child = label(height = 20) + p.add(child) + p.doLayout() + + assertEquals(0, child.y) + assertEquals(300, child.width) + assertEquals(20, child.height) + } + + fun `test two components are stacked with gap`() { + val p = panel(gap = 8, width = 300) + val c1 = label(height = 20) + val c2 = label(height = 30) + p.add(c1) + p.add(c2) + p.doLayout() + + assertEquals(0, c1.y) + assertEquals(20 + 8, c2.y) + } + + fun `test three components stack correctly with gap`() { + val p = panel(gap = 4, width = 300) + val c1 = label(height = 10) + val c2 = label(height = 15) + val c3 = label(height = 20) + p.add(c1) + p.add(c2) + p.add(c3) + p.doLayout() + + assertEquals(0, c1.y) + assertEquals(14, c2.y) // 10 + 4 + assertEquals(33, c3.y) // 10 + 4 + 15 + 4 + } + + fun `test all children receive full available width`() { + val p = panel(width = 500) + val c1 = label(height = 20) + val c2 = label(height = 20) + p.add(c1) + p.add(c2) + p.doLayout() + + assertEquals(500, c1.width) + assertEquals(500, c2.width) + } + + // ---- invisible children ------ + + fun `test invisible child is skipped in layout`() { + val p = panel(gap = 8, width = 300) + val c1 = label(height = 20) + val c2 = label(height = 30).also { it.isVisible = false } + val c3 = label(height = 25) + p.add(c1) + p.add(c2) + p.add(c3) + p.doLayout() + + assertEquals(0, c1.y) + // c2 is invisible — no gap before c3 + assertEquals(20 + 8, c3.y) + } + + fun `test only invisible children produce zero height`() { + val p = panel(gap = 8, width = 300) + val c = label(height = 20).also { it.isVisible = false } + p.add(c) + p.doLayout() + + val size = p.layout.preferredLayoutSize(p) + assertEquals(0, size.height) + } + + // ---- preferred size ------ + + fun `test preferredLayoutSize returns sum of child heights plus gaps`() { + val p = panel(gap = 4, width = 300) + p.add(label(height = 10)) + p.add(label(height = 15)) + p.add(label(height = 20)) + p.doLayout() + + val size = p.layout.preferredLayoutSize(p) + assertEquals(10 + 4 + 15 + 4 + 20, size.height) + } + + fun `test preferredLayoutSize with no children is zero`() { + val p = panel(width = 300) + val size = p.layout.preferredLayoutSize(p) + assertEquals(0, size.height) + } + + // ---- helpers ------ + + /** A fixed-height JLabel. The width is reported as 0 until layout sets it. */ + private fun label(height: Int) = object : JLabel("test") { + override fun getPreferredSize(): Dimension = Dimension(0, height) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionPanelTest.kt new file mode 100644 index 00000000000..a0df7614d7a --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionPanelTest.kt @@ -0,0 +1,237 @@ +package ai.kilocode.client.session.ui + +import ai.kilocode.client.session.model.SessionModel +import ai.kilocode.client.session.views.TextView +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PartDto +import com.intellij.openapi.Disposable +import com.intellij.openapi.util.Disposer +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +/** + * Tests for [SessionPanel] — structural and index integrity. + * + * Uses [BasePlatformTestCase] for a real IntelliJ Application; layout + * is not measured (no screen), but the structural / index state is fully + * testable. + */ +@Suppress("UnstableApiUsage") +class SessionPanelTest : BasePlatformTestCase() { + + private lateinit var model: SessionModel + private lateinit var parent: Disposable + private lateinit var panel: SessionPanel + + override fun setUp() { + super.setUp() + parent = Disposer.newDisposable("test") + model = SessionModel() + panel = SessionPanel(model, parent) + } + + override fun tearDown() { + try { + Disposer.dispose(parent) + } finally { + super.tearDown() + } + } + + // ------ initial state ------ + + fun `test empty panel has no turns`() { + assertEquals(0, panel.turnCount()) + assertEquals("", panel.dump()) + } + + // ------ TurnAdded ------ + + fun `test user message creates turn and is findable by message id`() { + model.upsertMessage(msg("u1", "user")) + + assertEquals(1, panel.turnCount()) + assertNotNull(panel.findMessage("u1")) + assertEquals("user", panel.findMessage("u1")!!.role) + assertEquals(panel.dump(), "turn#u1: user#u1") + } + + fun `test assistant message added to existing turn`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + + assertEquals(1, panel.turnCount()) + assertNotNull(panel.findMessage("a1")) + assertEquals("turn#u1: user#u1, assistant#a1", panel.dump()) + } + + fun `test second user message creates a second turn`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.upsertMessage(msg("u2", "user")) + model.upsertMessage(msg("a2", "assistant")) + + assertEquals(2, panel.turnCount()) + assertNotNull(panel.findMessage("u2")) + assertNotNull(panel.findMessage("a2")) + assertEquals(""" + turn#u1: user#u1, assistant#a1 + turn#u2: user#u2, assistant#a2 + """.trimIndent().trim(), panel.dump()) + } + + // ------ TurnRemoved ------ + + fun `test removing only message removes the turn`() { + model.upsertMessage(msg("u1", "user")) + model.removeMessage("u1") + + assertEquals(0, panel.turnCount()) + assertNull(panel.findMessage("u1")) + } + + fun `test removing user anchor of two-message turn creates new standalone turn for assistant`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + + model.removeMessage("u1") + + assertEquals(1, panel.turnCount()) + assertNull(panel.findMessage("u1")) + assertNotNull(panel.findMessage("a1")) + assertEquals("turn#a1: assistant#a1", panel.dump()) + } + + fun `test removing middle user message merges turns`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + model.upsertMessage(msg("u2", "user")) + model.upsertMessage(msg("a2", "assistant")) + + model.removeMessage("u2") + + assertEquals(1, panel.turnCount()) + assertNull(panel.findMessage("u2")) + assertNotNull(panel.findMessage("a2")) // now in u1's turn + assertEquals("turn#u1: user#u1, assistant#a1, assistant#a2", panel.dump()) + } + + // ------ secondary index integrity ------ + + fun `test findTurn returns the owning TurnView`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + + val tv = panel.findTurn("a1") + assertNotNull(tv) + assertEquals("u1", tv!!.id) + } + + fun `test indexes are null after message removal`() { + model.upsertMessage(msg("u1", "user")) + model.removeMessage("u1") + + assertNull(panel.findMessage("u1")) + assertNull(panel.findTurn("u1")) + } + + // ------ content events ------ + + fun `test ContentAdded adds TextView to MessageView`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "hello")) + + val mv = panel.findMessage("a1")!! + assertEquals(listOf("p1"), mv.partIds()) + assertTrue(mv.part("p1") is TextView) + } + + fun `test ContentDelta appends text to TextView`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "hello ")) + model.appendDelta("a1", "p1", "world") + + val tv = panel.findMessage("a1")!!.part("p1") as TextView + assertEquals("hello world", tv.markdown()) + } + + fun `test ContentRemoved removes PartView from MessageView`() { + model.upsertMessage(msg("a1", "assistant")) + model.updateContent("a1", part("p1", "a1", "text", text = "x")) + model.removeContent("a1", "p1") + + val mv = panel.findMessage("a1")!! + assertTrue(mv.partIds().isEmpty()) + } + + // ------ HistoryLoaded ------ + + fun `test HistoryLoaded rebuilds panel from scratch`() { + // Prime with some messages + model.upsertMessage(msg("u0", "user")) + + model.loadHistory(listOf( + MessageWithPartsDto(msg("u1", "user"), emptyList()), + MessageWithPartsDto(msg("a1", "assistant"), emptyList()), + MessageWithPartsDto(msg("u2", "user"), emptyList()), + )) + + assertNull(panel.findMessage("u0")) // old message gone + assertNotNull(panel.findMessage("u1")) + assertNotNull(panel.findMessage("a1")) + assertNotNull(panel.findMessage("u2")) + assertEquals(""" + turn#u1: user#u1, assistant#a1 + turn#u2: user#u2 + """.trimIndent().trim(), panel.dump()) + } + + fun `test HistoryLoaded with parts populates MessageView content`() { + model.loadHistory(listOf( + MessageWithPartsDto( + msg("a1", "assistant"), + listOf(part("p1", "a1", "text", text = "preloaded")), + ), + )) + + val mv = panel.findMessage("a1")!! + assertEquals(listOf("p1"), mv.partIds()) + val tv = mv.part("p1") as TextView + assertEquals("preloaded", tv.markdown()) + } + + // ------ Cleared ------ + + fun `test Cleared wipes all panel state`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("a1", "assistant")) + + model.clear() + + assertEquals(0, panel.turnCount()) + assertNull(panel.findMessage("u1")) + assertNull(panel.findMessage("a1")) + assertEquals("", panel.dump()) + } + + // ------ turn ordering ------ + + fun `test turn insertion order is preserved`() { + model.upsertMessage(msg("u1", "user")) + model.upsertMessage(msg("u2", "user")) + model.upsertMessage(msg("u3", "user")) + + assertEquals(listOf("u1", "u2", "u3"), panel.turnIds()) + } + + // ------ helpers ------ + + private fun msg(id: String, role: String) = MessageDto( + id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0), + ) + + private fun part(id: String, mid: String, type: String, text: String? = null) = PartDto( + id = id, sessionID = "ses", messageID = mid, type = type, text = text, + ) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt new file mode 100644 index 00000000000..784e2bd9777 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TextViewTest.kt @@ -0,0 +1,93 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Text +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +/** + * Tests for [TextView]. + * + * Uses [BasePlatformTestCase] so that [JBHtmlPane] (inside MdView) initialises + * correctly with a real IntelliJ Application. + */ +@Suppress("UnstableApiUsage") +class TextViewTest : BasePlatformTestCase() { + + // ---- creation ------ + + fun `test empty Text creates view with empty markdown`() { + val view = TextView(Text("p1")) + assertEquals("", view.markdown()) + } + + fun `test Text with content sets initial markdown`() { + val text = Text("p1").also { it.content.append("hello **world**") } + val view = TextView(text) + assertEquals("hello **world**", view.markdown()) + } + + // ---- update ------ + + fun `test update replaces markdown content`() { + val text = Text("p1").also { it.content.append("initial") } + val view = TextView(text) + + val updated = Text("p1").also { it.content.append("updated") } + view.update(updated) + + assertEquals("updated", view.markdown()) + } + + fun `test update with different content type is ignored`() { + val view = TextView(Text("p1").also { it.content.append("keep") }) + view.update(ai.kilocode.client.session.model.Reasoning("p1")) + assertEquals("keep", view.markdown()) + } + + // ---- appendDelta ------ + + fun `test appendDelta accumulates content`() { + val view = TextView(Text("p1")) + view.appendDelta("hello ") + view.appendDelta("**world**") + + assertEquals("hello **world**", view.markdown()) + } + + fun `test appendDelta after update extends content`() { + val text = Text("p1").also { it.content.append("first ") } + val view = TextView(text) + + view.appendDelta("second") + + assertEquals("first second", view.markdown()) + } + + // ---- contentId ------ + + fun `test contentId matches Text id`() { + val view = TextView(Text("part42")) + assertEquals("part42", view.contentId) + } + + // ---- component ------ + + fun `test component is non-null and is the MdView component`() { + val view = TextView(Text("p1")) + assertNotNull(view.md.component) + } + + // ---- markdown is rendered ------ + + fun `test update with bold text produces html with strong tag`() { + val view = TextView(Text("p1")) + view.update(Text("p1").also { it.content.append("**bold**") }) + assertTrue(view.md.html().contains("")) + } + + fun `test streaming bold across two deltas`() { + val view = TextView(Text("p1")) + view.appendDelta("**bold") + view.appendDelta("**") + assertTrue(view.md.html().contains("")) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt new file mode 100644 index 00000000000..5e8bdc46353 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/ToolViewTest.kt @@ -0,0 +1,91 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Content +import ai.kilocode.client.session.model.Tool +import ai.kilocode.client.session.model.ToolExecState +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +/** + * Tests for [ToolView]. + */ +@Suppress("UnstableApiUsage") +class ToolViewTest : BasePlatformTestCase() { + + // ---- state icons ------ + + fun `test PENDING state shows hourglass icon`() { + val view = ToolView(tool("p1", "bash", ToolExecState.PENDING)) + assertTrue(view.labelText().contains("\u23F3")) // ⏳ + } + + fun `test RUNNING state shows play icon`() { + val view = ToolView(tool("p1", "bash", ToolExecState.RUNNING)) + assertTrue(view.labelText().contains("\u25B6")) // ▶ + } + + fun `test COMPLETED state shows checkmark icon`() { + val view = ToolView(tool("p1", "bash", ToolExecState.COMPLETED)) + assertTrue(view.labelText().contains("\u2713")) // ✓ + } + + fun `test ERROR state shows cross icon`() { + val view = ToolView(tool("p1", "bash", ToolExecState.ERROR)) + assertTrue(view.labelText().contains("\u2717")) // ✗ + } + + // ---- display text ------ + + fun `test tool name shown when no title`() { + val view = ToolView(tool("p1", "bash", ToolExecState.RUNNING)) + assertTrue(view.labelText().contains("bash")) + } + + fun `test title shown instead of name when title is set`() { + val t = Tool("p1", "bash").also { it.state = ToolExecState.RUNNING; it.title = "Install deps" } + val view = ToolView(t) + assertTrue(view.labelText().contains("Install deps")) + assertFalse(view.labelText().contains("bash")) + } + + fun `test blank title falls back to tool name`() { + val t = Tool("p1", "bash").also { it.state = ToolExecState.COMPLETED; it.title = " " } + val view = ToolView(t) + assertTrue(view.labelText().contains("bash")) + } + + // ---- update ------ + + fun `test update changes state icon`() { + val view = ToolView(tool("p1", "bash", ToolExecState.RUNNING)) + val updated = Tool("p1", "bash").also { it.state = ToolExecState.COMPLETED } + view.update(updated) + assertTrue(view.labelText().contains("\u2713")) + assertFalse(view.labelText().contains("\u25B6")) + } + + fun `test update changes title`() { + val view = ToolView(tool("p1", "bash", ToolExecState.RUNNING, title = "old")) + val updated = Tool("p1", "bash").also { it.state = ToolExecState.COMPLETED; it.title = "new title" } + view.update(updated) + assertTrue(view.labelText().contains("new title")) + } + + fun `test update with non-Tool content is ignored`() { + val view = ToolView(tool("p1", "bash", ToolExecState.RUNNING)) + val before = view.labelText() + view.update(ai.kilocode.client.session.model.Text("p1")) + assertEquals(before, view.labelText()) + } + + // ---- contentId ------ + + fun `test contentId matches Tool id`() { + val view = ToolView(Tool("part99", "edit").also { it.state = ToolExecState.PENDING }) + assertEquals("part99", view.contentId) + } + + // ---- helpers ------ + + private fun tool(id: String, name: String, state: ToolExecState, title: String? = null): Tool = + Tool(id, name).also { it.state = state; it.title = title } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt new file mode 100644 index 00000000000..74862cb9d96 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/TurnViewTest.kt @@ -0,0 +1,161 @@ +package ai.kilocode.client.session.views + +import ai.kilocode.client.session.model.Message +import ai.kilocode.client.session.model.Text +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageTimeDto +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +/** + * Tests for [TurnView] and [MessageView]. + */ +@Suppress("UnstableApiUsage") +class TurnViewTest : BasePlatformTestCase() { + + // ------ TurnView ------ + + fun `test new TurnView is empty`() { + val tv = TurnView("t1") + assertTrue(tv.messageIds().isEmpty()) + } + + fun `test addMessage appends and returns view`() { + val tv = TurnView("t1") + val mv = tv.addMessage(msg("u1", "user")) + assertEquals("u1", mv.msg.info.id) + assertEquals(listOf("u1"), tv.messageIds()) + } + + fun `test addMessage preserves insertion order`() { + val tv = TurnView("t1") + tv.addMessage(msg("u1", "user")) + tv.addMessage(msg("a1", "assistant")) + tv.addMessage(msg("a2", "assistant")) + assertEquals(listOf("u1", "a1", "a2"), tv.messageIds()) + } + + fun `test messageView returns the view for a given id`() { + val tv = TurnView("t1") + tv.addMessage(msg("u1", "user")) + val mv = tv.messageView("u1") + assertNotNull(mv) + assertEquals("user", mv!!.role) + } + + fun `test messageView returns null for unknown id`() { + val tv = TurnView("t1") + assertNull(tv.messageView("missing")) + } + + fun `test removeMessage removes the view`() { + val tv = TurnView("t1") + tv.addMessage(msg("u1", "user")) + tv.addMessage(msg("a1", "assistant")) + + tv.removeMessage("a1") + + assertEquals(listOf("u1"), tv.messageIds()) + assertNull(tv.messageView("a1")) + } + + fun `test removeMessage unknown id is noop`() { + val tv = TurnView("t1") + tv.addMessage(msg("u1", "user")) + tv.removeMessage("nope") + assertEquals(listOf("u1"), tv.messageIds()) + } + + fun `test dump produces correct format`() { + val tv = TurnView("u1") + tv.addMessage(msg("u1", "user")) + tv.addMessage(msg("a1", "assistant")) + assertEquals("user#u1, assistant#a1", tv.dump()) + } + + // ------ MessageView ------ + + fun `test new MessageView is empty`() { + val mv = MessageView(msg("u1", "user")) + assertTrue(mv.partIds().isEmpty()) + } + + fun `test MessageView for user message has user role`() { + val mv = MessageView(msg("u1", "user")) + assertEquals("user", mv.role) + } + + fun `test MessageView for assistant message has assistant role`() { + val mv = MessageView(msg("a1", "assistant")) + assertEquals("assistant", mv.role) + } + + fun `test upsertPart adds a new TextView for Text content`() { + val mv = MessageView(msg("a1", "assistant")) + val text = ai.kilocode.client.session.model.Text("p1") + text.content.append("hello") + mv.upsertPart(text) + + assertEquals(listOf("p1"), mv.partIds()) + assertTrue(mv.part("p1") is TextView) + } + + fun `test upsertPart updates existing part rather than adding duplicate`() { + val mv = MessageView(msg("a1", "assistant")) + val t1 = ai.kilocode.client.session.model.Text("p1").also { it.content.append("v1") } + mv.upsertPart(t1) + + val t2 = ai.kilocode.client.session.model.Text("p1").also { it.content.append("v2") } + mv.upsertPart(t2) + + assertEquals(1, mv.partIds().size) + val view = mv.part("p1") as TextView + assertEquals("v2", view.markdown()) + } + + fun `test removePart removes the renderer`() { + val mv = MessageView(msg("a1", "assistant")) + mv.upsertPart(ai.kilocode.client.session.model.Text("p1").also { it.content.append("x") }) + mv.removePart("p1") + + assertTrue(mv.partIds().isEmpty()) + assertNull(mv.part("p1")) + } + + fun `test removePart unknown id is noop`() { + val mv = MessageView(msg("a1", "assistant")) + mv.removePart("none") + assertTrue(mv.partIds().isEmpty()) + } + + fun `test appendDelta reaches TextView`() { + val mv = MessageView(msg("a1", "assistant")) + mv.upsertPart(ai.kilocode.client.session.model.Text("p1").also { it.content.append("hello ") }) + + mv.appendDelta("p1", "world") + + val view = mv.part("p1") as TextView + assertEquals("hello world", view.markdown()) + } + + fun `test appendDelta for unknown part id is noop`() { + val mv = MessageView(msg("a1", "assistant")) + // Must not throw + mv.appendDelta("unknown", "delta") + } + + fun `test MessageView pre-populates parts from Message on creation`() { + val message = msg("a1", "assistant") + val text = ai.kilocode.client.session.model.Text("p1").also { it.content.append("preloaded") } + message.parts["p1"] = text + + val mv = MessageView(message) + + assertEquals(listOf("p1"), mv.partIds()) + assertTrue(mv.part("p1") is TextView) + } + + // ------ helpers ------ + + private fun msg(id: String, role: String): Message = + Message(MessageDto(id = id, sessionID = "ses", role = role, time = MessageTimeDto(0.0))) +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt index 573db3e2c12..de70b1424c9 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeSessionRpcApi.kt @@ -54,6 +54,9 @@ class FakeSessionRpcApi : KiloSessionRpcApi { /** Pending questions returned by [pendingQuestions]. */ val pendingQuestionList = mutableListOf() + /** Optional custom event stream factory for routing tests. */ + var eventFlow: ((String, String) -> Flow)? = null + // --- Call tracking --- val prompts = mutableListOf>() @@ -119,7 +122,7 @@ class FakeSessionRpcApi : KiloSessionRpcApi { override suspend fun events(id: String, directory: String): Flow { assertNotEdt("events") - return events + return eventFlow?.invoke(id, directory) ?: events } override suspend fun updateConfig(directory: String, config: ConfigUpdateDto) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt new file mode 100644 index 00000000000..5eaac3e666f --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/ui/md/MdViewLoggingTest.kt @@ -0,0 +1,17 @@ +package ai.kilocode.client.ui.md + +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +@Suppress("UnstableApiUsage") +class MdViewLoggingTest : BasePlatformTestCase() { + + fun `test invalid font family does not throw while building override sheet`() { + val view = MdView.html() + + view.codeFont = "broken'font" + view.set("`x`") + + assertTrue(view.overrideSheet().contains("broken\\'font")) + assertTrue(view.html().contains("")) + } +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt new file mode 100644 index 00000000000..3a7c73822a3 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/ChatLogSummary.kt @@ -0,0 +1,325 @@ +package ai.kilocode.log + +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.DiffFileDto +import ai.kilocode.rpc.dto.MessageDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.PartDto +import ai.kilocode.rpc.dto.PermissionRequestDto +import ai.kilocode.rpc.dto.PromptDto +import ai.kilocode.rpc.dto.QuestionRequestDto +import ai.kilocode.rpc.dto.SessionStatusDto +import ai.kilocode.rpc.dto.TodoDto + +object ChatLogSummary { + fun sid(id: String?): String = "sid=${id ?: "global"}" + + fun sid(event: ChatEventDto): String? = when (event) { + is ChatEventDto.MessageUpdated -> event.sessionID + is ChatEventDto.PartUpdated -> event.sessionID + is ChatEventDto.PartDelta -> event.sessionID + is ChatEventDto.PartRemoved -> event.sessionID + is ChatEventDto.TurnOpen -> event.sessionID + is ChatEventDto.TurnClose -> event.sessionID + is ChatEventDto.Error -> event.sessionID + is ChatEventDto.MessageRemoved -> event.sessionID + is ChatEventDto.PermissionAsked -> event.sessionID + is ChatEventDto.PermissionReplied -> event.sessionID + is ChatEventDto.QuestionAsked -> event.sessionID + is ChatEventDto.QuestionReplied -> event.sessionID + is ChatEventDto.QuestionRejected -> event.sessionID + is ChatEventDto.SessionStatusChanged -> event.sessionID + is ChatEventDto.SessionIdle -> event.sessionID + is ChatEventDto.SessionCompacted -> event.sessionID + is ChatEventDto.SessionDiffChanged -> event.sessionID + is ChatEventDto.TodoUpdated -> event.sessionID + } + + fun dir(dir: String): String = "dirHash=${hash(dir)}" + + fun body(text: String): String { + val out = mutableListOf() + out += "chars=${text.length}" + preview(text)?.let { out += "preview=\"$it\"" } + return out.joinToString(" ") + } + + fun prompt(text: String): String = join("kind=prompt", body(text)) + + fun prompt(prompt: PromptDto): String { + val out = mutableListOf() + val text = prompt.parts.joinToString("\n") { it.text } + out += "kind=prompt" + out += "parts=${prompt.parts.size}" + out += "chars=${text.length}" + prompt.parts.map { it.type } + .distinct() + .takeIf { it.isNotEmpty() } + ?.let { out += "types=${it.joinToString(",")}" } + prompt.agent?.takeIf { it.isNotBlank() }?.let { out += "agent=$it" } + model(prompt.providerID, prompt.modelID)?.let { out += "model=$it" } + preview(text)?.let { out += "preview=\"$it\"" } + return out.joinToString(" ") + } + + fun history(items: List): String { + val out = mutableListOf() + val parts = items.sumOf { it.parts.size } + val chars = items.sumOf { item -> item.parts.sumOf { it.text?.length ?: 0 } } + val roles = items.map { it.info.role }.distinct() + out += "kind=history" + out += "messages=${items.size}" + out += "parts=$parts" + out += "chars=$chars" + roles.takeIf { it.isNotEmpty() }?.let { out += "roles=${it.joinToString(",")}" } + items.firstOrNull()?.info?.id?.let { out += "first=$it" } + items.lastOrNull()?.info?.id?.takeIf { it != items.firstOrNull()?.info?.id }?.let { out += "last=$it" } + return out.joinToString(" ") + } + + fun permission(req: PermissionRequestDto): String = permission(req, true) + + fun question(req: QuestionRequestDto): String = question(req, true) + + fun status(dto: SessionStatusDto): String { + val out = mutableListOf() + out += "type=${dto.type}" + dto.attempt?.let { out += "attempt=$it" } + dto.next?.let { out += "next=$it" } + dto.requestID?.takeIf { it.isNotBlank() }?.let { out += "rid=$it" } + dto.message?.takeIf { it.isNotBlank() }?.let { msg -> statusPreview(msg)?.let { out += "message=\"$it\"" } } + return out.joinToString(" ") + } + + fun event(event: ChatEventDto): String = when (event) { + is ChatEventDto.MessageUpdated -> join( + sid(event.sessionID), + "evt=message.updated", + message(event.info), + ) + + is ChatEventDto.PartUpdated -> join( + sid(event.sessionID), + "evt=message.part.updated", + part(event.part), + ) + + is ChatEventDto.PartDelta -> join( + sid(event.sessionID), + "evt=message.part.delta", + "mid=${event.messageID}", + "pid=${event.partID}", + "field=${event.field}", + body(event.delta), + ) + + is ChatEventDto.PartRemoved -> join( + sid(event.sessionID), + "evt=message.part.removed", + "mid=${event.messageID}", + "pid=${event.partID}", + ) + + is ChatEventDto.TurnOpen -> join( + sid(event.sessionID), + "evt=session.turn.open", + ) + + is ChatEventDto.TurnClose -> join( + sid(event.sessionID), + "evt=session.turn.close", + "reason=${event.reason}", + ) + + is ChatEventDto.Error -> join( + sid(event.sessionID), + "evt=session.error", + event.error?.type?.let { "err=$it" }, + event.error?.message?.let { msg -> preview(msg)?.let { "message=\"$it\"" } }, + ) + + is ChatEventDto.MessageRemoved -> join( + sid(event.sessionID), + "evt=message.removed", + "mid=${event.messageID}", + ) + + is ChatEventDto.PermissionAsked -> join( + sid(event.sessionID), + "evt=permission.asked", + permission(event.request, false), + ) + + is ChatEventDto.PermissionReplied -> join( + sid(event.sessionID), + "evt=permission.replied", + "rid=${event.requestID}", + ) + + is ChatEventDto.QuestionAsked -> join( + sid(event.sessionID), + "evt=question.asked", + question(event.request, false), + ) + + is ChatEventDto.QuestionReplied -> join( + sid(event.sessionID), + "evt=question.replied", + "rid=${event.requestID}", + ) + + is ChatEventDto.QuestionRejected -> join( + sid(event.sessionID), + "evt=question.rejected", + "rid=${event.requestID}", + ) + + is ChatEventDto.SessionStatusChanged -> join( + sid(event.sessionID), + "evt=session.status", + status(event.status), + ) + + is ChatEventDto.SessionIdle -> join( + sid(event.sessionID), + "evt=session.idle", + ) + + is ChatEventDto.SessionCompacted -> join( + sid(event.sessionID), + "evt=session.compacted", + ) + + is ChatEventDto.SessionDiffChanged -> join( + sid(event.sessionID), + "evt=session.diff", + diff(event.diff), + ) + + is ChatEventDto.TodoUpdated -> join( + sid(event.sessionID), + "evt=todo.updated", + todos(event.todos), + ) + } + + fun eventBody(event: ChatEventDto): String = event(event).substringAfter("evt=").let { body -> + val evt = body.substringBefore(' ') + val rest = body.substringAfter(' ', "") + join("evt=$evt", rest) + } + + private fun message(dto: MessageDto): String = join( + "mid=${dto.id}", + "role=${dto.role}", + dto.agent?.takeIf { it.isNotBlank() }?.let { "agent=$it" }, + model(dto.providerID, dto.modelID)?.let { "model=$it" }, + dto.error?.type?.let { "err=$it" }, + ) + + private fun part(dto: PartDto): String = join( + "mid=${dto.messageID}", + "pid=${dto.id}", + "type=${dto.type}", + dto.tool?.takeIf { it.isNotBlank() }?.let { "tool=$it" }, + dto.callID?.takeIf { it.isNotBlank() }?.let { "call=$it" }, + dto.state?.takeIf { it.isNotBlank() }?.let { "state=$it" }, + dto.title?.takeIf { it.isNotBlank() }?.let { title -> preview(title)?.let { "title=\"$it\"" } }, + dto.text?.let { body(it) }, + ) + + private fun permission(req: PermissionRequestDto, sid: Boolean): String { + val out = mutableListOf() + if (sid) out += "sid=${req.sessionID}" + out += "rid=${req.id}" + out += "permission=${req.permission}" + out += "patterns=${req.patterns.size}" + out += "always=${req.always.size}" + out += "meta=${req.metadata.size}" + req.tool?.messageID?.let { out += "toolMid=$it" } + req.tool?.callID?.let { out += "call=$it" } + req.patterns.firstOrNull()?.let { item -> preview(item)?.let { out += "sample=\"$it\"" } } + return out.joinToString(" ") + } + + private fun question(req: QuestionRequestDto, sid: Boolean): String { + val out = mutableListOf() + val opts = req.questions.sumOf { it.options.size } + if (sid) out += "sid=${req.sessionID}" + out += "rid=${req.id}" + out += "questions=${req.questions.size}" + out += "options=$opts" + req.tool?.messageID?.let { out += "toolMid=$it" } + req.tool?.callID?.let { out += "call=$it" } + req.questions.firstOrNull()?.question?.let { item -> preview(item)?.let { out += "preview=\"$it\"" } } + return out.joinToString(" ") + } + + private fun diff(items: List): String { + val adds = items.sumOf { it.additions } + val dels = items.sumOf { it.deletions } + val chars = items.sumOf { it.patch?.length ?: 0 } + return join( + "files=${items.size}", + "adds=$adds", + "dels=$dels", + "patchChars=$chars", + ) + } + + private fun todos(items: List): String { + val out = mutableListOf() + out += "todos=${items.size}" + items.groupBy { it.status } + .toSortedMap() + .forEach { (key, value) -> out += "$key=${value.size}" } + return out.joinToString(" ") + } + + private fun model(provider: String?, model: String?): String? { + if (provider.isNullOrBlank() || model.isNullOrBlank()) return null + return "$provider/$model" + } + + private fun join(vararg parts: String?): String = + parts.filterNotNull().filter { it.isNotBlank() }.joinToString(" ") + + private fun preview(text: String): String? { + val mode = mode() + if (mode == Mode.OFF) return null + val raw = clean(text) + if (raw.isEmpty()) return null + val cut = if (mode == Mode.FULL) raw else raw.take(max()) + return if (cut.length == raw.length) cut else "$cut..." + } + + private fun statusPreview(text: String): String? { + val raw = clean(text) + if (raw.isEmpty()) return null + val mode = mode() + val cut = if (mode == Mode.FULL) raw else raw.take(max()) + return if (cut.length == raw.length) cut else "$cut..." + } + + private fun clean(text: String): String = text + .replace(Regex("\\s+"), " ") + .replace("\"", "'") + .trim() + + private fun hash(text: String): String = text.hashCode().toUInt().toString(16) + + private fun mode(): Mode = when ((System.getProperty("kilo.dev.log.chat.content") ?: "off").lowercase()) { + "preview" -> Mode.PREVIEW + "full" -> Mode.FULL + else -> Mode.OFF + } + + private fun max(): Int = (System.getProperty("kilo.dev.log.chat.preview.max")?.toIntOrNull() ?: 160) + .coerceIn(1, 2000) + + private enum class Mode { + OFF, + PREVIEW, + FULL, + } +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt new file mode 100644 index 00000000000..d76dc9e9df5 --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/log/KiloLog.kt @@ -0,0 +1,191 @@ +package ai.kilocode.log + +import com.intellij.openapi.application.PathManager +import com.intellij.openapi.diagnostic.Logger +import java.io.PrintWriter +import java.io.StringWriter +import java.lang.management.ManagementFactory +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.logging.FileHandler +import java.util.logging.Formatter +import java.util.logging.Level +import java.util.logging.LogRecord + +/** + * Logging interface for the Kilo JetBrains plugin. + * + * In normal (non-sandbox) mode, all output goes through IntelliJ's own [com.intellij.openapi.diagnostic.Logger], + * which writes to the standard IDE log file. + * + * In sandbox mode (i.e. when running via `./gradlew runIde`, detected via the `idea.plugin.in.sandbox.mode` + * system property), output is additionally written to a `kilo-dev.log` file inside the sandbox log directory. + * This makes it easy to tail frontend and backend logs side-by-side during development without opening the + * IDE's own log viewer. + * + * Usage: + * ```kotlin + * private val log = KiloLog.create(this::class.java) + * + * log.info("session started") + * log.debug { "expensive: ${computeSomething()}" } // lambda is only evaluated when debug is enabled + * log.warn("unexpected state", exception) + * ``` + * + * The log level for the sandbox file can be controlled via the `kilo.dev.log.level` system property + * (DEBUG, INFO, WARN, ERROR, OFF). Defaults to INFO. + */ +interface KiloLog { + val isDebugEnabled: Boolean + fun debug(block: () -> String) + fun info(msg: String) + fun warn(msg: String, t: Throwable? = null) + fun error(msg: String, t: Throwable? = null) + + companion object { + private val sandbox = System.getProperty("idea.plugin.in.sandbox.mode", "false").toBoolean() + + fun create(cls: Class<*>): KiloLog { + val intellij = IntellijLog(cls) + if (!sandbox) return intellij + return CompositeLog(intellij, FileLog(cls)) + } + } +} + +internal class IntellijLog(cls: Class<*>) : KiloLog { + private val delegate = Logger.getInstance(cls) + override val isDebugEnabled: Boolean + get() = delegate.isDebugEnabled + override fun debug(block: () -> String) { + if (delegate.isDebugEnabled) delegate.debug(block()) + } + override fun info(msg: String) = delegate.info(msg) + override fun warn(msg: String, t: Throwable?) { + if (t != null) delegate.warn(msg, t) else delegate.warn(msg) + } + override fun error(msg: String, t: Throwable?) { + if (t != null) delegate.error(msg, t) else delegate.error(msg) + } +} + +internal class FileLog(cls: Class<*>) : KiloLog { + private val name = cls.name + + companion object { + private val level: Level by lazy { resolveLevel() } + + private val root: java.util.logging.Logger by lazy { + val logger = java.util.logging.Logger.getLogger("ai.kilocode") + logger.addHandler(handler) + logger.useParentHandlers = false + logger.level = level + logger + } + + private val handler: FileHandler by lazy { + val dir = resolveLogDir() + val path = dir.resolve("kilo-dev.log") + val h = FileHandler(path.toString(), true) + h.formatter = KiloFormatter() + h + } + + private fun resolveLogDir(): Path { + val dir = PathManager.getLogDir() + var current = dir + var side: String? = null + while (current.parent != null) { + val name = current.fileName.toString() + if (name.startsWith("log_run")) { + side = if (name.lowercase().contains("frontend")) "kilo-frontend" else "kilo-backend" + } + if (name == "kilo.jetbrains" && side != null) { + val target = current.resolve(side) + Files.createDirectories(target) + return target + } + current = current.parent + } + return dir + } + + private fun resolveLevel(): Level { + val prop = System.getProperty("kilo.dev.log.level") ?: return Level.INFO + return when (prop.uppercase()) { + "DEBUG" -> Level.FINE + "INFO" -> Level.INFO + "WARN", "WARNING" -> Level.WARNING + "ERROR" -> Level.SEVERE + "OFF" -> Level.OFF + else -> Level.ALL + } + } + } + + override val isDebugEnabled: Boolean + get() = root.isLoggable(Level.FINE) + + override fun debug(block: () -> String) { + if (root.isLoggable(Level.FINE)) root.logp(Level.FINE, name, null, block()) + } + override fun info(msg: String) = root.logp(Level.INFO, name, null, msg) + override fun warn(msg: String, t: Throwable?) { + if (t != null) root.logp(Level.WARNING, name, null, msg, t) else root.logp(Level.WARNING, name, null, msg) + } + override fun error(msg: String, t: Throwable?) { + if (t != null) root.logp(Level.SEVERE, name, null, msg, t) else root.logp(Level.SEVERE, name, null, msg) + } +} + +internal class KiloFormatter : Formatter() { + private val start = ManagementFactory.getRuntimeMXBean().startTime + private val fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss,SSS") + + override fun format(record: LogRecord): String { + val time = Instant.ofEpochMilli(record.millis).atZone(ZoneId.systemDefault()) + val elapsed = record.millis - start + val level = when (record.level) { + Level.FINE -> "DEBUG" + Level.INFO -> "INFO" + Level.WARNING -> "WARN" + Level.SEVERE -> "ERROR" + else -> record.level.name + } + val category = record.sourceClassName ?: record.loggerName ?: "kilo.dev" + val sb = StringBuilder() + sb.append(fmt.format(time)) + sb.append(" [") + sb.append(elapsed.toString().padStart(8)) + sb.append("] ") + sb.append(level.padEnd(5)) + sb.append(" - #") + sb.append(category) + sb.append(" - ") + sb.append(formatMessage(record)) + sb.append('\n') + if (record.thrown != null) { + val sw = StringWriter() + record.thrown.printStackTrace(PrintWriter(sw)) + sb.append(sw) + } + return sb.toString() + } +} + +internal class CompositeLog(vararg val delegates: KiloLog) : KiloLog { + override val isDebugEnabled: Boolean + get() = delegates.any { it.isDebugEnabled } + override fun debug(block: () -> String) { + val active = delegates.filter { it.isDebugEnabled } + if (active.isEmpty()) return + val msg = block() + active.forEach { it.debug { msg } } + } + override fun info(msg: String) = delegates.forEach { it.info(msg) } + override fun warn(msg: String, t: Throwable?) = delegates.forEach { it.warn(msg, t) } + override fun error(msg: String, t: Throwable?) = delegates.forEach { it.error(msg, t) } +} diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index af676db0cec..1d29bba187d 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.2.14", + "version": "7.2.30", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", @@ -20,11 +20,11 @@ "posthog-node": "4.4.0", "@kilocode/kilo-gateway": "workspace:*", "@opentelemetry/api": "1.9.0", - "@opentelemetry/core": "1.30.1", - "@opentelemetry/sdk-trace-base": "1.30.1", - "@opentelemetry/sdk-trace-node": "1.30.1", - "@opentelemetry/resources": "1.30.1", - "@opentelemetry/semantic-conventions": "1.28.0" + "@opentelemetry/core": "2.6.1", + "@opentelemetry/sdk-trace-base": "2.6.1", + "@opentelemetry/sdk-trace-node": "2.6.1", + "@opentelemetry/resources": "2.6.1", + "@opentelemetry/semantic-conventions": "1.40.0" }, "devDependencies": { "@tsconfig/bun": "catalog:", diff --git a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts index 51c7c102f35..38470f03eb7 100644 --- a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts +++ b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect, beforeEach } from "bun:test" import { Identity } from "../identity.js" import { TelemetryEvent } from "../events.js" +import { Telemetry } from "../telemetry.js" import { PostHogSpanExporter } from "../otel-exporter.js" import { ExportResultCode } from "@opentelemetry/core" import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" @@ -66,6 +67,14 @@ describe("TelemetryEvent", () => { expect(TelemetryEvent.AGENT_USED).toBeDefined() }) + test("indexing events are defined", () => { + expect(TelemetryEvent.INDEXING_STARTED).toBeDefined() + expect(TelemetryEvent.INDEXING_COMPLETED).toBeDefined() + expect(TelemetryEvent.INDEXING_FILE_COUNT).toBeDefined() + expect(TelemetryEvent.INDEXING_BATCH_RETRY).toBeDefined() + expect(TelemetryEvent.INDEXING_ERROR).toBeDefined() + }) + test("auth events are defined", () => { expect(TelemetryEvent.AUTH_SUCCESS).toBeDefined() expect(TelemetryEvent.AUTH_LOGOUT).toBeDefined() @@ -86,6 +95,16 @@ describe("TelemetryEvent", () => { }) }) +describe("Telemetry", () => { + test("indexing helpers are exposed", () => { + expect(typeof Telemetry.trackIndexingStarted).toBe("function") + expect(typeof Telemetry.trackIndexingCompleted).toBe("function") + expect(typeof Telemetry.trackIndexingFileCount).toBe("function") + expect(typeof Telemetry.trackIndexingBatchRetry).toBe("function") + expect(typeof Telemetry.trackIndexingError).toBe("function") + }) +}) + describe("PostHogSpanExporter", () => { function createMockSpan(name: string, attributes: Record): ReadableSpan { return { @@ -96,13 +115,13 @@ describe("PostHogSpanExporter", () => { spanId: "span-456", traceFlags: 1, }), - parentSpanId: undefined, + parentSpanContext: undefined, startTime: [1000, 0], endTime: [1001, 0], status: { code: 0 }, kind: 0, resource: { attributes: {} }, - instrumentationLibrary: { name: "test" }, + instrumentationScope: { name: "test" }, events: [], links: [], ended: true, diff --git a/packages/kilo-telemetry/src/events.ts b/packages/kilo-telemetry/src/events.ts index c621da8dc0b..d61d4ae06b6 100644 --- a/packages/kilo-telemetry/src/events.ts +++ b/packages/kilo-telemetry/src/events.ts @@ -17,6 +17,13 @@ export enum TelemetryEvent { AGENT_USED = "Agent Used", PLAN_FOLLOWUP = "Plan Followup", + // Code Indexing + INDEXING_STARTED = "Indexing Started", + INDEXING_COMPLETED = "Indexing Completed", + INDEXING_FILE_COUNT = "Indexing File Count", + INDEXING_BATCH_RETRY = "Indexing Batch Retry", + INDEXING_ERROR = "Indexing Error", + // Share Events SHARE_CREATED = "Share Created", SHARE_DELETED = "Share Deleted", diff --git a/packages/kilo-telemetry/src/otel-exporter.ts b/packages/kilo-telemetry/src/otel-exporter.ts index 9a9a1f29192..a672e76fcf0 100644 --- a/packages/kilo-telemetry/src/otel-exporter.ts +++ b/packages/kilo-telemetry/src/otel-exporter.ts @@ -101,8 +101,9 @@ export class PostHogSpanExporter implements SpanExporter { } // Add parent ID if present - if (span.parentSpanId) { - properties.$ai_parent_id = span.parentSpanId + const parentSpanId = span.parentSpanContext?.spanId + if (parentSpanId) { + properties.$ai_parent_id = parentSpanId } // Map span attributes to PostHog properties diff --git a/packages/kilo-telemetry/src/telemetry.ts b/packages/kilo-telemetry/src/telemetry.ts index 5e4379892da..7a80c9e39c0 100644 --- a/packages/kilo-telemetry/src/telemetry.ts +++ b/packages/kilo-telemetry/src/telemetry.ts @@ -12,6 +12,45 @@ export interface TelemetryProperties { vscodeVersion?: string } +export interface IndexingTelemetryProperties extends Record { + source: "scan" | "watcher" + provider: string + vectorStore: "lancedb" | "qdrant" + modelId?: string + trigger?: "background" | "manual" + mode?: "full" | "incremental" +} + +export interface IndexingCompletedTelemetryProperties extends IndexingTelemetryProperties { + trigger: "background" | "manual" + mode: "full" | "incremental" + filesIndexed: number + filesDiscovered: number + totalBlocks: number + batchErrors: number +} + +export interface IndexingFileCountTelemetryProperties extends IndexingTelemetryProperties { + mode: "full" | "incremental" + discovered: number + candidate: number +} + +export interface IndexingRetryTelemetryProperties extends IndexingTelemetryProperties { + mode: "full" | "incremental" + attempt: number + maxRetries: number + batchSize: number + error: string +} + +export interface IndexingErrorTelemetryProperties extends IndexingTelemetryProperties { + location: string + error: string + retryCount?: number + maxRetries?: number +} + export namespace Telemetry { let initialized = false let startTime = 0 @@ -166,6 +205,26 @@ export namespace Telemetry { track(TelemetryEvent.PLAN_FOLLOWUP, { sessionId, choice }) } + export function trackIndexingStarted(properties: IndexingTelemetryProperties) { + track(TelemetryEvent.INDEXING_STARTED, properties) + } + + export function trackIndexingCompleted(properties: IndexingCompletedTelemetryProperties) { + track(TelemetryEvent.INDEXING_COMPLETED, properties) + } + + export function trackIndexingFileCount(properties: IndexingFileCountTelemetryProperties) { + track(TelemetryEvent.INDEXING_FILE_COUNT, properties) + } + + export function trackIndexingBatchRetry(properties: IndexingRetryTelemetryProperties) { + track(TelemetryEvent.INDEXING_BATCH_RETRY, properties) + } + + export function trackIndexingError(properties: IndexingErrorTelemetryProperties) { + track(TelemetryEvent.INDEXING_ERROR, properties) + } + // Share export function trackShareCreated(sessionId: string) { track(TelemetryEvent.SHARE_CREATED, { sessionId }) diff --git a/packages/kilo-telemetry/src/tracer.ts b/packages/kilo-telemetry/src/tracer.ts index b3935f1f050..b49ef7e5375 100644 --- a/packages/kilo-telemetry/src/tracer.ts +++ b/packages/kilo-telemetry/src/tracer.ts @@ -1,6 +1,6 @@ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node" import { SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base" -import { Resource } from "@opentelemetry/resources" +import { resourceFromAttributes } from "@opentelemetry/resources" import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions" import type { Tracer } from "@opentelemetry/api" import { PostHogSpanExporter } from "./otel-exporter.js" @@ -36,7 +36,7 @@ export namespace TracerSetup { exporter.setEnabled(options.enabled) provider = new NodeTracerProvider({ - resource: new Resource({ + resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: options.appName, [ATTR_SERVICE_VERSION]: options.version, }), diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 652953d00e1..fc122b26d99 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.14", + "version": "7.2.30", "type": "module", "license": "MIT", "exports": { @@ -115,7 +115,7 @@ }, "dependencies": { "@kobalte/core": "0.13.11", - "@opencode-ai/util": "workspace:*", + "@opencode-ai/shared": "workspace:*", "@pierre/diffs": "catalog:", "@solid-primitives/lifecycle": "0.1.2", "@solid-primitives/media": "2.3.3", diff --git a/packages/kilo-ui/playwright.config.ts b/packages/kilo-ui/playwright.config.ts index a40fa591518..ae9f765438e 100644 --- a/packages/kilo-ui/playwright.config.ts +++ b/packages/kilo-ui/playwright.config.ts @@ -3,6 +3,7 @@ import { defineConfig, devices } from "@playwright/test" export default defineConfig({ testDir: "./tests", testMatch: "**/*.spec.ts", + snapshotPathTemplate: "../kilo-docs/public/img/screenshot-tests/kilo-ui/{arg}{ext}", fullyParallel: true, forbidOnly: !!process.env["CI"], retries: process.env["CI"] ? 1 : 0, diff --git a/packages/kilo-ui/src/components/context-tool-results.tsx b/packages/kilo-ui/src/components/context-tool-results.tsx index 6e927ac10c9..516903afd30 100644 --- a/packages/kilo-ui/src/components/context-tool-results.tsx +++ b/packages/kilo-ui/src/components/context-tool-results.tsx @@ -1,6 +1,6 @@ import { createMemo, createSignal, For, onMount } from "solid-js" import type { ToolPart } from "@kilocode/sdk/v2" -import { getFilename } from "@opencode-ai/util/path" +import { getFilename } from "@opencode-ai/shared/util/path" import { useReducedMotion } from "../hooks/use-reduced-motion" import { useI18n } from "../context/i18n" import { BasicTool } from "./basic-tool" diff --git a/packages/kilo-ui/src/components/diff.tsx b/packages/kilo-ui/src/components/diff.tsx index d3332345b3c..769fea652b9 100644 --- a/packages/kilo-ui/src/components/diff.tsx +++ b/packages/kilo-ui/src/components/diff.tsx @@ -1,4 +1,4 @@ -import { sampledChecksum } from "@opencode-ai/util/encode" +import { sampledChecksum } from "@opencode-ai/shared/util/encode" import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs" import { createMediaQuery } from "@solid-primitives/media" import { createEffect, createMemo, createSignal, on, onCleanup, splitProps, untrack } from "solid-js" diff --git a/packages/kilo-ui/src/components/grow-box.tsx b/packages/kilo-ui/src/components/grow-box.tsx index c8ea6f3b3a9..ccf7cbd6c9d 100644 --- a/packages/kilo-ui/src/components/grow-box.tsx +++ b/packages/kilo-ui/src/components/grow-box.tsx @@ -219,9 +219,15 @@ export function GrowBox(props: GrowBoxProps) { const targetHeight = () => Math.max(0, Math.ceil(body?.getBoundingClientRect().height ?? 0)) - const setHeight = (nextMode: "mount" | "toggle" = "mount") => { + // `next` is already measured; avoids a `body.getBoundingClientRect()` layout + // read in the watch-mode streaming hot path, where ResizeObserver hands us + // the contentRect/contentBoxSize the browser has already computed. + // Also skips sub-pixel updates (<2px) that the spring would absorb + // imperceptibly anyway — cuts per-token spring work when tokens add tiny + // height deltas. + const setHeightMeasured = (next: number, nextMode: "mount" | "toggle" = "mount") => { if (!root || !open()) return - const next = targetHeight() + if (Math.abs(next - springTarget) < 2) return if (reduce()) { springTarget = next height.jump(next) @@ -234,7 +240,6 @@ export function GrowBox(props: GrowBoxProps) { root.style.overflow = next > 0 ? "visible" : "clip" return } - if (next === springTarget) return const prev = currentHeight() if (Math.abs(next - prev) < 1) { springTarget = next @@ -250,6 +255,8 @@ export function GrowBox(props: GrowBoxProps) { height.set(next) } + const setHeight = (nextMode: "mount" | "toggle" = "mount") => setHeightMeasured(targetHeight(), nextMode) + onMount(() => { if (!root || !body) return @@ -294,12 +301,19 @@ export function GrowBox(props: GrowBoxProps) { }) if (watch()) { - observer = new ResizeObserver(() => { + // Reuse the browser-measured contentBoxSize/contentRect from the + // observer entries instead of calling body.getBoundingClientRect() — + // during streaming this fires ~60Hz, and each gBCR would force a + // synchronous layout pass (profile showed ~9% of blocked main-thread + // time in gBCR calls from this callback). + observer = new ResizeObserver((entries) => { if (!open()) return + const last = entries[entries.length - 1] + const measured = Math.max(0, Math.ceil(last?.contentBoxSize?.[0]?.blockSize ?? last?.contentRect?.height ?? 0)) if (resizeFrame !== undefined) return resizeFrame = requestAnimationFrame(() => { resizeFrame = undefined - setHeight("mount") + setHeightMeasured(measured, "mount") }) }) observer.observe(body) diff --git a/packages/kilo-ui/src/components/icon.tsx b/packages/kilo-ui/src/components/icon.tsx index 90f437ca620..130949d4cf3 100644 --- a/packages/kilo-ui/src/components/icon.tsx +++ b/packages/kilo-ui/src/components/icon.tsx @@ -1 +1,45 @@ -export * from "@opencode-ai/ui/icon" +import { Icon as Upstream, type IconProps as Props } from "@opencode-ai/ui/icon" +import { splitProps } from "solid-js" + +const icons = { + "circuit-board": ``, + organization: ``, +} + +type Name = keyof typeof icons + +export interface IconProps extends Omit { + name: Props["name"] | Name +} + +export function Icon(props: IconProps) { + const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) + const name = local.name as Name + if (!(name in icons)) { + return ( + + ) + } + return ( +
+ +
+ ) +} diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index b4a7c60c159..5b90068c9c6 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -221,6 +221,20 @@ html[data-theme="kilo-vscode"] [data-component="bash-output"] { margin-bottom: 0px; } +[data-component="todos"] { + [data-slot="message-part-todo-hidden"] { + padding: 2px 0 2px 28px; + color: var(--text-weaker); + font-size: var(--font-size-small); + line-height: var(--line-height-normal); + } + + [data-slot="message-part-todo-content"][data-changed="changed"] { + color: var(--text-base); + font-weight: var(--font-weight-medium); + } +} + /* User message copy button: collapse wrapper when idle, show on hover. * The wrapper must be tall enough to fully contain the button (20px) * so that hovering the button keeps [data-component="user-message"] diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index e7392935d2a..18dc83834bf 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -43,8 +43,8 @@ import { Checkbox } from "./checkbox" import { DiffChanges } from "./diff-changes" import { Markdown } from "./markdown" import { ImagePreview } from "./image-preview" -import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path" -import { checksum } from "@opencode-ai/util/encode" +import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/shared/util/path" +import { checksum } from "@opencode-ai/shared/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" import { TextShimmer } from "@opencode-ai/ui/text-shimmer" @@ -85,6 +85,17 @@ interface Diagnostic { severity?: number } +type TodoView = { + mode?: "full" | "compact" + todos?: TodoItem[] + hiddenBefore?: number + hiddenAfter?: number +} + +type TodoItem = Todo & { + changed?: boolean +} + function getDiagnostics( diagnosticsByFile: Record | undefined, filePath: string | undefined, @@ -2022,11 +2033,28 @@ ToolRegistry.register({ const filename = () => getFilename(props.input.filePath ?? "") const pending = () => busy(props.status) const reveal = useToolReveal(pending, () => props.reveal !== false) + const before = () => props.metadata?.filediff?.before ?? props.input.oldString ?? "" + const after = () => props.metadata?.filediff?.after ?? props.input.newString ?? "" + const canOpenDiff = () => !!data.openDiff && !!path() && (before() !== "" || after() !== "") + const canOpenFile = () => !!data.openFile && !!path() const handleFileClick = (e: MouseEvent) => { - if (!data.openFile || !props.input.filePath) return e.stopPropagation() - data.openFile(props.input.filePath) + + if (canOpenDiff()) { + data.openDiff!({ + file: path(), + before: before(), + after: after(), + additions: props.metadata?.filediff?.additions ?? 0, + deletions: props.metadata?.filediff?.deletions ?? 0, + }) + return + } + + if (canOpenFile()) { + data.openFile!(path()) + } } return ( @@ -2049,7 +2077,7 @@ ToolRegistry.register({ path={props.input.filePath?.includes("/") ? getDirectory(props.input.filePath!) : undefined} changes={props.metadata.filediff} animate={reveal()} - onClick={data.openFile && props.input.filePath ? handleFileClick : undefined} + onClick={canOpenDiff() || canOpenFile() ? handleFileClick : undefined} /> )} @@ -2072,12 +2100,12 @@ ToolRegistry.register({ component={fileComponent} mode="diff" before={{ - name: props.metadata?.filediff?.file || props.input.filePath, - contents: props.metadata?.filediff?.before || props.input.oldString, + name: path(), + contents: before(), }} after={{ - name: props.metadata?.filediff?.file || props.input.filePath, - contents: props.metadata?.filediff?.after || props.input.newString, + name: path(), + contents: after(), }} /> @@ -2386,6 +2414,7 @@ ToolRegistry.register({ name: "todowrite", render(props) { const i18n = useI18n() + const view = createMemo(() => (isTodoView(props.metadata?.view) ? props.metadata.view : undefined)) const todos = createMemo(() => { const meta = props.metadata?.todos if (Array.isArray(meta)) return meta @@ -2395,6 +2424,7 @@ ToolRegistry.register({ return [] }) + const shown = createMemo(() => view()?.todos ?? todos()) const pending = createMemo(() => busy(props.status)) const subtitle = createMemo(() => { @@ -2417,20 +2447,27 @@ ToolRegistry.register({ /> } > - +
- - {(todo: Todo) => ( + 0}> +
{hiddenText("earlier", view()?.hiddenBefore ?? 0)}
+
+ + {(todo: TodoItem) => ( {todo.content} )} + 0}> +
{hiddenText("later", view()?.hiddenAfter ?? 0)}
+
@@ -2438,6 +2475,17 @@ ToolRegistry.register({ }, }) +function isTodoView(value: unknown): value is TodoView { + if (!value || typeof value !== "object") return false + const view = value as TodoView + return Array.isArray(view.todos) +} + +function hiddenText(dir: "earlier" | "later", count: number) { + const noun = count === 1 ? "to-do" : "to-dos" + return `${count} ${dir} ${noun} hidden` +} + ToolRegistry.register({ name: "question", render(props) { diff --git a/packages/kilo-ui/tests/visual-regression.spec.ts b/packages/kilo-ui/tests/visual-regression.spec.ts index 385f1b61809..90146045d13 100644 --- a/packages/kilo-ui/tests/visual-regression.spec.ts +++ b/packages/kilo-ui/tests/visual-regression.spec.ts @@ -163,6 +163,6 @@ for (const story of stories) { // Use [component, variant] path so snapshots are grouped per component dir. const [component, variant] = story.id.split("--") const root = page.locator("#storybook-root") - await expect(root).toHaveScreenshot([component, `${variant}.png`]) + await expect(root).toHaveScreenshot(["visual-regression", component, `${variant}.png`]) }) } diff --git a/packages/kilo-vscode/AGENTS.md b/packages/kilo-vscode/AGENTS.md index ff630a3ac06..898161058f8 100644 --- a/packages/kilo-vscode/AGENTS.md +++ b/packages/kilo-vscode/AGENTS.md @@ -26,38 +26,38 @@ Every client spawns or connects to a `kilo serve` process and communicates via H └──────────┘ └─────────┘ └──────────────┘ ``` -| Product | Package | What it is | How it uses the CLI | -| -------------------------- | -------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------- | -| Kilo CLI (TUI) | `packages/opencode/` | Interactive terminal UI (SolidJS + OpenTUI) | In-process — TUI and server run together | -| Kilo CLI (`kilo run`) | `packages/opencode/` | Non-interactive headless mode for scripting | In-process — no network socket | -| **Kilo VS Code Extension** | **`packages/kilo-vscode/`** | VS Code extension with sidebar chat + Agent Manager | Bundles CLI binary, spawns `kilo serve --port 0` as child process | -| OpenCode Desktop | `packages/desktop/` | Standalone native app (Tauri) | Bundles CLI binary as sidecar, spawns `kilo serve` | -| OpenCode Web (`kilo web`) | `packages/opencode/` + `packages/app/` | Browser-based UI | CLI starts server + opens browser | +| Product | Package | What it is | How it uses the CLI | +|---|---|---|---| +| Kilo CLI (TUI) | `packages/opencode/` | Interactive terminal UI (SolidJS + OpenTUI) | In-process — TUI and server run together | +| Kilo CLI (`kilo run`) | `packages/opencode/` | Non-interactive headless mode for scripting | In-process — no network socket | +| **Kilo VS Code Extension** | **`packages/kilo-vscode/`** | VS Code extension with sidebar chat + Agent Manager | Bundles CLI binary, spawns `kilo serve --port 0` as child process | +| OpenCode Desktop | `packages/desktop/` | Standalone native app (Tauri) | Bundles CLI binary as sidecar, spawns `kilo serve` | +| OpenCode Web (`kilo web`) | `packages/opencode/` + `packages/app/` | Browser-based UI | CLI starts server + opens browser | **OpenCode Desktop** (`packages/desktop/`) and `kilo web` both render the shared `@opencode-ai/app` SolidJS frontend (`packages/app/`). They differ only in the platform layer (Tauri native APIs vs browser APIs). Neither has any relationship to this extension or the Agent Manager — they are independent clients of the same `kilo serve` backend. ### Kilo-Domain Packages -| Package | Name | Role | -| -------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `packages/kilo-vscode/` | `kilo-code` | **This package.** VS Code extension. | -| `packages/kilo-gateway/` | `@kilocode/kilo-gateway` | Auth (device flow), AI provider routing (OpenRouter), Kilo API integration (profile, balance, teams) | -| `packages/kilo-ui/` | `@kilocode/kilo-ui` | SolidJS component library (40+ components, built on `@kobalte/core`). Shared by this extension's webview and `packages/app/` | -| `packages/kilo-telemetry/` | `@kilocode/kilo-telemetry` | PostHog analytics + OpenTelemetry tracing for the CLI | -| `packages/kilo-i18n/` | `@kilocode/kilo-i18n` | Translation strings (16 languages) | -| `packages/kilo-docs/` | `@kilocode/kilo-docs` | Documentation site (Next.js + Markdoc) | +| Package | Name | Role | +|---|---|---| +| `packages/kilo-vscode/` | `kilo-code` | **This package.** VS Code extension. | +| `packages/kilo-gateway/` | `@kilocode/kilo-gateway` | Auth (device flow), AI provider routing (OpenRouter), Kilo API integration (profile, balance, teams) | +| `packages/kilo-ui/` | `@kilocode/kilo-ui` | SolidJS component library (40+ components, built on `@kobalte/core`). Shared by this extension's webview and `packages/app/` | +| `packages/kilo-telemetry/` | `@kilocode/kilo-telemetry` | PostHog analytics + OpenTelemetry tracing for the CLI | +| `packages/kilo-i18n/` | `@kilocode/kilo-i18n` | Translation strings (16 languages) | +| `packages/kilo-docs/` | `@kilocode/kilo-docs` | Documentation site (Next.js + Markdoc) | ### Upstream OpenCode Packages (not Kilo-specific) -| Package | Name | Role | -| -------------------- | ---------------------- | ---------------------------------------------------------------------------------------- | -| `packages/opencode/` | `@kilocode/cli` | Core CLI — forked from upstream OpenCode. AI agents, tools, sessions, server. | -| `packages/sdk/js/` | `@kilocode/sdk` | Auto-generated TypeScript SDK client for the server API. Do not edit `src/gen/` by hand. | -| `packages/app/` | `@opencode-ai/app` | Shared SolidJS web UI consumed by desktop app and `kilo web` | -| `packages/desktop/` | `@opencode-ai/desktop` | Tauri desktop app shell | -| `packages/ui/` | `@opencode-ai/ui` | Shared UI primitives | -| `packages/util/` | `@opencode-ai/util` | Shared utilities (error, path, retry, slug) | -| `packages/plugin/` | `@kilocode/plugin` | Plugin/tool interface definitions | +| Package | Name | Role | +|---|---|---| +| `packages/opencode/` | `@kilocode/cli` | Core CLI — forked from upstream OpenCode. AI agents, tools, sessions, server. | +| `packages/sdk/js/` | `@kilocode/sdk` | Auto-generated TypeScript SDK client for the server API. Do not edit `src/gen/` by hand. | +| `packages/app/` | `@opencode-ai/app` | Shared SolidJS web UI consumed by desktop app and `kilo web` | +| `packages/desktop/` | `@opencode-ai/desktop` | Tauri desktop app shell | +| `packages/ui/` | `@opencode-ai/ui` | Shared UI primitives | +| `packages/util/` | `@opencode-ai/util` | Shared utilities (error, path, retry, slug) | +| `packages/plugin/` | `@kilocode/plugin` | Plugin/tool interface definitions | ## Commands @@ -155,15 +155,15 @@ The Agent Manager is a feature within this extension (not a separate product). I ### How It Differs From the Sidebar -| Aspect | Sidebar | Agent Manager | -| ------------- | -------------------------- | --------------------------------------------------- | -| Location | Activity bar sidebar panel | Editor tab (full panel) | -| Sessions | Single session at a time | Multiple parallel sessions with tabbed UI | -| Git isolation | Uses workspace root | Each session can get its own worktree branch | -| State | No dedicated state file | `.kilo/agent-manager.json` | -| Terminals | None | Dedicated VS Code terminal per session | -| Setup scripts | None | Configurable `.kilo/setup-script` runs per worktree | -| Multi-version | Not supported | Up to 4 parallel worktrees with the same prompt | +| Aspect | Sidebar | Agent Manager | +|---|---|---| +| Location | Activity bar sidebar panel | Editor tab (full panel) | +| Sessions | Single session at a time | Multiple parallel sessions with tabbed UI | +| Git isolation | Uses workspace root | Each session can get its own worktree branch | +| State | No dedicated state file | `.kilo/agent-manager.json` | +| Terminals | None | Dedicated VS Code terminal per session | +| Setup scripts | None | Configurable `.kilo/setup-script` runs per worktree | +| Multi-version | Not supported | Up to 4 parallel worktrees with the same prompt | ### Architecture @@ -186,6 +186,12 @@ New webview features must use **`@kilocode/kilo-ui`** components instead of raw - **Prefer kilo-ui styles**: Always reuse existing kilo-ui CSS variables, tokens, and component styles instead of writing custom CSS. If a style doesn't exist in kilo-ui yet, add it there and reuse it rather than inlining or duplicating styles in the webview. - **Icons**: kilo-ui has 75+ custom SVG icons in [`packages/ui/src/components/icon.tsx`](../../packages/ui/src/components/icon.tsx). To list all available icon names: `node -e "const c=require('fs').readFileSync('../../packages/ui/src/components/icon.tsx','utf8');[...c.matchAll(/^\\s{2}[\"']?([\\w-]+)[\"']?:\\s*\x60/gm)].map(m=>m[1]).sort().forEach(n=>console.log(n))"`. Icon names use both hyphenated (`arrow-left`) and bare-word (`brain`, `console`, `providers`) keys. +## Docs Screenshot Stories + +When adding or updating Storybook stories for screenshots used by docs, make the story content match the docs page closely before replacing the docs image. Do not replace screenshots from VSCode Legacy docs tabs or sections. + +Generated screenshot baselines live under `packages/kilo-docs/public/img/screenshot-tests/` and are referenced from docs as `/docs/img/screenshot-tests/...`. If a generated VS Code visual-regression screenshot is used in docs, add the docs usage to the `DOCS` map in `tests/visual-regression.spec.ts` and keep `tests/visual-regression.spec.mts` in sync while that file exists. + ## Debugging - Extension logs: "Extension Host" output channel (not Debug Console) diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 265535291a6..d3e2e0d3ea6 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,138 @@ # kilo-code +## 7.2.30 + +### Patch Changes + +- [#9435](https://github.com/Kilo-Org/kilocode/pull/9435) [`9186cb4`](https://github.com/Kilo-Org/kilocode/commit/9186cb440afd37c43bae6c99f9b95b23db698e5c) - Fix unreadable section titles on the Auto-Approve settings page when using a light VS Code color theme. Tool headers (External Directory, Bash, Read, Edit, etc.) now follow the active VS Code theme foreground color instead of always rendering in white. + +- [#9662](https://github.com/Kilo-Org/kilocode/pull/9662) [`bf60cde`](https://github.com/Kilo-Org/kilocode/commit/bf60cdedd6fc4af2918d514ca513f03e18a3133d) - Show auto-approve status in the session prompt controls and keep it synced across extension sessions. + +- [#9575](https://github.com/Kilo-Org/kilocode/pull/9575) [`5b26cfe`](https://github.com/Kilo-Org/kilocode/commit/5b26cfe5facb8e6227cddadb259715185a82cded) - Fix switching autocomplete model back to Codestral not persisting. + +- [#9481](https://github.com/Kilo-Org/kilocode/pull/9481) [`a76ab95`](https://github.com/Kilo-Org/kilocode/commit/a76ab9510361ed1ac40cc40d51817006ed6eac8e) - Improve the sidebar chat toolbar so the changes button collapses cleanly and keeps diff stats visible in its tooltip. + +- [#9576](https://github.com/Kilo-Org/kilocode/pull/9576) [`11f2e2a`](https://github.com/Kilo-Org/kilocode/commit/11f2e2afbe0ba1691bbaadcb4a3ceec0e009c9a5) Thanks [@johnnyeric](https://github.com/johnnyeric)! - Open edit-tool diffs in side-by-side (split) mode by default; permission-dock expand stays unified. + +- [#9450](https://github.com/Kilo-Org/kilocode/pull/9450) [`2032fe4`](https://github.com/Kilo-Org/kilocode/commit/2032fe4c4e574aa0664a1ab91e34633ce5b261f9) - Fix a session hang that could occur when multiple Kilo panels showed the same permission prompt, or when a subagent's permission was replied to from the wrong worktree. Replies are now routed to the exact CLI instance that holds the pending permission, and stale/unknown permissions surface a clear error so the UI doesn't leave buttons permanently disabled. + +- [#9667](https://github.com/Kilo-Org/kilocode/pull/9667) [`7eaf059`](https://github.com/Kilo-Org/kilocode/commit/7eaf059789d74c3fd57c88929a0846f18d9f5294) - Keep Agent Manager local tab sessions scoped to the main workspace after switching from a worktree. + +- [#9679](https://github.com/Kilo-Org/kilocode/pull/9679) [`867be23`](https://github.com/Kilo-Org/kilocode/commit/867be234549459ca47741e998aa54af23a09d8bc) - Restore prompt focus when the model selector is dismissed with Escape. + +- [#9670](https://github.com/Kilo-Org/kilocode/pull/9670) [`7575dce`](https://github.com/Kilo-Org/kilocode/commit/7575dce378bd136ac5eef46321a311454c786832) - Keep checkpoint revert actions available when switching models clears a stale provider error. + +- [#9633](https://github.com/Kilo-Org/kilocode/pull/9633) [`0918bb4`](https://github.com/Kilo-Org/kilocode/commit/0918bb4216aef60fcefb3025a105cf6d250de52b) - Preserve "Free" when it is part of a model's name (such as "Kilo Auto Free"). The parenthesized "(free)" suffix is still stripped so the separate Free label can be shown instead. + +- [#9672](https://github.com/Kilo-Org/kilocode/pull/9672) [`c923625`](https://github.com/Kilo-Org/kilocode/commit/c923625ea629d4f963d80baf81862d984ae55785) - Protect Agent Manager sessions and worktrees from being reset when persisted state fails to load after IDE restart. + +- [#9628](https://github.com/Kilo-Org/kilocode/pull/9628) [`6130a3e`](https://github.com/Kilo-Org/kilocode/commit/6130a3ea66c6a323710fdc2d325fac87011f6b85) - Show paid Kilo models to signed-out users so selecting one prompts them to log in. + +- [#9481](https://github.com/Kilo-Org/kilocode/pull/9481) [`18468b8`](https://github.com/Kilo-Org/kilocode/commit/18468b8e26577087834ace8b7aaee72d3176f25b) - Expose sidebar session, worktree, and agent manager actions above the prompt, including quick and advanced worktree creation. + +- [#9680](https://github.com/Kilo-Org/kilocode/pull/9680) [`b99dcee`](https://github.com/Kilo-Org/kilocode/commit/b99dcee596ace9021af041629881b7e7f0e45cda) - Support opening the reasoning effort selector from `/variant` when the selected model has variants. + +- [#9614](https://github.com/Kilo-Org/kilocode/pull/9614) [`6908f8a`](https://github.com/Kilo-Org/kilocode/commit/6908f8ae6df201233ad75df6c000b109ecaea637) - Start Agent Manager new tasks in the selected worktree instead of the workspace root. + +- Updated dependencies [[`28a0eae`](https://github.com/Kilo-Org/kilocode/commit/28a0eae4b0b940482222f6671a6885b575b2ad9c)]: + - @kilocode/kilo-indexing@7.1.4 + +## 7.2.26 + +### Minor Changes + +- [#9310](https://github.com/Kilo-Org/kilocode/pull/9310) [`f067a90`](https://github.com/Kilo-Org/kilocode/commit/f067a908a9fe161cfb87298b593b73e7e9bdb0b6) - Support selecting Mercury Edit by Inception for autocomplete. + +- [#9548](https://github.com/Kilo-Org/kilocode/pull/9548) [`c5614cc`](https://github.com/Kilo-Org/kilocode/commit/c5614cc54ed16e90151cbe1ceed5213b472383ff) - Add Settings header buttons to open the project and global Kilo config files directly in VS Code. + +### Patch Changes + +- [#9552](https://github.com/Kilo-Org/kilocode/pull/9552) [`3f0bf32`](https://github.com/Kilo-Org/kilocode/commit/3f0bf322c1e2397cffaf22739df8d41e1957f5ba) - Fix clearing an agent's Model Override in Agent Behaviour settings. Previously, clearing the field and saving would repopulate the old value because the empty input was sent as `undefined` and dropped by `JSON.stringify`, so the backend never received a delete instruction. The field now reverts to the global default model as expected. + +- [#9551](https://github.com/Kilo-Org/kilocode/pull/9551) [`b344ac9`](https://github.com/Kilo-Org/kilocode/commit/b344ac97c6a2d7caecf85b43b8c6a07ccb352b6b) - Restore disabled provider management in the VS Code extension provider settings. + +## 7.2.25 + +## 7.2.23 + +### Patch Changes + +- [#9423](https://github.com/Kilo-Org/kilocode/pull/9423) [`a87a461`](https://github.com/Kilo-Org/kilocode/commit/a87a461e971864377d2139c35e778cc98d5cca19) - Fix two Agent Manager tab-bar regressions. New session and terminal tabs now consistently open at the right end of the tab bar instead of sometimes slipping in front of existing tabs. Dragging a terminal tab now shows the same floating label preview as dragging a session tab. + +- [#9026](https://github.com/Kilo-Org/kilocode/pull/9026) [`61516b4`](https://github.com/Kilo-Org/kilocode/commit/61516b4d3f5a87f623a34b9112b8e1ff3725f93c) - Support attaching Git changes from prompt mentions in the VS Code extension. + +## 7.2.22 + +### Patch Changes + +- [#9444](https://github.com/Kilo-Org/kilocode/pull/9444) [`35f13cb`](https://github.com/Kilo-Org/kilocode/commit/35f13cbc8678f80cf6870c233f74f47b028bf705) - Fix restoring and paginating very long VS Code sessions. + +## 7.2.21 + +### Minor Changes + +- [#9268](https://github.com/Kilo-Org/kilocode/pull/9268) [`48c0553`](https://github.com/Kilo-Org/kilocode/commit/48c0553bb7b8abfa06fb352ad7a9cdc7f1af4bc5) - Open xterm.js-powered terminal tabs in the Agent Manager. Click the chevron next to the `+` tab button and pick "New Terminal" (or press `Cmd+Shift+T` / `Ctrl+Shift+T`) to spawn a real shell in the selected worktree or Local directory. Terminals render as proper tabs alongside agent sessions, support mixed drag-reorder with session tabs, and persist their position across webview reloads. The existing VS Code integrated terminal shortcut (`Cmd+/`) is unchanged. + +- [#9336](https://github.com/Kilo-Org/kilocode/pull/9336) [`85c578e`](https://github.com/Kilo-Org/kilocode/commit/85c578ed844eba7350ce915cff6b4a98f3eb1bbf) - Add the initial JetBrains session chat UI and improve sandbox debug logging for tracing chat events across frontend and backend. + +### Patch Changes + +- [#9335](https://github.com/Kilo-Org/kilocode/pull/9335) [`6015ac6`](https://github.com/Kilo-Org/kilocode/commit/6015ac6e7d85fc110a99562674484d3d00167525) - Restore explicit Submit behavior for single-choice question prompts in the VS Code extension so option clicks stay visible for review instead of immediately sending the answer. + +- [#9332](https://github.com/Kilo-Org/kilocode/pull/9332) [`0bda9d1`](https://github.com/Kilo-Org/kilocode/commit/0bda9d15ed5ef99fe149fd680a813ca3b4c1d050) - Fix mid-turn message handling so a new prompt sent while the assistant is working no longer aborts the in-flight response. The current LLM reply streams to completion, any pending suggestion or question is automatically dismissed, and the new prompt runs immediately after the current step instead of waiting for the entire multi-step turn to finish. + +- [#9119](https://github.com/Kilo-Org/kilocode/pull/9119) [`8e75084`](https://github.com/Kilo-Org/kilocode/commit/8e750846da39c6e78478b468b68fdefcaa37f44f) - Fix TUI freeze on huge-file diffs. Session-summary and file-view patches now use git directly instead of a JavaScript Myers implementation, so files of any size render a full diff without blocking the session. + +- [#9341](https://github.com/Kilo-Org/kilocode/pull/9341) [`00ec003`](https://github.com/Kilo-Org/kilocode/commit/00ec003c11476d995556d2b975b4da058f8b958c) - Significantly speed up LLM token streaming in long sessions. The chat view now stays responsive while the model streams a reply, even in sessions with hundreds of messages. Previously, each SSE batch produced ~1.3 seconds of visible freeze (roughly 80 dropped frames); streaming ticks are now inside a single animation frame. + +- Updated dependencies [[`00ec003`](https://github.com/Kilo-Org/kilocode/commit/00ec003c11476d995556d2b975b4da058f8b958c)]: + - @opencode-ai/ui@7.2.21 + - @kilocode/kilo-ui@7.2.21 + +## 7.2.19 + +## 7.2.18 + +## 7.2.17 + +### Minor Changes + +- [#9207](https://github.com/Kilo-Org/kilocode/pull/9207) [`4d85c23`](https://github.com/Kilo-Org/kilocode/commit/4d85c232681fb6844521bda5209dd021d742edd6) - Support forking Agent Manager sessions from a specific user message. + +- [#9244](https://github.com/Kilo-Org/kilocode/pull/9244) [`18442f6`](https://github.com/Kilo-Org/kilocode/commit/18442f695dee16ce98e1fe06c74cea74e6e18be0) - Support forking sidebar conversations from any user message. + +### Patch Changes + +- [#9305](https://github.com/Kilo-Org/kilocode/pull/9305) [`b4cb8c3`](https://github.com/Kilo-Org/kilocode/commit/b4cb8c3c04b6f7918032840785d3f6a6a8d077ac) - Prewarm the autocomplete backend on activation so inline completions are ready on first keystroke without opening the Kilo sidebar, and refresh autocomplete state when workspace folders change. + +- [#9318](https://github.com/Kilo-Org/kilocode/pull/9318) [`bfe4482`](https://github.com/Kilo-Org/kilocode/commit/bfe44822ed51292606daeb34325a0847d381ba40) - Prevent canceled autocomplete debounce requests from staying pending indefinitely. + +- [#9316](https://github.com/Kilo-Org/kilocode/pull/9316) [`bf98bbe`](https://github.com/Kilo-Org/kilocode/commit/bf98bbe34d2b143d0f7e5f50aa3732715ded75a0) - Cap autocomplete ignore path caching so long sessions do not retain unlimited paths. + +- [#9239](https://github.com/Kilo-Org/kilocode/pull/9239) [`2b17a7b`](https://github.com/Kilo-Org/kilocode/commit/2b17a7b4e80bb2bd30bd95d047c31ad17dd339b6) - Fix custom provider model and variant deletions being silently reverted on save. Removing a model or reasoning variant from a custom provider now actually removes it from your config. + +- [#9241](https://github.com/Kilo-Org/kilocode/pull/9241) [`6c0439b`](https://github.com/Kilo-Org/kilocode/commit/6c0439b0003d2bd011a9aa5bf0b634c18a0e9771) - Fix custom provider variant name validation showing inline errors correctly when a variant name is left empty + +- [#9208](https://github.com/Kilo-Org/kilocode/pull/9208) [`acd5798`](https://github.com/Kilo-Org/kilocode/commit/acd5798d99027e4281c8b7fb6c741ff8d63d8c2c) - Fixed Agent Manager incorrectly duplicating worktree sessions into the Local tab when forking a session or using "Continue in Worktree". + +- [#7960](https://github.com/Kilo-Org/kilocode/pull/7960) [`d1aed36`](https://github.com/Kilo-Org/kilocode/commit/d1aed364fb5d8aa6d45800c3647a7e1a1287c427) - Add KiloClaw chat panel to the VS Code extension + +- [#9245](https://github.com/Kilo-Org/kilocode/pull/9245) [`ec5ac2e`](https://github.com/Kilo-Org/kilocode/commit/ec5ac2e19f69052dae375c9ddc2f7edb67c62358) - Fix the "Continue here" button not submitting after a plan is finished. Picking an option on a single-question prompt now sends the reply immediately — matching the CLI behaviour — and the redundant "Type your own answer" row no longer appears on the plan follow-up question. + +- [#9246](https://github.com/Kilo-Org/kilocode/pull/9246) [`c529142`](https://github.com/Kilo-Org/kilocode/commit/c529142d7273092f657f02c66f39f06e9ed55ecc) - Translate the follow-up prompt shown after finishing a plan. The "Ready to implement?" question, "Start new session" and "Continue here" buttons, and their descriptions now respect the language configured in the VS Code sidebar. + +- [#9129](https://github.com/Kilo-Org/kilocode/pull/9129) [`60709fd`](https://github.com/Kilo-Org/kilocode/commit/60709fd015aa206c7466a6197642f144280ea2be) - Fix duplicate text shown when typing a custom answer in the question tool. The preview text above the input field is now hidden while editing, so only the input value is visible. + +- [#9195](https://github.com/Kilo-Org/kilocode/pull/9195) [`7038ce0`](https://github.com/Kilo-Org/kilocode/commit/7038ce0612179c72d4d369cdd7e337995c3a56fd) - Fix queued follow-up messages so they stay stacked at the bottom of the chat while the previous turn is still being processed, instead of getting interleaved with tool output. + +- [#9194](https://github.com/Kilo-Org/kilocode/pull/9194) [`0ace2f9`](https://github.com/Kilo-Org/kilocode/commit/0ace2f960f634c27199468c214cebd0154c998db) - Restore earlier chat history when scrolling through virtualized message lists. + +- [#9236](https://github.com/Kilo-Org/kilocode/pull/9236) [`6f17bb7`](https://github.com/Kilo-Org/kilocode/commit/6f17bb753bc83ba8fbe999fefe109275370637d1) - Fix chat scroll jumping back to the bottom while the session is busy — you can now scroll up to read earlier context while a response is streaming or while a question is waiting for your answer. + +- [#9199](https://github.com/Kilo-Org/kilocode/pull/9199) [`7a1388d`](https://github.com/Kilo-Org/kilocode/commit/7a1388d8d5849aac017a02a33527603c48d0b0a4) - Fix a stuck session state when the suggest tool was left open. If the suggestion was never accepted or dismissed (for example, because VS Code was closed while it was showing), the session stayed marked as busy and any follow-up messages appeared queued forever. The session is now marked idle while waiting for a response to a suggestion. + +- [#9141](https://github.com/Kilo-Org/kilocode/pull/9141) [`61ec925`](https://github.com/Kilo-Org/kilocode/commit/61ec925c509d6437942c23a604aaaeefa16d3c3f) - Restore spacing between sub-agent output and the following user message in the VS Code chat. + ## 7.2.14 ### Minor Changes diff --git a/packages/kilo-vscode/README.md b/packages/kilo-vscode/README.md index 7856479ec78..40687268e9a 100644 --- a/packages/kilo-vscode/README.md +++ b/packages/kilo-vscode/README.md @@ -9,7 +9,6 @@ # 🚀 Kilo > Kilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent. -> #1 on OpenRouter. 1.5M+ Kilo Coders. 25T+ tokens processed - ✨ Generate code from natural language - ✅ Checks its own work diff --git a/packages/kilo-vscode/assets/icons/kilo-icon-font.woff2 b/packages/kilo-vscode/assets/icons/kilo-icon-font.woff2 new file mode 100644 index 00000000000..e3f4612c7a3 Binary files /dev/null and b/packages/kilo-vscode/assets/icons/kilo-icon-font.woff2 differ diff --git a/packages/kilo-vscode/docs/agent-behaviour/mcp-server-creation.md b/packages/kilo-vscode/docs/agent-behaviour/mcp-server-creation.md index 2b2096bb9c1..7f1c971405f 100644 --- a/packages/kilo-vscode/docs/agent-behaviour/mcp-server-creation.md +++ b/packages/kilo-vscode/docs/agent-behaviour/mcp-server-creation.md @@ -6,33 +6,33 @@ The legacy MCP Servers sub-tab (842 lines) had full server lifecycle management. ## Side-by-Side Comparison -| Feature | Legacy | New | -| ----------------------- | -------------------------------- | ------------------------------------------------------------- | -| View server list | Name + source badge + status dot | Name + command/URL | -| Add server | Via edit config file buttons | Not available | -| Remove server | Delete button + confirmation | Remove button + confirmation | -| Edit server config | Via edit config file buttons | Not available | -| Connection status | Colored dot (green/yellow/red) | Live status display | -| Enable/disable toggle | `ToggleSwitch` per server | Connect/disconnect toggle | -| Restart/refresh | Per-server refresh button | Not available | -| Refresh all | "Refresh All MCP Servers" button | Not available | -| Edit Global MCP config | Button to open file | Not available | -| Edit Project MCP config | Button to open file | Not available | -| Network timeout | Per-server dropdown (15s–60min) | Not available | -| Server source badge | Shows "global" / "project" | Not displayed | -| Empty state | N/A | "No MCP servers configured. Edit the opencode config file..." | +| Feature | Legacy | New | +|---|---|---| +| View server list | Name + source badge + status dot | Name + command/URL | +| Add server | Via edit config file buttons | Not available | +| Remove server | Delete button + confirmation | Remove button + confirmation | +| Edit server config | Via edit config file buttons | Not available | +| Connection status | Colored dot (green/yellow/red) | Live status display | +| Enable/disable toggle | `ToggleSwitch` per server | Connect/disconnect toggle | +| Restart/refresh | Per-server refresh button | Not available | +| Refresh all | "Refresh All MCP Servers" button | Not available | +| Edit Global MCP config | Button to open file | Not available | +| Edit Project MCP config | Button to open file | Not available | +| Network timeout | Per-server dropdown (15s–60min) | Not available | +| Server source badge | Shows "global" / "project" | Not displayed | +| Empty state | N/A | "No MCP servers configured. Edit the opencode config file..." | ### Expandable Server Detail (Legacy Only) When a server was connected and enabled, expanding it showed 5 tabs: -| Tab | Content | -| ------------ | --------------------------------------------------------------------- | -| Tools | List of MCP tools with tool count | -| Resources | List of resources + resource templates with count | -| Instructions | Server-provided instruction text (if present) | -| Logs | Error/log entries sorted by timestamp with count | -| Auth | OAuth debug info: auth status, token expiration, scopes, refresh info | +| Tab | Content | +|---|---| +| Tools | List of MCP tools with tool count | +| Resources | List of resources + resource templates with count | +| Instructions | Server-provided instruction text (if present) | +| Logs | Error/log entries sorted by timestamp with count | +| Auth | OAuth debug info: auth status, token expiration, scopes, refresh info | The new extension has none of this expandable detail. diff --git a/packages/kilo-vscode/docs/agent-behaviour/modes-subtab-parity.md b/packages/kilo-vscode/docs/agent-behaviour/modes-subtab-parity.md index 3ce93fff942..614f9d47cdc 100644 --- a/packages/kilo-vscode/docs/agent-behaviour/modes-subtab-parity.md +++ b/packages/kilo-vscode/docs/agent-behaviour/modes-subtab-parity.md @@ -8,68 +8,68 @@ The legacy "Modes" sub-tab was a 1794-line component with comprehensive mode man ### Mode/Agent CRUD -| Feature | Legacy (Modes) | New (Agents) | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | -| View modes/agents list | List with name + slug | Interactive clickable list with name + description + "custom" badges | -| Select mode to configure | Popover/Command searchable combobox | Click-to-edit in agent list | -| Create new mode | Full dialog: name, slug, save location, role definition, description, when-to-use, tool groups, custom instructions | "Create New Mode" button with form: name (validated), description, system prompt | -| Delete custom mode | Confirmation dialog | Delete button with confirmation dialog | -| Rename custom mode | Inline text field with save/cancel | Not available — name is immutable after creation | -| Export mode | Export to file button | Not available | -| Import mode | Import dialog (project/global) | Not available (marketplace handles this) | -| Edit modes config (JSON) | Dropdown: "Edit Global Modes" / "Edit Project Modes" | Not available | -| Marketplace button | Opens mode marketplace | Not available | +| Feature | Legacy (Modes) | New (Agents) | +|---|---|---| +| View modes/agents list | List with name + slug | Interactive clickable list with name + description + "custom" badges | +| Select mode to configure | Popover/Command searchable combobox | Click-to-edit in agent list | +| Create new mode | Full dialog: name, slug, save location, role definition, description, when-to-use, tool groups, custom instructions | "Create New Mode" button with form: name (validated), description, system prompt | +| Delete custom mode | Confirmation dialog | Delete button with confirmation dialog | +| Rename custom mode | Inline text field with save/cancel | Not available — name is immutable after creation | +| Export mode | Export to file button | Not available | +| Import mode | Import dialog (project/global) | Not available (marketplace handles this) | +| Edit modes config (JSON) | Dropdown: "Edit Global Modes" / "Edit Project Modes" | Not available | +| Marketplace button | Opens mode marketplace | Not available | ### Per-Mode/Agent Settings -| Setting | Legacy | New | -| ------------------------------------ | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | -| API Configuration (provider profile) | `