diff --git a/.changeset/auto-approve-header-light-theme.md b/.changeset/auto-approve-header-light-theme.md new file mode 100644 index 00000000000..9ba83920162 --- /dev/null +++ b/.changeset/auto-approve-header-light-theme.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +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. diff --git a/.changeset/autocomplete-model-switch-back.md b/.changeset/autocomplete-model-switch-back.md new file mode 100644 index 00000000000..19a09ec1037 --- /dev/null +++ b/.changeset/autocomplete-model-switch-back.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix switching autocomplete model back to Codestral not persisting. diff --git a/.changeset/chat-toolbar-changes.md b/.changeset/chat-toolbar-changes.md new file mode 100644 index 00000000000..2a9d574980c --- /dev/null +++ b/.changeset/chat-toolbar-changes.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Improve the sidebar chat toolbar so the changes button collapses cleanly and keeps diff stats visible in its tooltip. diff --git a/.changeset/edit-tool-diff-split-view.md b/.changeset/edit-tool-diff-split-view.md new file mode 100644 index 00000000000..cca8699d5ff --- /dev/null +++ b/.changeset/edit-tool-diff-split-view.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Open edit-tool diffs in side-by-side (split) mode by default; permission-dock expand stays unified. diff --git a/.changeset/openrouter-deepseek-reasoning.md b/.changeset/openrouter-deepseek-reasoning.md deleted file mode 100644 index ea67392d3e8..00000000000 --- a/.changeset/openrouter-deepseek-reasoning.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@kilocode/cli": patch -"@kilocode/kilo-gateway": patch ---- - -Fix multi-turn DeepSeek reasoning round-tripping on OpenRouter by bumping `@openrouter/ai-sdk-provider` to 2.8.1 in both the CLI and Kilo Gateway packages and letting the SDK handle reasoning details, plus pulling in upstream DeepSeek variant, reasoning-effort, and assistant-reasoning fixes. New DeepSeek conversations are fixed; existing sessions that already stored empty reasoning metadata may still need to be restarted. diff --git a/.changeset/sidebar-worktree-actions.md b/.changeset/sidebar-worktree-actions.md new file mode 100644 index 00000000000..8cab7eeb83c --- /dev/null +++ b/.changeset/sidebar-worktree-actions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Expose sidebar session, worktree, and agent manager actions above the prompt, including quick and advanced worktree creation. diff --git a/AGENTS.md b/AGENTS.md index 9ddcc480e77..a2d0fee935e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang - **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/` +- **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. diff --git a/bun.lock b/bun.lock index e8a96176641..cf6fa323bd1 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.2.24", + "version": "7.2.26", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -88,7 +88,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.2.24", + "version": "7.2.26", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -121,7 +121,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.2.24", + "version": "7.2.26", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -129,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", @@ -152,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:", @@ -172,7 +173,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.24", + "version": "7.2.26", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -201,7 +202,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.24", + "version": "7.2.26", "dependencies": { "@ai-sdk/alibaba": "1.0.17", "@ai-sdk/anthropic": "3.0.71", @@ -237,7 +238,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.2.24", + "version": "7.2.26", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -245,12 +246,43 @@ "typescript": "catalog:", }, }, + "packages/kilo-indexing": { + "name": "@kilocode/kilo-indexing", + "version": "7.1.3", + "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.24", + "version": "7.2.26", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "@opentelemetry/api": "1.9.0", @@ -270,7 +302,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.24", + "version": "7.2.26", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/shared": "workspace:*", @@ -305,10 +337,11 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.24", + "version": "7.2.26", "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:*", @@ -365,7 +398,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.24", + "version": "7.2.26", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -405,6 +438,7 @@ "@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:*", @@ -412,6 +446,7 @@ "@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:", @@ -422,8 +457,8 @@ "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", - "@opentui/core": "catalog:", - "@opentui/solid": "catalog:", + "@opentui/core": "0.1.99", + "@opentui/solid": "0.1.99", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -472,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", @@ -520,15 +556,15 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.24", + "version": "7.2.26", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", "zod": "catalog:", }, "devDependencies": { - "@opentui/core": "catalog:", - "@opentui/solid": "catalog:", + "@opentui/core": "0.1.99", + "@opentui/solid": "0.1.99", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", @@ -545,7 +581,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.24", + "version": "7.2.26", "dependencies": { "semver": "^7.6.3", }, @@ -556,7 +592,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.24", + "version": "7.2.26", "dependencies": { "cross-spawn": "catalog:", }, @@ -571,7 +607,7 @@ }, "packages/shared": { "name": "@opencode-ai/shared", - "version": "7.2.24", + "version": "7.2.26", "bin": { "opencode": "./bin/opencode", }, @@ -595,7 +631,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.24", + "version": "7.2.26", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -618,7 +654,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.24", + "version": "7.2.26", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -677,6 +713,7 @@ ], "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": { @@ -716,7 +753,7 @@ "@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", @@ -840,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=="], @@ -868,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=="], @@ -888,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=="], @@ -902,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=="], @@ -1400,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"], @@ -1418,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=="], @@ -1504,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=="], @@ -1792,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=="], @@ -2214,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=="], @@ -2222,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=="], @@ -2546,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=="], @@ -2560,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=="], @@ -2584,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=="], @@ -2698,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=="], @@ -2748,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=="], @@ -2822,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=="], @@ -3074,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=="], @@ -3284,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=="], @@ -3596,6 +3687,8 @@ "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=="], @@ -3708,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=="], @@ -3974,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=="], @@ -4008,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=="], @@ -4258,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=="], @@ -4562,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=="], @@ -4650,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=="], @@ -4702,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=="], @@ -4764,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=="], @@ -4846,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=="], @@ -4886,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=="], @@ -4952,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=="], @@ -5088,6 +5195,8 @@ "@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-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=="], "@manypkg/find-root/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -5106,6 +5215,8 @@ "@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=="], @@ -5194,6 +5305,8 @@ "@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=="], @@ -5354,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=="], @@ -5362,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=="], @@ -5382,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=="], @@ -5396,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=="], @@ -5482,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=="], @@ -5562,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=="], @@ -5656,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=="], @@ -5832,6 +5961,8 @@ "@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=="], @@ -5874,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=="], @@ -5896,10 +6029,14 @@ "@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=="], @@ -6042,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=="], @@ -6052,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=="], @@ -6264,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=="], @@ -6666,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..1c8e62bd825 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": 1776683584, + "narHash": "sha256-NuTLMrr10Tng72hurYG8jYQ4XKK8wnpJmOGcPiis96g=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8c11f88bb9573a10a7d6bf87161ef08455ac70b9", + "rev": "9dd5558b06dbdacbf635a3dd36dce1b1a7ee3a89", "type": "github" }, "original": { diff --git a/nix/hashes.json b/nix/hashes.json index 1e170f76193..c92c084061b 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-uDu9FY2G8j6AzpXAQ3WoWLJ9uyh5R7xqcAVzij6dpdU=", - "aarch64-linux": "sha256-GtVSse+wWwmFA9e3XkAp+1spPH2u4C2jqJ9HUKQRJ44=", - "aarch64-darwin": "sha256-UrZIY7v59gB0k0+/CIXA/NFrR3FSE+5Lcsz9qZffsqM=", - "x86_64-darwin": "sha256-/thTDanC0f1rczDj7RbgeFyaRfuhMdbRXrgfH6KRejs=" + "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/package.json b/package.json index aa556d9729b..8eb5fcbce54 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,10 @@ "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: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", @@ -29,7 +29,7 @@ "@effect/opentelemetry": "4.0.0-beta.48", "@effect/platform-node": "4.0.0-beta.48", "@npmcli/arborist": "9.4.0", - "@types/bun": "1.3.11", + "@types/bun": "1.3.12", "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", @@ -141,10 +141,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", "stream-chat@9.38.0": "patches/stream-chat@9.38.0.patch" }, - "version": "7.2.24", + "version": "7.2.26", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index e45df199c81..2f36de77acb 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.2.24", + "version": "7.2.26", "description": "", "type": "module", "exports": { 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/app.tsx b/packages/app/src/app.tsx index dc75754d477..6bec1de84f0 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -141,13 +141,11 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { }> - - - - {props.children} - - - + + + {props.children} + + @@ -293,20 +291,22 @@ export function AppInterface(props: { > - - - {routerProps.children}} - > - - - - - - - - + + + + {routerProps.children}} + > + + + + + + + + + diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index ea5d70065ad..8eb12daf52e 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -12,6 +12,7 @@ import { type LocalProject, getAvatarColors } from "@/context/layout" 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) + }} > -
+
{(i) => { const key = ServerConnection.key(i) @@ -619,7 +619,7 @@ export function DialogSelectServer() { -
+
= (props) => { } } - const agentsQuery = useQuery(() => loadAgentsQuery(sdk.directory)) + const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({ + queries: [loadAgentsQuery(sdk.directory), loadProvidersQuery(null), loadProvidersQuery(sdk.directory)], + })) + const agentsLoading = () => agentsQuery.isLoading - - const globalProvidersQuery = useQuery(() => loadProvidersQuery(null)) - const providersQuery = useQuery(() => loadProvidersQuery(sdk.directory)) - + 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)} @@ -1359,15 +1366,13 @@ export const PromptInput: Component = (props) => { }} style={{ "padding-bottom": space }} /> - -
- {placeholder()} -
-
+
+ {placeholder()} +
= (props) => {
-
+
= (props) => { -
+
0} fallback={ @@ -1558,7 +1569,10 @@ export const PromptInput: Component = (props) => {
-
+
{ 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, @@ -276,6 +280,18 @@ export const SettingsGeneral: Component = () => { />
+ + +
+ settings.general.setShowSessionProgressBar(checked)} + /> +
+
) @@ -451,6 +467,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()) }} + /> +
+
) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 57e91d6d335..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" @@ -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(), diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index b7edea70cd1..edebef2b909 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -9,10 +9,9 @@ import type { } from "@kilocode/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/shared/util/path" -import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" +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,7 +23,6 @@ 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" @@ -56,15 +54,10 @@ 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: {}, @@ -73,37 +66,18 @@ function createGlobalSync() { }) 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[]) => { @@ -116,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) { @@ -223,16 +187,18 @@ function createGlobalSync() { 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) + 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) => { @@ -298,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, diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 86386fd59a3..0b4d843026a 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -19,7 +19,6 @@ 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" -import { loadSessionsQuery } from "../global-sync" type GlobalStore = { ready: boolean @@ -82,6 +81,9 @@ export async function bootstrapGlobal(input: { input.setGlobalStore("config", x.data!) }), ), + ] + + const slow = [ () => input.queryClient.fetchQuery({ ...loadProvidersQuery(null), @@ -93,9 +95,6 @@ export async function bootstrapGlobal(input: { }), ), }), - ] - - const slow = [ () => retry(() => input.globalSDK.path.get().then((x) => { @@ -183,8 +182,43 @@ function warmSessions(input: { export const loadProvidersQuery = (directory: string | null) => queryOptions({ queryKey: [directory, "providers"], queryFn: skipToken }) -export const loadAgentsQuery = (directory: string | null) => - queryOptions({ queryKey: [directory, "agents"], 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 @@ -222,45 +256,27 @@ export async function bootstrapDirectory(input: { input.setStore("lsp", []) if (loading) input.setStore("status", "partial") - const fast = [() => Promise.resolve(input.loadSessions(input.directory))] - - 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), - }) - } - + 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), - queryFn: () => - retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))).then( - () => null, - ), - }), + 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 - ? 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 next = projectID(x.data?.directory ?? input.directory, input.global.project) - if (next) input.setStore("project", next) - }), - ), + !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) => { @@ -330,7 +346,28 @@ export async function bootstrapDirectory(input: { 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), + }), + ].filter(Boolean) as (() => Promise)[] await waitForPaint() const slowErrs = errors(await runAll(slow)) @@ -344,29 +381,6 @@ export async function bootstrapDirectory(input: { }) } - 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 input.queryClient.ensureQueryData({ - ...loadSessionsQuery(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), - }) + 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 b94b03fbc35..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) diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index d628cad723d..9196d5b4f76 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -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/layout.tsx b/packages/app/src/context/layout.tsx index 192d2499900..603c3c5961c 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -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) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 9b666e5e751..15af57b355e 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -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 a585789ce46..be2fb49d7e0 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -31,6 +31,7 @@ export interface Settings { showReasoningSummaries: boolean shellToolPartsExpanded: boolean editToolPartsExpanded: boolean + showSessionProgressBar: boolean } updates: { startup: boolean @@ -39,6 +40,7 @@ export interface Settings { fontSize: number mono: string sans: string + terminal: string } keybinds: Record permissions: { @@ -50,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 ?? "" @@ -89,6 +95,14 @@ 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, @@ -102,6 +116,7 @@ const defaultSettings: Settings = { showReasoningSummaries: false, shellToolPartsExpanded: false, editToolPartsExpanded: false, + showSessionProgressBar: true, }, updates: { startup: true, @@ -110,6 +125,7 @@ const defaultSettings: Settings = { fontSize: 14, mono: "", sans: "", + terminal: "", }, keybinds: {}, permissions: { @@ -213,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), @@ -233,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/i18n/ar.ts b/packages/app/src/i18n/ar.ts index afc5d08765f..6a5c8a24a11 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -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..23ab983bd65 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -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..e8944d2406b 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -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..a6fb99eeac6 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -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..4b6c2310714 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -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 30b5fce6361..11c83dde208 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -735,7 +735,9 @@ 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", @@ -760,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..bae6b6e3b18 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -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..e73c54d2128 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -579,7 +579,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 +598,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..9d68b8975d4 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -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 18274900092..234980c1038 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -566,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": "후속 조치 동작", @@ -581,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..0dbf94e347c 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -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..e0b11074339 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -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..7838dcd14be 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -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..90f33affd5b 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -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": "ฟอนต์ UI", "settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ", "settings.general.row.followup.title": "พฤติกรรมการติดตามผล", @@ -645,6 +647,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..4a2ab351ff3 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -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..7c54b6d60bc 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -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..1cc5cb521f8 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -626,7 +626,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 +642,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 f604dd6c5c7..36514f56c63 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,7 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" 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 ( 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 8cc554e723b..adb46170a87 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -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-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 8a685af3fe0..a39e28cdb3b 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -321,7 +321,7 @@ export const SortableWorkspace = (props: { const hasMore = createMemo(() => workspaceStore.sessionTotal > count()) const query = useQuery(() => ({ ...loadSessionsQuery(props.project.worktree) })) const busy = createMemo(() => props.ctx.isBusy(props.directory)) - const loading = () => query.isLoading + const loading = () => query.isLoading && count() === 0 const touch = createMediaQuery("(hover: none)") const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id))) const loadMore = async () => { diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index 4dbdb24633f..90e74e90a88 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -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/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 ad0a65a8140..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", + }, }, }, }, diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index f402c598167..be4a9209bac 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.24", + "version": "7.2.26", "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/index.ts b/packages/desktop-electron/src/main/index.ts index 5cee8a97194..98766e1b80d 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()), 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/store.ts b/packages/desktop-electron/src/main/store.ts index 709e820e253..61f0c0a4938 100644 --- a/packages/desktop-electron/src/main/store.ts +++ b/packages/desktop-electron/src/main/store.ts @@ -4,6 +4,10 @@ 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 @@ -11,5 +15,3 @@ export function getStore(name = SETTINGS_STORE) { 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..a815a717126 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 + 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 85d987041ed..c9fa8db9514 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.2.24", + "version": "7.2.26", "type": "module", "license": "MIT", "scripts": { 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/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 41c243835c0..38120bb7f11 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 = "7.2.24" +version = "7.2.26" 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/v7.2.24/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.26/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.24/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.26/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.24/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.26/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/v7.2.24/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.26/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/v7.2.24/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.26/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] 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/package.json b/packages/kilo-docs/package.json index 40d5bcf1ac7..cb7f3dcf874 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.2.24", + "version": "7.2.26", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", 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/context-mentions.md b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md index dd10ed38b05..3a0011661db 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,12 +20,13 @@ 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 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..834ec18ce16 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 @@ -84,6 +84,7 @@ All fields are optional. When a model ID matches one already in the built-in cat | `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 | @@ -91,6 +92,26 @@ All fields are optional. When a model ID matches one already in the built-in cat | `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**. 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/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/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-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 index b3de7ce0a6b..32ab1dbd8c6 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:63c6e23a31f570031361db39105d4283e05da923bb50757e7583c9430f805c53 -size 16379 +oid sha256:6fbf2dc34c9c1ba549244a750a5ce828089fc378fa2352645c9a2267270a0c08 +size 18021 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 index da8e320285b..96ce67cb9ae 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:024ac7b12c5c6455d30c349dbfde0faff8d8c864761d0d79f0f9e08dbf7144e0 -size 26331 +oid sha256:d2cdb6dfc9a907b8ff98bb1982f324efa6d19e4ee98f9f690b8adbc2a0a163de +size 28994 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-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 index df62efbf2c0..996569f48fb 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c67c86c68c4a07514db50c964668753c0a85ec410e55208b6e094f711c0bceb5 -size 20317 +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 index 1f474da3485..2a9f410b3bb 100644 --- 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 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4524d1fe8b44cf2cce15bdf3ad100572b57fa479fefe456f1c141ebca8d5187d -size 28078 +oid sha256:16e602e6a870e4b66a466a0895f92e9df117f6137809c79ed797f24d2b6a1e88 +size 31028 diff --git a/packages/kilo-docs/source-links.md b/packages/kilo-docs/source-links.md index dd97d3bdfe3..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 @@ - +- + - - diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 1a35b3cb9d8..8678ac117d3 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.24", + "version": "7.2.26", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-gateway/src/server/routes.ts b/packages/kilo-gateway/src/server/routes.ts index f09b945a280..0282e2f94bc 100644 --- a/packages/kilo-gateway/src/server/routes.ts +++ b/packages/kilo-gateway/src/server/routes.ts @@ -112,6 +112,7 @@ export function createKiloRoutes(deps: KiloRoutesDeps) { content: z.string().optional(), }) .optional(), + text: z.string().optional(), // Text-completion style streaming (Mercury) }), ) .optional(), diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index aa719502af5..50ab921fef8 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.24", + "version": "7.2.26", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json new file mode 100644 index 00000000000..9adb44dfee8 --- /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.1.3", + "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..04904ebebad --- /dev/null +++ b/packages/kilo-indexing/src/detect.ts @@ -0,0 +1,98 @@ +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..8c943c03acb --- /dev/null +++ b/packages/kilo-indexing/src/indexing/manager.ts @@ -0,0 +1,517 @@ +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 + + 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 { + 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() + this.emitStart(trigger) + await this._orchestrator!.startIndexing(trigger) + } catch (err) { + 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) + 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._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() + 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() + 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) { + 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.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._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 { + 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..8be9ad0e0e2 --- /dev/null +++ b/packages/kilo-indexing/test/kilocode/indexing/manager.test.ts @@ -0,0 +1,381 @@ +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("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-telemetry/package.json b/packages/kilo-telemetry/package.json index 15696852343..69aa0259dc9 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.24", + "version": "7.2.26", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-telemetry/src/__tests__/telemetry.test.ts b/packages/kilo-telemetry/src/__tests__/telemetry.test.ts index 2b7bc64a548..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 { 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/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-ui/package.json b/packages/kilo-ui/package.json index 80cdfeccb86..a22f5de37b3 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.24", + "version": "7.2.26", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index cb65a7718de..5cc9ce26870 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -2022,11 +2022,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 +2066,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 +2089,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(), }} /> diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 16ec7a7eac8..b3c7c564932 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,21 @@ # kilo-code +## 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 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/eslint.config.mjs b/packages/kilo-vscode/eslint.config.mjs index 56c773ded01..b11a766659b 100644 --- a/packages/kilo-vscode/eslint.config.mjs +++ b/packages/kilo-vscode/eslint.config.mjs @@ -38,18 +38,18 @@ export default [ // New code must stay ≤ 20. Do not raise these caps; refactor instead. { files: ["src/KiloProvider.ts"], - rules: { complexity: ["error", 140], "max-lines": ["error", 3350] }, + rules: { complexity: ["error", 150], "max-lines": ["error", 3420] }, }, { files: ["webview-ui/agent-manager/AgentManagerApp.tsx"], - // Raised from 3100 → 3175 for the experimental terminal tabs feature. + // Raised from 3100 → 3200 for the experimental terminal tabs feature. // ~600 lines of terminal logic were extracted to ./terminal/* and // ./tab-rendering.tsx; the remaining ~75 lines are signal bindings, // a stacking-container wrapper required by the hydration invariant // (canvases must never leave the paint tree — see render.tsx), and // render-call wiring that must live at the top of // `AgentManagerContent` alongside the existing selection/session state. - rules: { complexity: ["error", 74], "max-lines": ["error", 3175] }, + rules: { complexity: ["error", 74], "max-lines": ["error", 3200] }, }, { files: ["src/agent-manager/AgentManagerProvider.ts"], diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 72f3341a6b2..fe786f1f582 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.2.24", + "version": "7.2.26", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -61,6 +61,15 @@ } } ], + "icons": { + "kilo-logo": { + "description": "Kilo Code logo", + "default": { + "fontPath": "assets/icons/kilo-icon-font.woff2", + "fontCharacter": "\\F101" + } + } + }, "viewsContainers": { "activitybar": [ { @@ -89,7 +98,7 @@ { "command": "kilo-code.new.agentManagerOpen", "title": "Agent Manager", - "icon": "$(circuit-board)" + "icon": "$(organization)" }, { "command": "kilo-code.new.kiloClawOpen", @@ -740,6 +749,18 @@ "default": "kilo-auto/free", "description": "Default model ID for new sessions" }, + "kilo-code.new.autocomplete.model": { + "type": "string", + "enum": [ + "mistralai/codestral-2508", + "inception/mercury-edit" + ], + "enumDescriptions": [ + "Codestral by Mistral AI (default)", + "Mercury Edit by Inception" + ], + "description": "Model to use for inline autocomplete suggestions" + }, "kilo-code.new.autocomplete.enableAutoTrigger": { "type": "boolean", "default": true, @@ -885,6 +906,7 @@ "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:*", diff --git a/packages/kilo-vscode/src/DiffVirtualProvider.ts b/packages/kilo-vscode/src/DiffVirtualProvider.ts index b585ee71fdd..9b66a60a0f2 100644 --- a/packages/kilo-vscode/src/DiffVirtualProvider.ts +++ b/packages/kilo-vscode/src/DiffVirtualProvider.ts @@ -8,6 +8,7 @@ export interface DiffVirtualFile { after: string additions: number deletions: number + initialDiffStyle: "unified" | "split" } /** @@ -83,7 +84,7 @@ export class DiffVirtualProvider implements vscode.Disposable { private pushData(): void { if (!this.pending) return - this.post({ type: "diffVirtual.data", diff: this.pending }) + this.post({ type: "diffVirtual.data", diff: this.pending, initialDiffStyle: this.pending.initialDiffStyle }) } private post(message: Record): void { diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index a37424bcb82..38fd59e385a 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -11,8 +11,8 @@ import type { FilePartInput, Config, } from "@kilocode/sdk/v2/client" -import { type KiloConnectionService, type KilocodeNotification, ServerStartupError } from "./services/cli-backend" -import type { EditorContext } from "./services/cli-backend/types" +import { type KiloConnectionService, ServerStartupError } from "./services/cli-backend" +import type { EditorContext, IndexingStatus } from "./services/cli-backend/types" import { FileIgnoreController } from "./services/autocomplete/shims/FileIgnoreController" import { ChatTextAreaAutocomplete } from "./services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete" import { buildWebviewHtml } from "./utils" @@ -45,7 +45,7 @@ import { resolveProjectDirectory } from "./project-directory" import { getBusySessionCount, seedSessionStatuses } from "./session-status" import { retry } from "./services/cli-backend/retry" import { slimPart, slimParts } from "./kilo-provider/slim-metadata" -import { handleContinueInWorktree } from "./kilo-provider/continue-worktree" +import { handleSidebarWorktreeMessage } from "./kilo-provider/sidebar-worktree" import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-files" import { handleFileSearch } from "./kilo-provider/file-search" import { getTerminalContents } from "./services/terminal/context" @@ -64,6 +64,7 @@ import { } from "./services/autocomplete/settings" import * as ModelState from "./kilo-provider/model-state" import { handleForkSession } from "./kilo-provider/fork-session" +import { openConfig } from "./kilo-provider/open-config" import { retryable, backoff, MAX_RETRIES } from "./util/retry" import { hasGit } from "./kilo-provider/git-status" // legacy-migration start @@ -116,6 +117,7 @@ import { } from "./provider-actions" import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models" import type { Agent } from "@kilocode/sdk/v2/client" +import { configFeatures } from "./features" type KiloProviderOptions = { projectDirectory?: string | null @@ -165,6 +167,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private cachedCommandsMessage: unknown = null /** Cached configLoaded payload so requestConfig can be served before client is ready */ private cachedConfigMessage: unknown = null + /** Cached indexingStatusLoaded payload so requestIndexingStatus can be served before client is ready */ + private cachedIndexingStatusMessage: unknown = null /** Cached mcpStatusLoaded payload so requestMcpStatus can be served before client is ready */ private cachedMcpStatusMessage: unknown = null /** Ref-count of in-flight handleUpdateConfig calls; prevents fetchAndSendConfig from sending stale data */ @@ -237,6 +241,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper | ((sessionId: string, progress: (status: string, detail?: string, error?: string) => void) => Promise) | null = null + /** Handler for sidebar worktree creation — delegates to AgentManagerProvider. */ + private createWorktreeHandler: ((baseBranch?: string, branchName?: string) => Promise) | null = null + private diffVirtualProvider: import("./DiffVirtualProvider").DiffVirtualProvider | undefined private remoteService: RemoteStatusService | null = null private unsubscribeRemote: (() => void) | null = null @@ -551,6 +558,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.continueInWorktreeHandler = handler } + public setCreateWorktreeHandler(handler: (baseBranch?: string, branchName?: string) => Promise): void { + this.createWorktreeHandler = handler + } + public attachToWebview( webview: vscode.Webview, options?: { onBeforeMessage?: (msg: Record) => Promise | null> }, @@ -579,6 +590,20 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await routeSuggestionWebviewMessage(this.questionCtx, message) if (await ModelState.handleMessage(message.type, message, this.client, (msg) => this.postMessage(msg))) return if (await routeAutocompleteMessage(message, (msg) => this.postMessage(msg))) return + if ( + await handleSidebarWorktreeMessage(message, { + post: (msg) => this.postMessage(msg), + openAgentManager: () => vscode.commands.executeCommand("kilo-code.new.agentManagerOpen"), + openAdvancedWorktree: () => vscode.commands.executeCommand("kilo-code.new.agentManager.advancedWorktree"), + openChanges: () => vscode.commands.executeCommand("kilo-code.new.showChanges"), + createWorktree: async (baseBranch, branchName) => { + await this.createWorktreeHandler?.(baseBranch, branchName) + }, + continueInWorktree: this.continueInWorktreeHandler ?? undefined, + }) + ) { + return + } switch (message.type) { case "webviewReady": console.log("[Kilo New] KiloProvider: ✅ webviewReady received") @@ -697,21 +722,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "openVSCodeSettings": vscode.commands.executeCommand("workbench.action.openSettings", message.query) break + case "openConfigFile": + await openConfig(message.scope, message.labels, this.getProjectDirectory(this.currentSession?.id)) + break case "openMarketplacePanel": vscode.commands.executeCommand("kilo-code.new.marketplaceButtonClicked", this.projectDirectory) break - case "openChanges": - vscode.commands.executeCommand("kilo-code.new.showChanges") - break case "openDiffVirtual": - this.openDiffVirtual(message.diff) - break - case "continueInWorktree": - handleContinueInWorktree({ - sessionId: message.sessionId, - handler: this.continueInWorktreeHandler ?? undefined, - post: (msg) => this.postMessage(msg), - }) + this.openDiffVirtual(message.diff, message.initialDiffStyle) break case "forkSession": handleForkSession(this.forkCtx, message.sessionId, message.messageId).catch((e) => @@ -802,9 +820,19 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "requestGlobalConfig": this.fetchAndSendGlobalConfig().catch((e) => console.error("[Kilo New] fetchAndSendGlobalConfig failed:", e)) break + case "requestIndexingStatus": + this.fetchAndSendIndexingStatus().catch((e) => + console.error("[Kilo New] fetchAndSendIndexingStatus failed:", e), + ) + break case "updateConfig": await this.handleUpdateConfig(message.config) break + case "openSettingsTab": + if (message.tab === "indexing") { + await vscode.commands.executeCommand("kilo-code.new.openIndexingSettings") + } + break case "setLanguage": await vscode.workspace .getConfiguration("kilo-code.new") @@ -1045,9 +1073,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper void vscode.env.openExternal(vscode.Uri.parse(url)) } - private openDiffVirtual(diff: unknown): void { + private openDiffVirtual(diff: unknown, initialDiffStyle?: unknown): void { if (!this.diffVirtualProvider || !diff) return - this.diffVirtualProvider.open(diff as import("./DiffVirtualProvider").DiffVirtualFile) + const d = diff as import("./DiffVirtualProvider").DiffVirtualFile + d.initialDiffStyle = initialDiffStyle === "split" ? "split" : "unified" + this.diffVirtualProvider.open(d) } /** @@ -1217,6 +1247,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.fetchAndSendSkills(), this.fetchAndSendCommands(), this.fetchAndSendConfig(), + this.fetchAndSendIndexingStatus(), this.fetchAndSendNotifications(), this.seedSessionStatusMap(), ]) @@ -2037,6 +2068,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const message = { type: "configLoaded", config, + features: configFeatures(config), } this.cachedConfigMessage = message this.postMessage(message) @@ -2056,6 +2088,39 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private async fetchAndSendIndexingStatus(): Promise { + if (!this.client) { + if (this.cachedIndexingStatusMessage) { + this.postMessage(this.cachedIndexingStatusMessage) + } + return + } + + const config = this.connectionService.getServerConfig() + if (!config) return + + try { + const dir = this.getWorkspaceDirectory(this.currentSession?.id) + const auth = Buffer.from(`kilo:${config.password}`).toString("base64") + const res = await fetch(`${config.baseUrl}/indexing/status`, { + headers: { + Authorization: `Basic ${auth}`, + ...(dir ? { "x-kilo-directory": dir } : {}), + }, + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const status = (await res.json()) as IndexingStatus + const message = { + type: "indexingStatusLoaded", + status, + } + this.cachedIndexingStatusMessage = message + this.postMessage(message) + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to fetch indexing status:", error) + } + } + /** * Seed sessionStatusMap with current session statuses on connect. * Without this, the Settings panel (which has no tracked sessions) would see @@ -2080,8 +2145,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const dir = this.getWorkspaceDirectory() const { data: config } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) - this.cachedConfigMessage = { type: "configLoaded", config } - this.postMessage({ type: "configUpdated", config }) + this.cachedConfigMessage = { type: "configLoaded", config, features: configFeatures(config) } + this.postMessage({ type: "configUpdated", config, features: configFeatures(config) }) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to fetch config after update:", error) } @@ -2277,15 +2342,20 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const dir = this.getWorkspaceDirectory() const { data: merged } = await retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })) - this.cachedConfigMessage = { type: "configLoaded", config: merged } - this.postMessage({ type: "configUpdated", config: merged }) + this.cachedConfigMessage = { type: "configLoaded", config: merged, features: configFeatures(merged) } + this.postMessage({ type: "configUpdated", config: merged, features: configFeatures(merged) }) if (refreshProviders) await this.fetchAndSendProviders() } catch (error) { console.error("[Kilo New] KiloProvider: Config write succeeded but post-write refresh failed:", error) const cached = (this.cachedConfigMessage as { config?: unknown } | null)?.config + const features = (this.cachedConfigMessage as { features?: unknown } | null)?.features const optimistic = cached && typeof cached === "object" ? { ...(cached as Record), ...partial } : partial - this.postMessage({ type: "configUpdated", config: optimistic }) + this.postMessage({ + type: "configUpdated", + config: optimistic, + features: features ?? configFeatures(optimistic as Config), + }) } finally { this.pending-- } @@ -2868,12 +2938,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Re-fetch all server-side state after an auth change. */ private async reloadAfterAuthChange(): Promise { + await this.fetchAndSendConfig() await Promise.all([ this.fetchAndSendProviders(), this.fetchAndSendAgents(), this.fetchAndSendSkills(), this.fetchAndSendCommands(), - this.fetchAndSendConfig(), + this.fetchAndSendIndexingStatus(), this.fetchAndSendNotifications(), ]) } @@ -2882,7 +2953,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * Handle SSE events from the CLI backend. * Filters events by project ID and tracked session IDs so each webview only sees its own sessions. */ - private handleEvent(event: Event): void { + private handleEvent(event: Event, directory?: string): void { if (event.type === "kilo-sessions.remote-status-changed") { this.remoteService?.updateFromEvent({ enabled: event.properties.enabled, connected: event.properties.connected }) return @@ -2920,13 +2991,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const sessionID = this.connectionService.resolveEventSessionId(event) - // Events without sessionID (server.connected, server.heartbeat) → always forward + // Events without sessionID (server.connected, server.heartbeat, indexing.status) → always forward // Events with sessionID → only forward if this webview tracks that session // message.part.updated and message.part.delta are always session-scoped; drop if session unknown. if (!sessionID && (event.type === "message.part.updated" || event.type === "message.part.delta")) { return } - if (sessionID && !this.trackedSessionIds.has(sessionID)) { + if (event.type !== "indexing.status" && sessionID && !this.trackedSessionIds.has(sessionID)) { return } @@ -2984,12 +3055,20 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper handleNetworkEvent(event.type as string, event.properties as any, this.client, (s) => this.getWorkspaceDirectory(s)) + if (event.type === "indexing.status" && directory) { + const current = path.resolve(this.getWorkspaceDirectory(this.currentSession?.id)) + if (path.resolve(directory) !== current) return + } + const msg = mapSSEEventToWebviewMessage(event, sessionID) if (!msg) return if (msg.type === "partUpdated") { this.streams.push({ ...msg, part: this.slimPart(msg.part) }) return } + if (msg.type === "indexingStatusLoaded") { + this.cachedIndexingStatusMessage = msg + } this.streams.flush(sessionID) this.postMessage(msg) } diff --git a/packages/kilo-vscode/src/SettingsEditorProvider.ts b/packages/kilo-vscode/src/SettingsEditorProvider.ts index 493a6fd519b..a03637d0110 100644 --- a/packages/kilo-vscode/src/SettingsEditorProvider.ts +++ b/packages/kilo-vscode/src/SettingsEditorProvider.ts @@ -4,12 +4,13 @@ import { resolvePanelProjectDirectory } from "./project-directory" import type { KiloConnectionService } from "./services/cli-backend" import type { RemoteStatusService } from "./services/RemoteStatusService" -type PanelView = "settings" | "profile" | "marketplace" +type PanelView = "settings" | "profile" | "marketplace" | "indexing" const PANEL_TITLES: Record = { settings: "Kilo Settings", profile: "Kilo Profile", marketplace: "Kilo Marketplace", + indexing: "Codebase Indexing", } /** @@ -65,6 +66,7 @@ export class SettingsEditorProvider implements vscode.Disposable { provider?.postMessage({ type: "navigate", view, tab }) } existing.reveal(vscode.ViewColumn.One) + this.providers.get(view)?.postMessage({ type: "navigate", view, ...(tab ? { tab } : {}) }) return } @@ -163,5 +165,6 @@ export class SettingsEditorProvider implements vscode.Disposable { } this.panels.clear() this.providers.clear() + this.tabs.clear() } } diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index d42e229f1fe..788248d17db 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -1537,6 +1537,25 @@ export class AgentManagerProvider implements Disposable { ) } + public async createFromSidebar(baseBranch?: string, branchName?: string): Promise { + this.openPanel() + const panel = this.panel + if (!panel) return + await panel.waitForReady() + await this.waitForStateReady("createFromSidebar") + await this.onCreateWorktree(baseBranch, branchName) + } + + public async openAdvancedWorktree(): Promise { + this.openPanel() + const panel = this.panel + if (!panel) return + await panel.waitForActive() + await panel.waitForReady() + await this.waitForStateReady("openAdvancedWorktree") + queueMicrotask(() => this.postToWebview({ type: "action", action: "advancedWorktree" })) + } + private handleSection(m: AgentManagerInMessage): boolean { return handleSection(this.state, m, () => this.pushState()) } diff --git a/packages/kilo-vscode/src/agent-manager/host.ts b/packages/kilo-vscode/src/agent-manager/host.ts index 1f86425194f..92970ecac71 100644 --- a/packages/kilo-vscode/src/agent-manager/host.ts +++ b/packages/kilo-vscode/src/agent-manager/host.ts @@ -56,6 +56,12 @@ export interface PanelContext { /** Send a message to the webview. */ postMessage(msg: unknown): void + /** Resolve once the panel webview is ready to receive messages. */ + waitForReady(): Promise + + /** Resolve once the panel is the active editor tab. */ + waitForActive(): Promise + /** Reveal the panel. */ reveal(preserveFocus?: boolean): void diff --git a/packages/kilo-vscode/src/agent-manager/vscode-host.ts b/packages/kilo-vscode/src/agent-manager/vscode-host.ts index 06370eae2fc..8b5ebfdc72b 100644 --- a/packages/kilo-vscode/src/agent-manager/vscode-host.ts +++ b/packages/kilo-vscode/src/agent-manager/vscode-host.ts @@ -110,6 +110,19 @@ export class VscodeHost implements Host { postMessage(msg) { void panel.webview.postMessage(msg) }, + waitForReady() { + return provider.waitForReady() + }, + waitForActive() { + if (panel.active) return Promise.resolve() + return new Promise((resolve) => { + const sub = panel.onDidChangeViewState((e) => { + if (!e.webviewPanel.active) return + sub.dispose() + resolve() + }) + }) + }, reveal(preserveFocus) { panel.reveal(vscode.ViewColumn.One, preserveFocus ?? false) }, diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 06103e05703..fcd6ff1eb81 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -112,6 +112,9 @@ export function activate(context: vscode.ExtensionContext) { provider.setContinueInWorktreeHandler((sessionId, progress) => agentManagerProvider.continueFromSidebar(sessionId, progress), ) + provider.setCreateWorktreeHandler((baseBranch, branchName) => + agentManagerProvider.createFromSidebar(baseBranch, branchName), + ) // Register serializer so Agent Manager restores when VS Code restarts context.subscriptions.push( @@ -145,6 +148,9 @@ export function activate(context: vscode.ExtensionContext) { tabProvider.setContinueInWorktreeHandler((sessionId, progress) => agentManagerProvider.continueFromSidebar(sessionId, progress), ) + tabProvider.setCreateWorktreeHandler((baseBranch, branchName) => + agentManagerProvider.createFromSidebar(baseBranch, branchName), + ) tabProvider.setDiffVirtualProvider(diffVirtualProvider) tabProvider.resolveWebviewPanel(panel) tabPanels.set(panel, tabProvider) @@ -256,6 +262,9 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("kilo-code.new.settingsButtonClicked", (tab?: string) => { settingsEditorProvider.openPanel("settings", tab) }), + vscode.commands.registerCommand("kilo-code.new.openIndexingSettings", () => { + settingsEditorProvider.openPanel("settings", "indexing") + }), // legacy-migration start vscode.commands.registerCommand("kilo-code.new.openMigrationWizard", () => { provider.postMessage({ type: "migrationState", needed: true }) @@ -335,9 +344,9 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("kilo-code.new.agentManager.closeWorktree", () => { agentManagerProvider.postMessage({ type: "action", action: "closeWorktree" }) }), - vscode.commands.registerCommand("kilo-code.new.agentManager.advancedWorktree", () => { - agentManagerProvider.postMessage({ type: "action", action: "advancedWorktree" }) - }), + vscode.commands.registerCommand("kilo-code.new.agentManager.advancedWorktree", () => + agentManagerProvider.openAdvancedWorktree(), + ), ...Array.from({ length: 9 }, (_, i) => vscode.commands.registerCommand(`kilo-code.new.agentManager.jumpTo${i + 1}`, () => { agentManagerProvider.postMessage({ type: "action", action: `jumpTo${i + 1}` }) @@ -450,6 +459,9 @@ async function openKiloInNewTab( tabProvider.setContinueInWorktreeHandler((sessionId, progress) => agentManagerProvider.continueFromSidebar(sessionId, progress), ) + tabProvider.setCreateWorktreeHandler((baseBranch, branchName) => + agentManagerProvider.createFromSidebar(baseBranch, branchName), + ) tabProvider.setDiffVirtualProvider(diffVirtualProvider) tabProvider.resolveWebviewPanel(panel) tabPanels.set(panel, tabProvider) diff --git a/packages/kilo-vscode/src/features.ts b/packages/kilo-vscode/src/features.ts new file mode 100644 index 00000000000..889c12d8e71 --- /dev/null +++ b/packages/kilo-vscode/src/features.ts @@ -0,0 +1,18 @@ +import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect" + +type PluginSpec = string | [string, Record] + +type ConfigLike = { + plugin?: readonly PluginSpec[] | null + experimental?: { semantic_indexing?: boolean } | null +} + +export type Features = { + indexing: boolean +} + +export function configFeatures(config?: ConfigLike | null): Features { + return { + indexing: hasIndexingPlugin(config?.plugin ?? []) && config?.experimental?.semantic_indexing === true, + } +} diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index 3c27dec87c8..b00eefd0770 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -1,6 +1,6 @@ import type { Session, Agent, Event, ProviderListResponse } from "@kilocode/sdk/v2/client" import { prettifyError } from "zod/v4" -import type { CloudSessionMessage } from "./services/cli-backend/types" +import type { CloudSessionMessage, IndexingStatus } from "./services/cli-backend/types" import type { PartBatch, PartUpdate } from "./kilo-provider/session-stream-scheduler" export { SessionStreamScheduler } from "./kilo-provider/session-stream-scheduler" @@ -333,6 +333,10 @@ export function resolveContextDirectory(input: { export type WebviewMessage = | PartUpdate | PartBatch + | { + type: "indexingStatusLoaded" + status: IndexingStatus + } | { type: "messageCreated" message: Record @@ -518,6 +522,11 @@ export function mapSSEEventToWebviewMessage(event: Event, sessionID: string | un type: "sessionUpdated", session: sessionToWebview(event.properties.info), } + case "indexing.status": + return { + type: "indexingStatusLoaded", + status: event.properties.status, + } default: return null } diff --git a/packages/kilo-vscode/src/kilo-provider/config-file.ts b/packages/kilo-vscode/src/kilo-provider/config-file.ts new file mode 100644 index 00000000000..f943ebb5d78 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/config-file.ts @@ -0,0 +1,117 @@ +import { existsSync } from "fs" +import * as os from "os" +import * as path from "path" + +export type Scope = "global" | "local" + +export type Source = + | "sourceXdg" + | "sourceHomeKilo" + | "sourceHomeKilocode" + | "sourceHomeOpencode" + | "sourceEnvFile" + | "sourceEnvDir" + | "sourceEnvContent" + | "sourceProjectKilo" + | "sourceProjectRoot" + | "sourceProjectKilocode" + | "sourceProjectOpencode" + +export interface Entry { + file?: string + name: string + source: Source + exists: boolean + loaded: boolean + legacy?: boolean + recommended?: boolean + virtual?: boolean +} + +const SCHEMA = "https://app.kilo.ai/config.json" + +const MODERN = ["kilo.jsonc", "kilo.json"] +const LEGACY = ["opencode.jsonc", "opencode.json"] +const FILES = [...MODERN, ...LEGACY] +const GLOBAL = ["kilo.jsonc", "kilo.json", "opencode.jsonc", "opencode.json", "config.json"] +const HOME = [".kilo", ".kilocode", ".opencode"] +const SOURCES: Record = { + ".kilo": "sourceHomeKilo", + ".kilocode": "sourceHomeKilocode", + ".opencode": "sourceHomeOpencode", +} + +function row(file: string, source: Source, loaded = true, recommended = false): Entry { + const name = path.basename(file) + return { + file, + name, + source, + exists: existsSync(file), + loaded: loaded && existsSync(file), + legacy: name.startsWith("opencode") || name === "config.json" || file.includes(`${path.sep}.kilocode${path.sep}`), + recommended, + } +} + +function ensure(list: Entry[], file: string, source: Source) { + if (list.some((item) => item.file === file)) return list + return [...list, row(file, source, true, true)] +} + +export function globalFiles() { + const root = path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"), "kilo") + const base = GLOBAL.map((file) => row(path.join(root, file), "sourceXdg")).filter((item) => item.exists) + const dirs = HOME.flatMap((dir) => { + const base = path.join(os.homedir(), dir) + if (!existsSync(base)) return [] + return FILES.map((file) => row(path.join(base, file), SOURCES[dir])).filter((item) => item.exists) + }) + const env = process.env.KILO_CONFIG ? [row(process.env.KILO_CONFIG, "sourceEnvFile")] : [] + const extra = process.env.KILO_CONFIG_DIR + const dir = extra + ? ensure( + FILES.map((file) => row(path.join(extra, file), "sourceEnvDir")).filter((item) => item.exists), + path.join(extra, "kilo.jsonc"), + "sourceEnvDir", + ) + : [] + const virtual: Entry[] = process.env.KILO_CONFIG_CONTENT + ? [ + { + name: "KILO_CONFIG_CONTENT", + source: "sourceEnvContent", + exists: true, + loaded: true, + virtual: true, + }, + ] + : [] + + return ensure([...base, ...dirs, ...env, ...dir, ...virtual], path.join(root, "kilo.jsonc"), "sourceXdg") +} + +export function localFiles(root: string) { + const enabled = !process.env.KILO_DISABLE_PROJECT_CONFIG + const dirs = [path.join(root, ".kilo"), root, path.join(root, ".kilocode"), path.join(root, ".opencode")] + const list = dirs.flatMap((dir) => FILES.map((file) => row(path.join(dir, file), localSource(root, dir), enabled))) + return ensure( + list.filter((item) => item.exists), + path.join(root, ".kilo", "kilo.jsonc"), + "sourceProjectKilo", + ).map((item) => (enabled ? item : { ...item, loaded: false })) +} + +function localSource(root: string, dir: string) { + if (dir === root) return "sourceProjectRoot" + if (dir.endsWith(`${path.sep}.kilo`)) return "sourceProjectKilo" + if (dir.endsWith(`${path.sep}.kilocode`)) return "sourceProjectKilocode" + return "sourceProjectOpencode" +} + +export function content() { + return `{ + "$schema": "${SCHEMA}" +} +` +} diff --git a/packages/kilo-vscode/src/kilo-provider/open-config.ts b/packages/kilo-vscode/src/kilo-provider/open-config.ts new file mode 100644 index 00000000000..e30ee9d51bb --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/open-config.ts @@ -0,0 +1,72 @@ +import * as path from "path" +import * as vscode from "vscode" +import { content, globalFiles, localFiles, type Entry, type Scope, type Source } from "./config-file" + +interface Labels extends Record { + scope: string + statusLoaded: string + statusLoadedLegacy: string + statusNotLoaded: string + statusCreate: string + title: string + placeholder: string + noWorkspace: string + openFailed: string +} + +export async function openConfig(scope: Scope, labels: Labels, root?: string): Promise { + if (scope === "local" && !root) { + void vscode.window.showWarningMessage(labels.noWorkspace) + return + } + + const list = scope === "global" ? globalFiles() : localFiles(root!) + const picked = await pick(list, labels) + if (!picked?.file) return + + await open(picked.file, labels) +} + +async function pick(list: Entry[], labels: Labels) { + const editable = list.filter((item) => !item.virtual) + if (editable.length === 1) return editable[0] + + const picked = await vscode.window.showQuickPick( + editable.map((item) => ({ + label: item.recommended && !item.exists ? `$(add) ${item.name}` : `$(json) ${item.name}`, + description: item.exists ? status(item, labels) : labels.statusCreate, + detail: `${labels[item.source]} - ${item.file}`, + item, + })), + { + title: labels.title, + placeHolder: labels.placeholder, + }, + ) + + return picked?.item +} + +function status(item: Entry, labels: Labels) { + if (!item.loaded) return labels.statusNotLoaded + if (item.legacy) return labels.statusLoadedLegacy + return labels.statusLoaded +} + +async function open(file: string, labels: Labels) { + const uri = vscode.Uri.file(file) + try { + await vscode.workspace.fs.createDirectory(vscode.Uri.file(path.dirname(file))) + const exists = await vscode.workspace.fs.stat(uri).then( + () => true, + () => false, + ) + if (!exists) await vscode.workspace.fs.writeFile(uri, Buffer.from(content())) + const doc = await vscode.workspace.openTextDocument(uri) + await vscode.window.showTextDocument(doc, { preview: false }) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + console.error("[Kilo New] Failed to open config file:", file, err) + void vscode.window.showErrorMessage(labels.openFailed.replace("{{message}}", () => msg)) + } +} diff --git a/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts b/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts new file mode 100644 index 00000000000..a478d9b13c4 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/sidebar-worktree.ts @@ -0,0 +1,67 @@ +import { GitOps } from "../agent-manager/GitOps" +import { getWorkspaceRoot } from "../review-utils" +import { handleContinueInWorktree } from "./continue-worktree" + +interface Msg { + type: string + baseBranch?: string + branchName?: string + sessionId?: string +} + +interface Ctx { + post: (msg: unknown) => void + openAgentManager: () => Thenable + openAdvancedWorktree: () => Thenable + openChanges: () => Thenable + createWorktree?: (baseBranch?: string, branchName?: string) => Promise + continueInWorktree?: ( + sessionId: string, + progress: (status: string, detail?: string, error?: string) => void, + ) => Promise +} + +async function repo(post: Ctx["post"]) { + const root = getWorkspaceRoot() + if (!root) return + const git = new GitOps({ log: () => {} }) + const branch = await git.currentBranch(root).catch(() => "") + git.dispose() + if (!branch || branch === "HEAD") return + post({ type: "agentManager.repoInfo", branch }) +} + +export async function handleSidebarWorktreeMessage(message: Msg, ctx: Ctx) { + if (message.type === "openAgentManager") { + await ctx.openAgentManager() + return true + } + + if (message.type === "openAdvancedWorktree") { + await ctx.openAdvancedWorktree() + return true + } + + if (message.type === "agentManager.requestRepoInfo") { + await repo(ctx.post) + return true + } + + if (message.type === "agentManager.createWorktree") { + await ctx.createWorktree?.(message.baseBranch, message.branchName) + return true + } + + if (message.type === "openChanges") { + await ctx.openChanges() + return true + } + + if (message.type !== "continueInWorktree") return false + handleContinueInWorktree({ + sessionId: message.sessionId, + handler: ctx.continueInWorktree, + post: ctx.post, + }) + return true +} diff --git a/packages/kilo-vscode/src/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index 9db694dcee6..e86312f68ae 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -10,6 +10,7 @@ import { withCustomProviderDeletions, } from "./shared/custom-provider" import { KILO_AUTO, parseModelString } from "./shared/provider-model" +import { configFeatures } from "./features" /** * Compute the default model selection from CLI config, VS Code settings, or hardcoded fallback. @@ -17,6 +18,10 @@ import { KILO_AUTO, parseModelString } from "./shared/provider-model" */ type AuthState = "api" | "oauth" | "wellknown" +function disabledWithout(list: string[] | undefined, id: string) { + return (list ?? []).filter((item) => item !== id) +} + /** Fetch auth methods alongside the provider list. Auth states default to empty (endpoint not yet available). */ export async function fetchProviderData(client: KiloClient, dir: string) { const authRequest = @@ -232,6 +237,9 @@ export async function disconnectProvider( try { const globalConfig = (await ctx.client.global.config.get({ throwOnError: true })).data ?? {} const configured = !!globalConfig.provider?.[id] + const { response } = await fetchProviderData(ctx.client, ctx.workspaceDir) + const active = response.all.find((item) => item.id === id) + const oauth = active?.source === "custom" && configured // Remove auth store entry. Config-sourced providers may not have an auth // store entry (credentials come from config or env), so failure is non-fatal. @@ -251,7 +259,7 @@ export async function disconnectProvider( // server rebuilds state from config. Add to disabled_providers so the server // excludes them. The config entry is preserved (user may re-enable later). // This matches the desktop app's disableProvider() pattern. - if (configured) { + if (configured && !oauth) { const disabled = globalConfig.disabled_providers ?? [] if (!disabled.includes(id)) { const merged = ( @@ -260,6 +268,19 @@ export async function disconnectProvider( { throwOnError: true }, ) ).data + if (merged) { + setCachedConfig({ type: "configLoaded", config: merged, features: configFeatures(merged) }) + ctx.postMessage({ type: "configUpdated", config: merged, features: configFeatures(merged) }) + } + } + } + + if (oauth) { + const disabled = disabledWithout(globalConfig.disabled_providers, id) + if (disabled.length !== (globalConfig.disabled_providers ?? []).length) { + const merged = ( + await ctx.client.global.config.update({ config: { disabled_providers: disabled } }, { throwOnError: true }) + ).data if (merged) { setCachedConfig({ type: "configLoaded", config: merged }) ctx.postMessage({ type: "configUpdated", config: merged }) @@ -315,9 +336,9 @@ export async function saveCustomProvider( { throwOnError: true }, ) - const msg = { type: "configLoaded", config: updated } + const msg = { type: "configLoaded", config: updated, features: configFeatures(updated) } setCachedConfig(msg) - ctx.postMessage({ type: "configUpdated", config: updated }) + ctx.postMessage({ type: "configUpdated", config: updated, features: configFeatures(updated) }) const auth = resolveCustomProviderAuth(apiKey, apiKeyChanged) diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteModel.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteModel.ts index 6a0e925fba4..020ae9651ae 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteModel.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteModel.ts @@ -1,11 +1,10 @@ import { ResponseMetaData } from "./types" import type { KiloConnectionService } from "../cli-backend" - -const DEFAULT_MODEL = "mistralai/codestral-2508" -const PROVIDER_DISPLAY_NAME = "Kilo Gateway" +import { DEFAULT_AUTOCOMPLETE_MODEL, getAutocompleteModel } from "../../shared/autocomplete-models" export class AutocompleteModel { private connectionService: KiloConnectionService | null = null + private currentModel: string = DEFAULT_AUTOCOMPLETE_MODEL.id public profileName: string | null = null public profileType: string | null = null @@ -15,6 +14,10 @@ export class AutocompleteModel { } } + public setModel(model: string): void { + this.currentModel = model + } + /** * Set the connection service (can be called after construction when service becomes available) */ @@ -48,13 +51,16 @@ export class AutocompleteModel { // client catches HTTP errors (402, 401, 429, 5xx) internally and silently // ends the stream. Without this, errors never reach ErrorBackoff. let sseError: Error | undefined + + const temp = getAutocompleteModel(this.currentModel).temperature + const { stream } = await client.kilo.fim( { prefix, suffix, - model: DEFAULT_MODEL, + model: this.currentModel, maxTokens: 256, - temperature: 0.2, + temperature: temp, }, { signal, @@ -66,7 +72,8 @@ export class AutocompleteModel { ) for await (const chunk of stream) { - const content = chunk.choices?.[0]?.delta?.content + const choice = chunk.choices?.[0] + const content = choice?.delta?.content ?? choice?.text if (content) onChunk(content) if (chunk.usage) { inputTokens = chunk.usage.prompt_tokens ?? 0 @@ -87,11 +94,11 @@ export class AutocompleteModel { } public getModelName(): string { - return DEFAULT_MODEL + return this.currentModel } public getProviderDisplayName(): string { - return PROVIDER_DISPLAY_NAME + return getAutocompleteModel(this.currentModel).provider } /** diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts index 329240445d5..1beb1485ea4 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteServiceManager.ts @@ -8,6 +8,7 @@ import { AutocompleteCodeActionProvider } from "./AutocompleteCodeActionProvider import { AutocompleteInlineCompletionProvider } from "./classic-auto-complete/AutocompleteInlineCompletionProvider" import { AutocompleteTelemetry } from "./classic-auto-complete/AutocompleteTelemetry" import type { KiloConnectionService } from "../cli-backend" +import { getAutocompleteModel } from "../../shared/autocomplete-models" const CONFIG_SECTION = "kilo-code.new.autocomplete" @@ -26,6 +27,7 @@ function readSettings(): AutocompleteServiceSettings { enableAutoTrigger: config.get("enableAutoTrigger") ?? true, enableSmartInlineTaskKeybinding: config.get("enableSmartInlineTaskKeybinding") ?? true, enableChatAutocomplete: config.get("enableChatAutocomplete") ?? true, + model: getAutocompleteModel(config.get("model") ?? "").id, snoozeUntil: config.get("snoozeUntil"), } } @@ -118,6 +120,10 @@ export class AutocompleteServiceManager { public async load() { this.settings = readSettings() + if (this.settings.model) { + this.model.setModel(this.settings.model) + } + await this.updateGlobalContext() this.updateStatusBar() await this.ensureInlineCompletionProviderRegistration() diff --git a/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteModel.spec.ts b/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteModel.spec.ts index 6ab38e7ff42..cce80476b89 100644 --- a/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteModel.spec.ts +++ b/packages/kilo-vscode/src/services/autocomplete/__tests__/AutocompleteModel.spec.ts @@ -83,9 +83,16 @@ describe("AutocompleteModel", () => { }) describe("getProviderDisplayName", () => { - it("returns Kilo Gateway", () => { + it("returns the default provider", () => { const model = new AutocompleteModel() - expect(model.getProviderDisplayName()).toBe("Kilo Gateway") + expect(model.getProviderDisplayName()).toBe("Mistral AI") + }) + + it("returns the selected provider", () => { + const model = new AutocompleteModel() + model.setModel("inception/mercury-edit") + + expect(model.getProviderDisplayName()).toBe("Inception") }) }) @@ -136,6 +143,23 @@ describe("AutocompleteModel", () => { }) }) + it("streams text-completion chunks", async () => { + const chunks = [{ choices: [{ text: "hello" }] }, { choices: [{ text: " world" }] }] + + const connection = createMockConnectionService("connected") + mockClient.kilo.fim.mockResolvedValue({ + stream: (async function* () { + for (const chunk of chunks) yield chunk + })(), + }) + + const model = new AutocompleteModel(connection) + const received: string[] = [] + await model.generateFimResponse("prefix", "suffix", (text) => received.push(text)) + + expect(received).toEqual(["hello", " world"]) + }) + it("passes model parameters to fim call", async () => { const connection = createMockConnectionService("connected") mockClient.kilo.fim.mockResolvedValue({ @@ -157,5 +181,24 @@ describe("AutocompleteModel", () => { expect.objectContaining({ signal }), ) }) + + it("passes selected model parameters to fim call", async () => { + const connection = createMockConnectionService("connected") + mockClient.kilo.fim.mockResolvedValue({ + stream: (async function* () {})(), + }) + + const model = new AutocompleteModel(connection) + model.setModel("inception/mercury-edit") + await model.generateFimResponse("pre", "suf", vi.fn()) + + expect(mockClient.kilo.fim).toHaveBeenCalledWith( + expect.objectContaining({ + model: "inception/mercury-edit", + temperature: 0, + }), + expect.any(Object), + ) + }) }) }) diff --git a/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts b/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts new file mode 100644 index 00000000000..9ee596bc23b --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/__tests__/settings.spec.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const state = new Map() +const update = vi.fn(async (key: string, value: unknown) => { + state.set(key, value) +}) + +vi.mock("vscode", () => ({ + ConfigurationTarget: { + Global: 1, + }, + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn((key: string, fallback: unknown) => state.get(key) ?? fallback), + update, + })), + onDidChangeConfiguration: vi.fn(), + }, +})) + +describe("autocomplete settings", () => { + beforeEach(() => { + state.clear() + update.mockClear() + }) + + it("includes the configured model in loaded settings", async () => { + state.set("model", "inception/mercury-edit") + const { buildAutocompleteSettingsMessage } = await import("../settings") + + expect(buildAutocompleteSettingsMessage().settings.model).toBe("inception/mercury-edit") + }) + + it("defaults to codestral when no model is set", async () => { + const { buildAutocompleteSettingsMessage } = await import("../settings") + + expect(buildAutocompleteSettingsMessage().settings.model).toBe("mistralai/codestral-2508") + }) + + it("defaults to codestral when stored model is no longer supported", async () => { + state.set("model", "some/removed-model") + const { buildAutocompleteSettingsMessage } = await import("../settings") + + expect(buildAutocompleteSettingsMessage().settings.model).toBe("mistralai/codestral-2508") + }) + + it("persists supported model updates", async () => { + const post = vi.fn() + const { routeAutocompleteMessage } = await import("../settings") + + await routeAutocompleteMessage( + { type: "updateAutocompleteSetting", key: "model", value: "inception/mercury-edit" }, + post, + ) + + expect(update).toHaveBeenCalledWith("model", "inception/mercury-edit", 1) + expect(post).toHaveBeenCalledWith(expect.objectContaining({ type: "autocompleteSettingsLoaded" })) + }) + + it("rejects unsupported model updates", async () => { + const post = vi.fn() + const { routeAutocompleteMessage } = await import("../settings") + + await routeAutocompleteMessage({ type: "updateAutocompleteSetting", key: "model", value: "other/model" }, post) + + expect(update).not.toHaveBeenCalled() + expect(post).not.toHaveBeenCalled() + }) + + it("rejects non-boolean toggle updates", async () => { + const post = vi.fn() + const { routeAutocompleteMessage } = await import("../settings") + + await routeAutocompleteMessage({ type: "updateAutocompleteSetting", key: "enableAutoTrigger", value: "true" }, post) + + expect(update).not.toHaveBeenCalled() + expect(post).not.toHaveBeenCalled() + }) +}) diff --git a/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts b/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts index 35117fca858..328ba612eb4 100644 --- a/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts +++ b/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts @@ -7,6 +7,7 @@ import { postprocessAutocompleteSuggestion } from "../classic-auto-complete/usel import { VisibleCodeTracker } from "../context/VisibleCodeTracker" import { FileIgnoreController } from "../shims/FileIgnoreController" import type { KiloConnectionService } from "../../cli-backend" +import { getAutocompleteModel } from "../../../shared/autocomplete-models" import { finalizeChatSuggestion, buildChatPrefix } from "./chat-autocomplete-utils" interface ChatCompletionRequestMessage { @@ -75,6 +76,8 @@ export class ChatTextAreaAutocomplete { } async getCompletion(userText: string, visibleCodeContext?: VisibleCodeContext): Promise<{ suggestion: string }> { + const cfg = vscode.workspace.getConfiguration("kilo-code.new.autocomplete") + this.model.setModel(getAutocompleteModel(cfg.get("model") ?? "").id) const startTime = Date.now() // Build context for telemetry diff --git a/packages/kilo-vscode/src/services/autocomplete/settings.ts b/packages/kilo-vscode/src/services/autocomplete/settings.ts index fa2f52a06f8..029d4921291 100644 --- a/packages/kilo-vscode/src/services/autocomplete/settings.ts +++ b/packages/kilo-vscode/src/services/autocomplete/settings.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" +import { AUTOCOMPLETE_MODELS, getAutocompleteModel } from "../../shared/autocomplete-models" -const keys = new Set(["enableAutoTrigger", "enableSmartInlineTaskKeybinding", "enableChatAutocomplete"]) +const keys = new Set(["enableAutoTrigger", "enableSmartInlineTaskKeybinding", "enableChatAutocomplete", "model"]) type Message = { type: string @@ -34,6 +35,7 @@ export function buildAutocompleteSettingsMessage() { enableAutoTrigger: config.get("enableAutoTrigger", true), enableSmartInlineTaskKeybinding: config.get("enableSmartInlineTaskKeybinding", false), enableChatAutocomplete: config.get("enableChatAutocomplete", false), + model: getAutocompleteModel(config.get("model") ?? "").id, }, } } @@ -50,6 +52,7 @@ export function watchAutocompleteConfig(post: Post): vscode.Disposable { async function update(key: unknown, value: unknown) { if (typeof key !== "string") return false if (!keys.has(key)) return false + if (!valid(key, value)) return false await vscode.workspace .getConfiguration("kilo-code.new.autocomplete") @@ -57,3 +60,12 @@ async function update(key: unknown, value: unknown) { return true } + +function valid(key: string, value: unknown) { + if (key === "model") { + if (typeof value !== "string") return false + return AUTOCOMPLETE_MODELS.some((m) => m.id === value) + } + + return typeof value === "boolean" +} diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index 0a237f5c29d..1c980b63c3d 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -6,9 +6,9 @@ import type { ServerConfig } from "./types" import { resolveEventSessionId as resolveEventSessionIdPure } from "./connection-utils" export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" -type SSEEventListener = (event: Event) => void +type SSEEventListener = (event: Event, directory?: string) => void type StateListener = (state: ConnectionState) => void -type SSEEventFilter = (event: Event) => boolean +type SSEEventFilter = (event: Event, directory?: string) => boolean type NotificationDismissListener = (notificationId: string) => void type LanguageChangeListener = (locale: string) => void type ProfileChangeListener = (data: unknown) => void @@ -192,11 +192,11 @@ export class KiloConnectionService { * Subscribe to SSE events with a filter. The filter runs for every incoming SSE event. */ onEventFiltered(filter: SSEEventFilter, listener: SSEEventListener): () => void { - const wrapped: SSEEventListener = (event) => { - if (!filter(event)) { + const wrapped: SSEEventListener = (event, directory) => { + if (!filter(event, directory)) { return } - listener(event) + listener(event, directory) } return this.onEvent(wrapped) } @@ -355,10 +355,7 @@ export class KiloConnectionService { /** * Reject all pending permission requests and questions across every - * directory known to any KiloProvider **and** every project the CLI - * backend has ever opened. The project list covers worktree sessions - * whose provider was disposed (panel/sidebar closed) while the CLI - * backend kept running. + * directory known to any currently-mounted KiloProvider. * * Must be called before operations that trigger Instance.disposeAll() * (e.g. config save) to prevent orphaned Promises from freezing @@ -370,7 +367,11 @@ export class KiloConnectionService { async drainPendingPrompts(): Promise { if (!this.client) return - // Collect directories from all mounted providers (root + worktree dirs). + // Only drain directories from currently-mounted providers (root + worktree dirs). + // Previously this also called project.list() to include every historically-opened + // directory, but each permission/question list call goes through Instance.provide() + // which bootstraps fresh instances (including indexing) for directories without + // cached instances. Disposed worktree sessions can't have pending prompts anyway. const dirs = new Set() for (const provider of this.directoryProviders) { for (const dir of provider()) { @@ -378,16 +379,6 @@ export class KiloConnectionService { } } - // Also include every project directory the CLI backend knows about. - // This covers worktree sessions whose KiloProvider was already disposed. - const { data: projects, error: projectsErr } = await this.client.project.list() - if (projectsErr) throw new Error(`Failed to list projects: ${String(projectsErr)}`) - if (projects) { - for (const p of projects) { - dirs.add(p.worktree) - } - } - for (const dir of dirs) { const { data: perms, error: permsErr } = await this.client.permission.list({ directory: dir }) if (permsErr) throw new Error(`Failed to list permissions for ${dir}: ${String(permsErr)}`) @@ -594,9 +585,9 @@ export class KiloConnectionService { let didConnect = false // Wire SSE events → broadcast to all registered listeners - this.sseClient.onEvent((event) => { + this.sseClient.onEvent((event, directory) => { for (const listener of this.eventListeners) { - listener(event) + listener(event, directory) } }) diff --git a/packages/kilo-vscode/src/services/cli-backend/index.ts b/packages/kilo-vscode/src/services/cli-backend/index.ts index 261cc1f5d1c..813f295cee4 100644 --- a/packages/kilo-vscode/src/services/cli-backend/index.ts +++ b/packages/kilo-vscode/src/services/cli-backend/index.ts @@ -1,6 +1,4 @@ // Main exports for cli-backend services -export type { KilocodeNotification } from "./types" - export { KiloConnectionService } from "./connection-service" export { ServerStartupError } from "./server-manager" diff --git a/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts b/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts index 1e7e2bd6f2a..374d34a16b6 100644 --- a/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts +++ b/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts @@ -1,6 +1,6 @@ import type { KiloClient, GlobalEvent, Event } from "@kilocode/sdk/v2/client" -export type SSEEventHandler = (event: Event) => void +export type SSEEventHandler = (event: Event, directory?: string) => void export type SSEErrorHandler = (error: Error) => void export type SSEStateHandler = (state: "connecting" | "connected" | "disconnected") => void @@ -177,7 +177,7 @@ export class SdkSSEAdapter { if (type !== "server.heartbeat") { console.log("[Kilo New] SSE: 📨 Event:", type) } - this.notifyEvent(globalEvent.payload as Event) + this.notifyEvent(globalEvent.payload as Event, globalEvent.directory) } console.log("[Kilo New] SSE: 📭 Stream ended normally") @@ -229,10 +229,10 @@ export class SdkSSEAdapter { // ── Notify helpers ───────────────────────────────────────────────── - private notifyEvent(event: Event): void { + private notifyEvent(event: Event, directory?: string): void { for (const handler of this.handlers) { try { - handler(event) + handler(event, directory) } catch (error) { console.error("[Kilo New] SSE: Error in event handler:", error) } diff --git a/packages/kilo-vscode/src/services/cli-backend/types.ts b/packages/kilo-vscode/src/services/cli-backend/types.ts index 2c0ab42435c..b6755004547 100644 --- a/packages/kilo-vscode/src/services/cli-backend/types.ts +++ b/packages/kilo-vscode/src/services/cli-backend/types.ts @@ -5,6 +5,8 @@ // equivalents in @kilocode/sdk. All API types (Session, Event, Agent, // McpStatus, Config, etc.) should be imported from "@kilocode/sdk/v2/client". +import type { IndexingStatus as SdkIndexingStatus } from "@kilocode/sdk/v2/client" + /** Connection config used by the extension to reach the local CLI server */ export interface ServerConfig { baseUrl: string @@ -18,21 +20,6 @@ interface ProviderAuthAuthorization { instructions: string } -// Kilo notification from kilo-gateway -export interface KilocodeNotificationAction { - actionText: string - actionURL: string -} - -export interface KilocodeNotification { - id: string - title: string - message: string - action?: KilocodeNotificationAction - showIn?: string[] - suggestModelId?: string -} - // Profile types from kilo-gateway export interface KilocodeOrganization { id: string @@ -95,6 +82,8 @@ export interface CloudSessionData { messages: CloudSessionMessage[] } +export type IndexingStatus = SdkIndexingStatus + /** VS Code editor context sent alongside messages to the CLI backend */ interface WorktreeFileDiff { file: string diff --git a/packages/kilo-vscode/src/shared/autocomplete-models.ts b/packages/kilo-vscode/src/shared/autocomplete-models.ts new file mode 100644 index 00000000000..203be8daea5 --- /dev/null +++ b/packages/kilo-vscode/src/shared/autocomplete-models.ts @@ -0,0 +1,44 @@ +/** + * Single source of truth for autocomplete FIM model definitions. + * + * Shared between extension code (src/) and webview code (webview-ui/). + * When adding a new model, update ONLY this file and package.json's + * `kilo-code.new.autocomplete.model` enum. + */ + +export interface AutocompleteModelDef { + /** Full model ID sent to the gateway, e.g. "mistralai/codestral-2508" */ + readonly id: string + /** Human-readable label shown in the settings dropdown */ + readonly label: string + /** Provider display name for status bar / telemetry */ + readonly provider: string + /** FIM request temperature */ + readonly temperature: number +} + +const models: AutocompleteModelDef[] = [ + { + id: "mistralai/codestral-2508", + label: "Codestral (Mistral AI)", + provider: "Mistral AI", + temperature: 0.2, + }, + { + id: "inception/mercury-edit", + label: "Mercury Edit (Inception)", + provider: "Inception", + temperature: 0, + }, +] + +export const AUTOCOMPLETE_MODELS: readonly AutocompleteModelDef[] = models + +export const DEFAULT_AUTOCOMPLETE_MODEL: AutocompleteModelDef = models[0]! + +export function getAutocompleteModel(id: string): AutocompleteModelDef { + for (const m of models) { + if (m.id === id) return m + } + return DEFAULT_AUTOCOMPLETE_MODEL +} diff --git a/packages/kilo-vscode/tests/indexing-provider-blur-race.spec.ts b/packages/kilo-vscode/tests/indexing-provider-blur-race.spec.ts new file mode 100644 index 00000000000..3b5b35beeae --- /dev/null +++ b/packages/kilo-vscode/tests/indexing-provider-blur-race.spec.ts @@ -0,0 +1,63 @@ +import { test, expect, type Page } from "@playwright/test" +import { platform } from "node:os" + +const IS_DARWIN = platform() === "darwin" + +if (IS_DARWIN) { + console.warn("Visual regression tests must be run on CI, skipping on local macOS.") + test.skip() +} + +const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" +const STORY_ID = "settings--indexing-provider-blur-race" + +type Saved = { + provider?: string + openai?: { apiKey?: string } + gemini?: { apiKey?: string } +} + +function storyUrl() { + return `/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}` +} + +async function disableAnimations(page: Page) { + await page.addStyleTag({ + content: ` + *, *::before, *::after { + animation-duration: 0s !important; + animation-delay: 0s !important; + transition-duration: 0s !important; + transition-delay: 0s !important; + } + `, + }) +} + +test("provider switch writes to selected provider bucket", async ({ page }) => { + await page.setViewportSize({ width: 420, height: 720 }) + await page.goto(storyUrl(), { waitUntil: "load" }) + await disableAnimations(page) + await page.waitForSelector("#storybook-root *", { state: "attached" }) + + const saved = page.getByTestId("indexing-provider-save") + + const trigger = page.locator('[data-component="select"] [data-slot="select-select-trigger"]').first() + await trigger.click() + await page.locator('[data-slot="select-select-item-label"]', { hasText: "Gemini" }).click() + + await expect + .poll(async () => { + const text = ((await saved.textContent()) ?? "{}").trim() + const cfg = JSON.parse(text) as Saved + return cfg.provider + }) + .toBe("gemini") + + const text = ((await saved.textContent()) ?? "{}").trim() + const cfg = JSON.parse(text) as Saved + + expect(cfg.provider).toBe("gemini") + expect(cfg.openai?.apiKey ?? "").toBe("") + expect(cfg.gemini?.apiKey ?? "").toBe("") +}) diff --git a/packages/kilo-vscode/tests/unit/autocomplete-models-sync.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-models-sync.test.ts new file mode 100644 index 00000000000..e7dc5657839 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/autocomplete-models-sync.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "bun:test" +import { readFileSync } from "node:fs" +import { join } from "node:path" +import { AUTOCOMPLETE_MODELS } from "../../src/shared/autocomplete-models" + +describe("autocomplete model enum ↔ AUTOCOMPLETE_MODELS sync", () => { + const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf8")) + const prop = pkg.contributes.configuration.properties["kilo-code.new.autocomplete.model"] + + it("package.json enum matches AUTOCOMPLETE_MODELS ids", () => { + const ids = AUTOCOMPLETE_MODELS.map((m) => m.id) + expect(prop.enum).toEqual(ids) + }) + + it("package.json enumDescriptions has one entry per model", () => { + expect(prop.enumDescriptions).toHaveLength(AUTOCOMPLETE_MODELS.length) + }) + + it("package.json does not declare a default (VS Code strips user overrides that equal the schema default)", () => { + expect(prop.default).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/config-utils.test.ts b/packages/kilo-vscode/tests/unit/config-utils.test.ts index c8974427c11..15ae52b443e 100644 --- a/packages/kilo-vscode/tests/unit/config-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/config-utils.test.ts @@ -155,4 +155,53 @@ describe("ConfigState", () => { expect(s.config.snapshot).toBe(true) expect(s.dirty).toBe(false) }) + + // ------------------------------------------------------------------------- + // Issue #9527: clearing an agent model override must unset it, not repopulate + // ------------------------------------------------------------------------- + describe("clearing an agent model override (issue #9527)", () => { + it("keeps null in the draft so the backend receives a delete sentinel", () => { + const s = new ConfigState() + s.handleConfigLoaded({ agent: { explore: { model: "anthropic/claude-sonnet-4-20250514" } } }) + + // User clears the Model Override field. ModeEditView now sends `null` + // instead of `undefined` (the fix). null is the delete sentinel that + // patchJsonc maps to jsonc-parser's remove operation. + s.updateConfig({ agent: { explore: { model: null } } }) + + // Optimistic UI: stripNulls removes the key so the field renders empty. + expect(s.config.agent?.explore?.model).toBeUndefined() + expect(s.dirty).toBe(true) + + // Draft must retain the null so it survives JSON.stringify on the wire + // and reaches patchJsonc as an explicit delete. + expect(s.draft.agent?.explore?.model).toBeNull() + expect(JSON.parse(JSON.stringify(s.draft))).toEqual({ + agent: { explore: { model: null } }, + }) + }) + + it("undefined (the old buggy behavior) is dropped by JSON.stringify", () => { + // Reproduction of the pre-fix bug: sending `undefined` results in an + // empty patch on the wire, so the backend never deletes the override + // and the next configUpdated pushes the stale model back into the UI. + const draft = { agent: { explore: { model: undefined } } } + expect(JSON.parse(JSON.stringify(draft))).toEqual({ agent: { explore: {} } }) + }) + + it("confirms the save and drops the draft once the backend acks", () => { + const s = new ConfigState() + s.handleConfigLoaded({ agent: { explore: { model: "anthropic/claude-sonnet-4-20250514" } } }) + s.updateConfig({ agent: { explore: { model: null } } }) + s.saveConfig() + + // Backend removed the override and pushes the stripped config back. + s.handleConfigUpdated({ agent: { explore: {} } }) + + expect(s.config.agent?.explore?.model).toBeUndefined() + expect(s.dirty).toBe(false) + expect(s.saving).toBe(false) + expect(Object.keys(s.draft).length).toBe(0) + }) + }) }) diff --git a/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts b/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts index 337dd77a144..a045e2e1f76 100644 --- a/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts +++ b/packages/kilo-vscode/tests/unit/custom-provider-dialog-validate.test.ts @@ -28,6 +28,18 @@ function args(form: FormState) { } describe("validateCustomProvider – variant name validation", () => { + it("allows reconnecting a disabled provider id", () => { + const form = base() + const out = validateCustomProvider({ + ...args(form), + disabledProviders: ["my-provider"], + existingProviderIDs: new Set(["my-provider"]), + }) + + expect(out.result?.providerID).toBe("my-provider") + expect(out.errors.providerID).toBeUndefined() + }) + it("allows submit when reasoning is enabled with no variants", () => { const form = base() form.models[0].reasoning = true diff --git a/packages/kilo-vscode/tests/unit/databridge-shape.test.ts b/packages/kilo-vscode/tests/unit/databridge-shape.test.ts index 353edcfd519..be286d3408f 100644 --- a/packages/kilo-vscode/tests/unit/databridge-shape.test.ts +++ b/packages/kilo-vscode/tests/unit/databridge-shape.test.ts @@ -2,6 +2,9 @@ import { describe, it, expect } from "bun:test" import { readFileSync } from "node:fs" import { join } from "node:path" +const APP_FILE = join(__dirname, "..", "..", "webview-ui", "src", "App.tsx") +const src = readFileSync(APP_FILE, "utf8") + /** * Static guard against the perf regression fixed in this PR. * @@ -28,9 +31,6 @@ import { join } from "node:path" * `tests/webview-reactivity/databridge-reactivity.test.ts`. */ describe("DataBridge shape (perf regression guard)", () => { - const path = join(__dirname, "..", "..", "webview-ui", "src", "App.tsx") - const src = readFileSync(path, "utf8") - it("DataBridge exists in App.tsx", () => { expect(src).toMatch(/export const DataBridge/) }) @@ -59,3 +59,18 @@ describe("DataBridge shape (perf regression guard)", () => { expect(block).toMatch(/get\s+part\s*\(\s*\)\s*\{/) }) }) + +describe("DataBridge openDiff wiring (regression guard)", () => { + const openDiffBlock = () => { + const match = src.match(/const\s+openDiff\s*=\s*\(diff:\s*\{[\s\S]*?\n\s*\}\n\n\s*const\s+openUrl/) + expect(match).toBeTruthy() + return match![0] + } + + it("wires openDiff to the openDiffVirtual webview message", () => { + expect(openDiffBlock()).toMatch( + /postMessage\(\{\s*type:\s*["']openDiffVirtual["']\s*,\s*diff\s*,\s*initialDiffStyle:\s*["']split["']\s*\}\)/, + ) + expect(src).toContain("onOpenDiff={openDiff}") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/extension-arch.test.ts b/packages/kilo-vscode/tests/unit/extension-arch.test.ts index af37e1fbae9..174989a2611 100644 --- a/packages/kilo-vscode/tests/unit/extension-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/extension-arch.test.ts @@ -17,6 +17,24 @@ const SRC_DIR = path.join(ROOT, "src") const EXTENSION_FILE = path.join(ROOT, "src/extension.ts") const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") +function sliceBlock(source: string, start: number): string { + const open = source.indexOf("{", start) + expect(open, "block opening brace must exist").toBeGreaterThan(-1) + + const state = { depth: 0, end: -1 } + Array.from(source.slice(open)).some((ch, i) => { + if (ch === "{") state.depth++ + if (ch === "}") state.depth-- + if (state.depth !== 0) return false + state.end = open + i + return true + }) + + if (state.end > -1) return source.slice(start, state.end + 1) + + throw new Error("block closing brace not found") +} + function readSrcFiles(dir: string): string { const parts: string[] = [] for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { @@ -141,7 +159,7 @@ describe("Extension — KiloProvider handler wiring", () => { it("openKiloInNewTab wires setContinueInWorktreeHandler before resolveWebviewPanel", () => { const fn = ext.indexOf("function openKiloInNewTab") expect(fn, "openKiloInNewTab must exist").toBeGreaterThan(-1) - const body = ext.slice(fn, fn + 1500) + const body = sliceBlock(ext, fn) const handler = body.indexOf("setContinueInWorktreeHandler") const resolve = body.indexOf("resolveWebviewPanel") expect(handler, "setContinueInWorktreeHandler must be called").toBeGreaterThan(-1) @@ -152,7 +170,7 @@ describe("Extension — KiloProvider handler wiring", () => { it("TabPanel deserializer wires setContinueInWorktreeHandler before resolveWebviewPanel", () => { const serializer = ext.indexOf('"kilo-code.new.TabPanel"') expect(serializer, "TabPanel serializer must exist").toBeGreaterThan(-1) - const body = ext.slice(serializer, serializer + 800) + const body = sliceBlock(ext, serializer) const handler = body.indexOf("setContinueInWorktreeHandler") const resolve = body.indexOf("resolveWebviewPanel") expect(handler, "setContinueInWorktreeHandler must be called in deserializer").toBeGreaterThan(-1) diff --git a/packages/kilo-vscode/tests/unit/indexing-utils.test.ts b/packages/kilo-vscode/tests/unit/indexing-utils.test.ts new file mode 100644 index 00000000000..8405bf23f39 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/indexing-utils.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "bun:test" +import { formatIndexingLabel, indexingTone } from "../../webview-ui/src/context/indexing-utils" +import { mapSSEEventToWebviewMessage } from "../../src/kilo-provider-utils" +import { configFeatures } from "../../src/features" +import type { EventIndexingStatus, IndexingStatus } from "@kilocode/sdk/v2/client" + +function makeStatus(overrides: Partial = {}): IndexingStatus { + return { + state: "Disabled", + message: "Indexing disabled.", + processedFiles: 0, + totalFiles: 0, + percent: 0, + ...overrides, + } +} + +describe("indexing formatting", () => { + it("formats in-progress status like the TUI", () => { + const status = makeStatus({ state: "In Progress", percent: 42, processedFiles: 21, totalFiles: 50 }) + expect(formatIndexingLabel(status)).toBe("IDX 42% 21/50") + }) + + it("formats indeterminate in-progress status without 0/0 counts", () => { + const status = makeStatus({ state: "In Progress", percent: 0, processedFiles: 0, totalFiles: 0 }) + expect(formatIndexingLabel(status)).toBe("IDX In Progress") + }) + + it("formats error status with the backend message", () => { + const status = makeStatus({ state: "Error", message: "Indexing failed." }) + expect(formatIndexingLabel(status)).toBe("IDX Indexing failed.") + }) + + it("formats complete and disabled states with the public state label", () => { + expect(formatIndexingLabel(makeStatus({ state: "Complete" }))).toBe("IDX Complete") + expect(formatIndexingLabel(makeStatus({ state: "Disabled" }))).toBe("IDX Disabled") + expect(formatIndexingLabel(makeStatus({ state: "Standby" }))).toBe("IDX Standby") + }) + + it("maps tones by status", () => { + expect(indexingTone(makeStatus({ state: "Disabled" }))).toBe("muted") + expect(indexingTone(makeStatus({ state: "Standby" }))).toBe("muted") + expect(indexingTone(makeStatus({ state: "In Progress" }))).toBe("warning") + expect(indexingTone(makeStatus({ state: "Complete" }))).toBe("success") + expect(indexingTone(makeStatus({ state: "Error" }))).toBe("error") + }) +}) + +describe("indexing SSE mapping", () => { + it("maps indexing.status to indexingStatusLoaded", () => { + const event: EventIndexingStatus = { + type: "indexing.status", + properties: { + status: makeStatus({ state: "Complete", percent: 100 }), + }, + } + + const msg = mapSSEEventToWebviewMessage(event, undefined) + expect(msg?.type).toBe("indexingStatusLoaded") + if (msg?.type === "indexingStatusLoaded") { + expect(msg.status.state).toBe("Complete") + expect(msg.status.percent).toBe(100) + } + }) + + it("maps indexing.status regardless of sessionID (filtering is caller responsibility)", () => { + const event: EventIndexingStatus = { + type: "indexing.status", + properties: { + status: makeStatus({ state: "Disabled", message: "Indexing is disabled in worktree sessions." }), + }, + } + + const msg = mapSSEEventToWebviewMessage(event, undefined) + expect(msg?.type).toBe("indexingStatusLoaded") + if (msg?.type === "indexingStatusLoaded") { + expect(msg.status.state).toBe("Disabled") + expect(msg.status.message).toBe("Indexing is disabled in worktree sessions.") + } + }) +}) + +describe("indexing feature detection", () => { + it("requires experimental.semantic_indexing when indexing plugin is present", () => { + expect(configFeatures({ plugin: ["kilo-indexing"] }).indexing).toBe(false) + expect(configFeatures({ plugin: ["kilo-indexing"], experimental: {} }).indexing).toBe(false) + expect(configFeatures({ plugin: ["kilo-indexing"], experimental: { semantic_indexing: false } }).indexing).toBe( + false, + ) + }) + + it("detects supported indexing plugin specifiers when experimental.semantic_indexing is true", () => { + expect(configFeatures({ plugin: ["kilo-indexing"], experimental: { semantic_indexing: true } }).indexing).toBe(true) + expect( + configFeatures({ plugin: ["kilo-indexing@1.2.3"], experimental: { semantic_indexing: true } }).indexing, + ).toBe(true) + expect( + configFeatures({ plugin: ["@kilocode/kilo-indexing"], experimental: { semantic_indexing: true } }).indexing, + ).toBe(true) + expect( + configFeatures({ plugin: ["@kilocode/kilo-indexing@1.2.3"], experimental: { semantic_indexing: true } }).indexing, + ).toBe(true) + expect( + configFeatures({ + plugin: ["file:///tmp/.opencode/plugin/kilo-indexing.js"], + experimental: { semantic_indexing: true }, + }).indexing, + ).toBe(true) + expect( + configFeatures({ + plugin: ["file:///tmp/node_modules/@kilocode/kilo-indexing/index.js"], + experimental: { semantic_indexing: true }, + }).indexing, + ).toBe(true) + }) + + it("ignores unrelated plugin lists", () => { + expect(configFeatures({ plugin: ["@kilocode/kilo-gateway"] }).indexing).toBe(false) + expect(configFeatures({ plugin: ["file:///tmp/.opencode/plugin/index.js"] }).indexing).toBe(false) + expect(configFeatures({}).indexing).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts index fdea58318cb..77ec50adf60 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-followup.test.ts @@ -67,6 +67,7 @@ function connection() { onFavoritesChanged: () => () => undefined, registerDirectoryProvider: () => () => undefined, getServerInfo: () => ({ port: 12345 }), + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:12345", password: "test" }), getConnectionState: () => "connected" as const, resolveEventSessionId: (event: Event) => (event.type === "session.created" ? event.properties.info.id : undefined), recordMessageSessionId: () => undefined, diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts new file mode 100644 index 00000000000..c1f8386e2bc --- /dev/null +++ b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "bun:test" +import type { Config } from "@kilocode/sdk/v2/client" + +// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts) +const { KiloProvider } = await import("../../src/KiloProvider") + +type Internals = { + connectionState: "connecting" | "connected" | "disconnected" | "error" + currentSession: { id: string } | null + reloadAfterAuthChange: () => Promise + handleUpdateConfig: (partial: Partial) => Promise + fetchAndSendConfig: () => Promise + fetchAndSendProviders: () => Promise + fetchAndSendAgents: () => Promise + fetchAndSendSkills: () => Promise + fetchAndSendCommands: () => Promise + fetchAndSendNotifications: () => Promise + fetchAndSendIndexingStatus: () => Promise +} + +function createConnection() { + let drains = 0 + const client = { + global: { + config: { + update: async () => ({ data: {} }), + }, + }, + config: { + get: async () => ({ data: {} }), + }, + } + + return { + drains: () => drains, + service: { + drainPendingPrompts: async () => { + drains += 1 + }, + getClient: () => client, + }, + } +} + +describe("KiloProvider indexing refresh", () => { + it("reloadAfterAuthChange fetches config first, then indexing status", async () => { + const provider = new KiloProvider({} as never, {} as never) + const internal = provider as unknown as Internals + const calls: string[] = [] + + internal.fetchAndSendConfig = async () => { + calls.push("config") + } + internal.fetchAndSendProviders = async () => { + calls.push("providers") + } + internal.fetchAndSendAgents = async () => { + calls.push("agents") + } + internal.fetchAndSendSkills = async () => { + calls.push("skills") + } + internal.fetchAndSendCommands = async () => { + calls.push("commands") + } + internal.fetchAndSendNotifications = async () => { + calls.push("notifications") + } + internal.fetchAndSendIndexingStatus = async () => { + calls.push("indexing") + } + + await internal.reloadAfterAuthChange() + + expect(calls[0]).toBe("config") + expect(calls.includes("indexing")).toBe(true) + }) + + it("handleUpdateConfig no longer eagerly fetches indexing status", async () => { + const conn = createConnection() + const provider = new KiloProvider({} as never, conn.service as never) + const internal = provider as unknown as Internals + + let indexing = 0 + internal.connectionState = "connected" + internal.fetchAndSendIndexingStatus = async () => { + indexing += 1 + } + + await internal.handleUpdateConfig({}) + + expect(conn.drains()).toBe(1) + expect(indexing).toBe(0) + }) + + it("fetchAndSendIndexingStatus uses current session directory header", async () => { + const worktree = "/repo/.kilo/.kilocode/worktrees/feature" + const calls: { input: RequestInfo | URL; init?: RequestInit }[] = [] + const original = globalThis.fetch + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ input, init }) + return new Response( + JSON.stringify({ + state: "Disabled", + message: "Indexing is disabled in worktree sessions.", + processedFiles: 0, + totalFiles: 0, + percent: 0, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ) + }) as typeof fetch + + try { + const provider = new KiloProvider( + {} as never, + { + getClient: () => ({}) as never, + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:9999", password: "secret" }), + } as never, + ) + const internal = provider as unknown as Internals + provider.setSessionDirectory("ses_worktree", worktree) + internal.currentSession = { id: "ses_worktree" } + + await internal.fetchAndSendIndexingStatus() + + expect(calls.length).toBe(1) + const headers = new Headers(calls[0]?.init?.headers) + const auth = Buffer.from("kilo:secret").toString("base64") + expect(headers.get("Authorization")).toBe(`Basic ${auth}`) + expect(headers.get("x-kilo-directory")).toBe(worktree) + } finally { + globalThis.fetch = original + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts index 2dd31cf7b9b..b6e24bd2e8b 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-session-refresh.test.ts @@ -52,10 +52,14 @@ function createClient() { }, app: { agents: async () => ({ data: [] }), + skills: async () => ({ data: [] }), }, config: { get: async () => ({ data: {} }), }, + indexing: { + status: async () => ({ data: { state: "disabled" } }), + }, kilo: { notifications: async () => ({ data: [] }), profile: async () => ({ data: {} }), @@ -85,6 +89,7 @@ function createConnection(client: ReturnType) { onClearPendingPrompts: () => () => undefined, registerDirectoryProvider: () => () => undefined, getServerInfo: () => ({ port: 12345 }), + getServerConfig: () => ({ baseUrl: "http://127.0.0.1:12345", password: "test" }), getConnectionState: () => "connected" as const, resolveEventSessionId: () => undefined, recordMessageSessionId: () => undefined, diff --git a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts index 280b9b022f7..2db786d39d6 100644 --- a/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-ui-contract.test.ts @@ -21,6 +21,7 @@ const MONOREPO_ROOT = path.resolve(import.meta.dir, "../../../..") const KILO_UI_DIR = path.join(MONOREPO_ROOT, "packages/kilo-ui") const DATA_CONTEXT_FILE = path.join(MONOREPO_ROOT, "packages/ui/src/context/data.tsx") const MESSAGE_PART_FILE = path.join(MONOREPO_ROOT, "packages/ui/src/components/message-part.tsx") +const KILO_MESSAGE_PART_FILE = path.join(MONOREPO_ROOT, "packages/kilo-ui/src/components/message-part.tsx") function check(code: string): { ok: boolean; output: string } { const result = Bun.spawnSync(["bun", "--conditions=browser", "-e", code], { @@ -118,6 +119,27 @@ describe("DataProvider contract (runtime)", () => { expect(src).toContain("OpenFileFn") expect(src).toMatch(/openFile:\s*props\.onOpenFile/) }) + + it("DataProvider accepts onOpenDiff prop and exports OpenDiffFn (source)", () => { + // onOpenDiff and OpenDiffFn are `kilocode_change` additions — TypeScript types + // erased at runtime, so we verify via source analysis + const src = fs.readFileSync(DATA_CONTEXT_FILE, "utf-8") + expect(src).toContain("onOpenDiff") + expect(src).toContain("OpenDiffFn") + expect(src).toMatch(/openDiff:\s*props\.onOpenDiff/) + }) +}) + +describe("Edit tool diff-first click contract (source)", () => { + const src = fs.readFileSync(KILO_MESSAGE_PART_FILE, "utf-8") + + const editBlockMatch = src.match(/ToolRegistry\.register\(\{\s*name:\s*"edit"[\s\S]*?(?=ToolRegistry\.register\(|$)/) + const editBlock = editBlockMatch?.[0] ?? "" + + it("edit tool derives before/after content from filediff or input", () => { + expect(editBlock).toMatch(/filediff\?\.before\s*\?\?.*oldString/) + expect(editBlock).toMatch(/filediff\?\.after\s*\?\?.*newString/) + }) }) describe("BasicTool export contract (runtime)", () => { diff --git a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts index 0f550492bf5..2e04caa3adf 100644 --- a/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts +++ b/packages/kilo-vscode/tests/unit/provider-actions-save.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { fetchProviderData, saveCustomProvider } from "../../src/provider-actions" +import { disconnectProvider, fetchProviderData, saveCustomProvider } from "../../src/provider-actions" type ExistingGlobal = { disabled_providers?: string[]; provider?: Record } @@ -26,6 +26,24 @@ function createCtx(existing: ExistingGlobal = { disabled_providers: [] }) { return { data: true } }, }, + provider: { + list: async () => ({ + data: { + all: [ + { + id: "openai", + name: "OpenAI", + source: "custom", + env: [], + models: {}, + }, + ], + connected: ["openai"], + default: {}, + }, + }), + auth: async () => ({ data: {} }), + }, global: { config: { get: async () => ({ data: existing }), @@ -64,6 +82,26 @@ function createProvider() { } } +describe("disconnectProvider", () => { + it("keeps configured provider enabled after disconnecting oauth override", async () => { + const existing = { + disabled_providers: ["openai", "groq"], + provider: { + openai: { + options: { apiKey: "sk-test" }, + }, + }, + } + const { ctx, calls, setCachedConfig } = createCtx(existing) + + await disconnectProvider(ctx, "req", "openai", null, setCachedConfig) + + expect(calls.remove).toEqual([{ providerID: "openai" }]) + expect(calls.config).toEqual([{ config: { disabled_providers: ["groq"] } }]) + expect(calls.refresh).toBe(1) + }) +}) + describe("saveCustomProvider", () => { it("preserves auth when the api key field is unchanged", async () => { const { ctx, calls, setCachedConfig } = createCtx() @@ -191,6 +229,50 @@ describe("saveCustomProvider", () => { .models expect(Object.values(models).every((v) => v !== null)).toBe(true) }) + + it("removes saved custom providers from disabled_providers when reconnecting", async () => { + const { ctx, calls, setCachedConfig } = createCtx({ disabled_providers: ["myprovider", "openai"] }) + + await saveCustomProvider(ctx, "req", "myprovider", createProvider(), undefined, false, null, setCachedConfig) + + expect(calls.config).toHaveLength(1) + expect(calls.config[0].config.disabled_providers).toEqual(["openai"]) + }) +}) + +describe("disconnectProvider", () => { + it("adds configured providers to disabled_providers without deleting their config", async () => { + const existing = { + disabled_providers: ["openai"], + provider: { + myprovider: createProvider(), + }, + } + const { ctx, calls, setCachedConfig } = createCtx(existing) + + await disconnectProvider(ctx, "req", "myprovider", null, setCachedConfig) + + expect(calls.config).toHaveLength(1) + expect(calls.config[0].config).toEqual({ disabled_providers: ["openai", "myprovider"] }) + expect(calls.remove).toEqual([{ providerID: "myprovider" }]) + expect(calls.refresh).toBe(1) + expect(calls.posts).toContainEqual({ type: "providerDisconnected", requestId: "req", providerID: "myprovider" }) + }) + + it("does not duplicate configured providers already disabled", async () => { + const existing = { + disabled_providers: ["myprovider"], + provider: { + myprovider: createProvider(), + }, + } + const { ctx, calls, setCachedConfig } = createCtx(existing) + + await disconnectProvider(ctx, "req", "myprovider", null, setCachedConfig) + + expect(calls.config).toHaveLength(0) + expect(calls.refresh).toBe(1) + }) }) describe("fetchProviderData", () => { diff --git a/packages/kilo-vscode/tests/unit/provider-visibility.test.ts b/packages/kilo-vscode/tests/unit/provider-visibility.test.ts index 5b71ad7ef9b..3efd293f648 100644 --- a/packages/kilo-vscode/tests/unit/provider-visibility.test.ts +++ b/packages/kilo-vscode/tests/unit/provider-visibility.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "bun:test" -import { visibleConnectedIds } from "../../webview-ui/src/components/settings/provider-visibility" +import { + disabledProviderOptions, + providersWithKiloFallback, + visibleConnectedIds, +} from "../../webview-ui/src/components/settings/provider-visibility" describe("visibleConnectedIds", () => { it("hides Kilo from the connected list when auth is missing", () => { @@ -21,3 +25,55 @@ describe("visibleConnectedIds", () => { expect(ids).toEqual(["anthropic"]) }) }) + +describe("disabledProviderOptions", () => { + it("includes Kilo and excludes already disabled providers", () => { + const options = disabledProviderOptions( + { + kilo: { id: "kilo", name: "Kilo Gateway", env: [], models: {} }, + openai: { id: "openai", name: "OpenAI", env: [], models: {} }, + anthropic: { id: "anthropic", name: "Anthropic", env: [], models: {} }, + }, + ["openai"], + ) + + expect(options).toEqual([ + { value: "anthropic", label: "Anthropic" }, + { value: "kilo", label: "Kilo Gateway" }, + ]) + }) + + it("sorts options by provider name", () => { + const options = disabledProviderOptions( + { + zed: { id: "zed", name: "Zed", env: [], models: {} }, + alpha: { id: "alpha", name: "Alpha", env: [], models: {} }, + }, + [], + ) + + expect(options).toEqual([ + { value: "alpha", label: "Alpha" }, + { value: "zed", label: "Zed" }, + ]) + }) +}) + +describe("providersWithKiloFallback", () => { + it("adds Kilo when backend providers omit it", () => { + const providers = providersWithKiloFallback({ + anthropic: { id: "anthropic", name: "Anthropic", env: [], models: {} }, + }) + + expect(providers.kilo?.name).toBe("Kilo Gateway") + expect(providers.anthropic?.name).toBe("Anthropic") + }) + + it("keeps the backend Kilo provider when present", () => { + const providers = providersWithKiloFallback({ + kilo: { id: "kilo", name: "Custom Kilo Name", env: [], models: {} }, + }) + + expect(providers.kilo?.name).toBe("Custom Kilo Name") + }) +}) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 401be6510a9..83d6f6fe496 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1,7 +1,6 @@ -// Agent Manager root component +/** @jsxImportSource solid-js */ import { - Component, For, Show, createSignal, @@ -11,6 +10,7 @@ import { onMount, onCleanup, type Accessor, + type Component, } from "solid-js" import type { ExtensionMessage, @@ -41,6 +41,7 @@ import type { SessionInfo, BranchInfo, } from "../src/types/messages" +import { IndexingProvider } from "../src/context/indexing" import { DragDropProvider, DragDropSensors, @@ -136,14 +137,10 @@ interface ApplyState { message: string conflicts: AgentManagerApplyWorktreeDiffConflict[] } - /** Sidebar selection: LOCAL for local repo, worktree ID for a worktree, or null for an unassigned session. */ type SidebarSelection = typeof LOCAL | string | null - type SidePanel = "diff" | "pr" | null - const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent) - // Fallback keybindings before extension sends resolved ones const MAX_JUMP_INDEX = 9 @@ -178,7 +175,6 @@ function useTabScroll(activeTabs: Accessor, activeId: Accessor() const [showLeft, setShowLeft] = createSignal(false) const [showRight, setShowRight] = createSignal(false) - let scrollFrame: number | undefined const update = () => { if (scrollFrame !== undefined) return @@ -190,7 +186,6 @@ function useTabScroll(activeTabs: Accessor, activeId: Accessor { const el = ref() @@ -199,7 +194,6 @@ function useTabScroll(activeTabs: Accessor, activeId: Accessor 0 ? 60 : -60 } - // Recalculate on scroll, resize, or tab changes createEffect(() => { const el = ref() @@ -217,7 +211,6 @@ function useTabScroll(activeTabs: Accessor, activeId: Accessor { const id = activeId() const el = ref() @@ -236,7 +229,6 @@ function useTabScroll(activeTabs: Accessor, activeId: Accessor { - {t("agentManager.worktree.new")} + - - {t("agentManager.dialog.advanced")} + {t("agentManager.dialog.configureWorktree")} {parseBindingTokens(kb().advancedWorktree ?? "").map((token) => ( {token} @@ -3152,11 +3150,13 @@ export const AgentManagerApp: Component = () => { - - - - - + + + + + + + diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index 13f5692bf68..3e54451f4e1 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -1,6 +1,8 @@ // New Worktree dialog — prompt, versions, model, mode, import tab -import { Component, For, Show, createSignal, createEffect, createMemo, onMount, onCleanup } from "solid-js" +/** @jsxImportSource solid-js */ + +import { type Component, For, Show, createSignal, createEffect, createMemo, onMount, onCleanup } from "solid-js" import type { AgentManagerBranchesMessage, AgentManagerImportResultMessage, BranchInfo } from "../src/types/messages" import { Dialog } from "@kilocode/kilo-ui/dialog" import { showToast } from "@kilocode/kilo-ui/toast" @@ -171,6 +173,17 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran vscode.postMessage({ type: "agentManager.requestBranches" }) // Resize textarea if restoring a cached prompt if (prompt()) adjustHeight() + const focus = () => { + textareaRef?.focus({ preventScroll: true }) + const end = textareaRef?.value.length ?? 0 + textareaRef?.setSelectionRange(end, end) + } + requestAnimationFrame(() => { + focus() + requestAnimationFrame(focus) + setTimeout(focus, 0) + setTimeout(focus, 50) + }) }) const effectiveBaseBranch = () => baseBranch() ?? defaultBranch() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 368cd6863e5..c001212a07f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -2029,15 +2029,36 @@ body.am-wt-dragging-active * { } .am-split-menu { - min-width: 210px; + min-width: 250px; } .am-split-menu [data-slot="dropdown-menu-item"] { padding: 6px 10px; } -.am-split-menu [data-slot="dropdown-menu-separator"] { - margin: 4px 0; +.am-worktree-menu-gap { + display: inline-flex; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.am-worktree-menu-label { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.am-worktree-menu-branch { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.am-worktree-menu-branch strong { + font-weight: 600; } /* New Versioned Session Dialog */ diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts index 8e86eca5d98..8fe7cc66eff 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ar.ts @@ -71,7 +71,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "العنصر التالي", "agentManager.shortcuts.newWorktree": "Worktree جديد", "agentManager.shortcuts.openWorktree": "فتح Worktree", - "agentManager.shortcuts.advancedWorktree": "Worktree متقدم", + "agentManager.shortcuts.advancedWorktree": "تكوين worktree جديد", "agentManager.shortcuts.deleteWorktree": "حذف Worktree", "agentManager.shortcuts.previousTab": "علامة التبويب السابقة", "agentManager.shortcuts.nextTab": "علامة التبويب التالية", @@ -93,6 +93,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "إزالة Worktree القديم", "agentManager.dialog.openWorktree": "شجرة عمل جديدة", + "agentManager.dialog.configureWorktree": "تكوين Worktree جديد...", "agentManager.dialog.tab.new": "جديد", "agentManager.dialog.tab.import": "استيراد", "agentManager.dialog.namePlaceholder": "اسم Worktree (اختياري)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts index cdaf64ae9e6..0403f4757c5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/br.ts @@ -72,7 +72,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "Próximo item", "agentManager.shortcuts.newWorktree": "Novo Worktree", "agentManager.shortcuts.openWorktree": "Abrir Worktree", - "agentManager.shortcuts.advancedWorktree": "Worktree avançado", + "agentManager.shortcuts.advancedWorktree": "Configurar novo worktree", "agentManager.shortcuts.deleteWorktree": "Excluir Worktree", "agentManager.shortcuts.previousTab": "Aba anterior", "agentManager.shortcuts.nextTab": "Próxima aba", @@ -94,6 +94,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "Remover Worktree obsoleto", "agentManager.dialog.openWorktree": "Novo Worktree", + "agentManager.dialog.configureWorktree": "Configurar Novo Worktree...", "agentManager.dialog.tab.new": "Novo", "agentManager.dialog.tab.import": "Importar", "agentManager.dialog.namePlaceholder": "Nome do Worktree (opcional)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts index bedf7a05359..83d07a4300f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/bs.ts @@ -73,7 +73,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "Sljedeći element", "agentManager.shortcuts.newWorktree": "Novi Worktree", "agentManager.shortcuts.openWorktree": "Otvori worktree", - "agentManager.shortcuts.advancedWorktree": "Napredni Worktree", + "agentManager.shortcuts.advancedWorktree": "Konfiguriši novi worktree", "agentManager.shortcuts.deleteWorktree": "Obriši Worktree", "agentManager.shortcuts.previousTab": "Prethodna kartica", "agentManager.shortcuts.nextTab": "Sljedeća kartica", @@ -95,6 +95,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "Ukloni zastarjeli Worktree", "agentManager.dialog.openWorktree": "Novi worktree", + "agentManager.dialog.configureWorktree": "Konfiguriši Novi Worktree...", "agentManager.dialog.tab.new": "Novo", "agentManager.dialog.tab.import": "Uvezi", "agentManager.dialog.namePlaceholder": "Naziv Worktree-a (opcionalno)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts index beed91109b8..b922b7ad487 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/da.ts @@ -72,7 +72,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "Næste element", "agentManager.shortcuts.newWorktree": "Nyt Worktree", "agentManager.shortcuts.openWorktree": "Åbn Worktree", - "agentManager.shortcuts.advancedWorktree": "Avanceret Worktree", + "agentManager.shortcuts.advancedWorktree": "Konfigurer nyt worktree", "agentManager.shortcuts.deleteWorktree": "Slet Worktree", "agentManager.shortcuts.previousTab": "Forrige fane", "agentManager.shortcuts.nextTab": "Næste fane", @@ -94,6 +94,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "Fjern forældet Worktree", "agentManager.dialog.openWorktree": "Ny Worktree", + "agentManager.dialog.configureWorktree": "Konfigurer Nyt Worktree...", "agentManager.dialog.tab.new": "Ny", "agentManager.dialog.tab.import": "Importér", "agentManager.dialog.namePlaceholder": "Worktree-navn (valgfrit)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts index acc48640f06..056b3365ea9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/de.ts @@ -73,7 +73,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "Nächstes Element", "agentManager.shortcuts.newWorktree": "Neuer Worktree", "agentManager.shortcuts.openWorktree": "Worktree öffnen", - "agentManager.shortcuts.advancedWorktree": "Erweiterter Worktree", + "agentManager.shortcuts.advancedWorktree": "Neuen Worktree konfigurieren", "agentManager.shortcuts.deleteWorktree": "Worktree löschen", "agentManager.shortcuts.previousTab": "Vorheriger Tab", "agentManager.shortcuts.nextTab": "Nächster Tab", @@ -95,6 +95,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "Veralteten Worktree entfernen", "agentManager.dialog.openWorktree": "Neuer Worktree", + "agentManager.dialog.configureWorktree": "Neuen Worktree konfigurieren...", "agentManager.dialog.tab.new": "Neu", "agentManager.dialog.tab.import": "Importieren", "agentManager.dialog.namePlaceholder": "Worktree-Name (optional)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts index bde300bd166..e564072f3a3 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/en.ts @@ -77,7 +77,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "Next item", "agentManager.shortcuts.newWorktree": "New worktree", "agentManager.shortcuts.openWorktree": "Open worktree", - "agentManager.shortcuts.advancedWorktree": "Advanced worktree", + "agentManager.shortcuts.advancedWorktree": "Configure new worktree", "agentManager.shortcuts.deleteWorktree": "Delete worktree", "agentManager.shortcuts.previousTab": "Previous tab", "agentManager.shortcuts.nextTab": "Next tab", @@ -122,6 +122,7 @@ export const dict = { "agentManager.dialog.createWorktree": "Create Worktree", "agentManager.dialog.removeImage": "Remove image", "agentManager.dialog.advanced": "Advanced...", + "agentManager.dialog.configureWorktree": "Configure New Worktree...", "agentManager.diff.toggle": "Toggle diff", "agentManager.diff.openFile": "Open file", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts index 6319f590a9e..55fe7198a8f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/es.ts @@ -72,7 +72,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "Siguiente elemento", "agentManager.shortcuts.newWorktree": "Nuevo Worktree", "agentManager.shortcuts.openWorktree": "Abrir Worktree", - "agentManager.shortcuts.advancedWorktree": "Worktree avanzado", + "agentManager.shortcuts.advancedWorktree": "Configurar nuevo worktree", "agentManager.shortcuts.deleteWorktree": "Eliminar Worktree", "agentManager.shortcuts.previousTab": "Pestaña anterior", "agentManager.shortcuts.nextTab": "Siguiente pestaña", @@ -94,6 +94,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "Eliminar Worktree obsoleto", "agentManager.dialog.openWorktree": "Nuevo Worktree", + "agentManager.dialog.configureWorktree": "Configurar Nuevo Worktree...", "agentManager.dialog.tab.new": "Nuevo", "agentManager.dialog.tab.import": "Importar", "agentManager.dialog.namePlaceholder": "Nombre del Worktree (opcional)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts index 840d96b4137..d61da449af9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/fr.ts @@ -72,7 +72,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "Élément suivant", "agentManager.shortcuts.newWorktree": "Nouveau Worktree", "agentManager.shortcuts.openWorktree": "Ouvrir le worktree", - "agentManager.shortcuts.advancedWorktree": "Worktree avancé", + "agentManager.shortcuts.advancedWorktree": "Configurer un nouveau worktree", "agentManager.shortcuts.deleteWorktree": "Supprimer le Worktree", "agentManager.shortcuts.previousTab": "Onglet précédent", "agentManager.shortcuts.nextTab": "Onglet suivant", @@ -94,6 +94,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "Supprimer le Worktree obsolète", "agentManager.dialog.openWorktree": "Nouveau worktree", + "agentManager.dialog.configureWorktree": "Configurer un Nouveau Worktree...", "agentManager.dialog.tab.new": "Nouveau", "agentManager.dialog.tab.import": "Importer", "agentManager.dialog.namePlaceholder": "Nom du Worktree (optionnel)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts index 965a2fdd6a9..4035c4ac951 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ja.ts @@ -72,7 +72,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "次の項目", "agentManager.shortcuts.newWorktree": "新しいWorktree", "agentManager.shortcuts.openWorktree": "Worktreeを開く", - "agentManager.shortcuts.advancedWorktree": "詳細Worktree", + "agentManager.shortcuts.advancedWorktree": "新規 worktree の構成", "agentManager.shortcuts.deleteWorktree": "Worktreeを削除", "agentManager.shortcuts.previousTab": "前のタブ", "agentManager.shortcuts.nextTab": "次のタブ", @@ -94,6 +94,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "無効な Worktree を削除", "agentManager.dialog.openWorktree": "新規ワークツリー", + "agentManager.dialog.configureWorktree": "新規 Worktree の構成...", "agentManager.dialog.tab.new": "新規", "agentManager.dialog.tab.import": "インポート", "agentManager.dialog.namePlaceholder": "Worktree名(任意)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts index 6580d9bfe73..3fa6207bd92 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ko.ts @@ -72,7 +72,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "다음 항목", "agentManager.shortcuts.newWorktree": "새 Worktree", "agentManager.shortcuts.openWorktree": "Worktree 열기", - "agentManager.shortcuts.advancedWorktree": "고급 Worktree", + "agentManager.shortcuts.advancedWorktree": "새 worktree 구성", "agentManager.shortcuts.deleteWorktree": "Worktree 삭제", "agentManager.shortcuts.previousTab": "이전 탭", "agentManager.shortcuts.nextTab": "다음 탭", @@ -94,6 +94,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "오래된 Worktree 제거", "agentManager.dialog.openWorktree": "새 워크트리", + "agentManager.dialog.configureWorktree": "새 Worktree 구성...", "agentManager.dialog.tab.new": "새로 만들기", "agentManager.dialog.tab.import": "가져오기", "agentManager.dialog.namePlaceholder": "Worktree 이름 (선택사항)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts index bf52dacc3ed..6d71eb72fb9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/nl.ts @@ -77,7 +77,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "Volgend item", "agentManager.shortcuts.newWorktree": "Nieuwe worktree", "agentManager.shortcuts.openWorktree": "Worktree openen", - "agentManager.shortcuts.advancedWorktree": "Geavanceerde worktree", + "agentManager.shortcuts.advancedWorktree": "Nieuwe worktree configureren", "agentManager.shortcuts.deleteWorktree": "Worktree verwijderen", "agentManager.shortcuts.previousTab": "Vorig tabblad", "agentManager.shortcuts.nextTab": "Volgend tabblad", @@ -100,6 +100,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "Verouderde worktree verwijderen", "agentManager.dialog.openWorktree": "Nieuwe worktree", + "agentManager.dialog.configureWorktree": "Nieuwe Worktree Configureren...", "agentManager.dialog.tab.new": "Nieuw", "agentManager.dialog.tab.import": "Importeren", "agentManager.dialog.namePlaceholder": "Worktree naam (optioneel)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts index 22ce0f348b6..bd0fe919656 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/no.ts @@ -72,7 +72,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "Neste element", "agentManager.shortcuts.newWorktree": "Nytt Worktree", "agentManager.shortcuts.openWorktree": "Åpne Worktree", - "agentManager.shortcuts.advancedWorktree": "Avansert Worktree", + "agentManager.shortcuts.advancedWorktree": "Konfigurer nytt worktree", "agentManager.shortcuts.deleteWorktree": "Slett Worktree", "agentManager.shortcuts.previousTab": "Forrige fane", "agentManager.shortcuts.nextTab": "Neste fane", @@ -94,6 +94,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "Fjern utdatert Worktree", "agentManager.dialog.openWorktree": "Ny worktree", + "agentManager.dialog.configureWorktree": "Konfigurer Nytt Worktree...", "agentManager.dialog.tab.new": "Ny", "agentManager.dialog.tab.import": "Importer", "agentManager.dialog.namePlaceholder": "Worktree-navn (valgfritt)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts index 84abc9b768b..d27b43d4c3a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/pl.ts @@ -72,7 +72,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "Następny element", "agentManager.shortcuts.newWorktree": "Nowy Worktree", "agentManager.shortcuts.openWorktree": "Otwórz Worktree", - "agentManager.shortcuts.advancedWorktree": "Zaawansowany Worktree", + "agentManager.shortcuts.advancedWorktree": "Skonfiguruj nowe worktree", "agentManager.shortcuts.deleteWorktree": "Usuń Worktree", "agentManager.shortcuts.previousTab": "Poprzednia karta", "agentManager.shortcuts.nextTab": "Następna karta", @@ -94,6 +94,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "Usuń nieaktualny Worktree", "agentManager.dialog.openWorktree": "Nowy Worktree", + "agentManager.dialog.configureWorktree": "Skonfiguruj Nowe Worktree...", "agentManager.dialog.tab.new": "Nowy", "agentManager.dialog.tab.import": "Importuj", "agentManager.dialog.namePlaceholder": "Nazwa Worktree (opcjonalnie)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts index 96a6117c1dd..c963c944c68 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/ru.ts @@ -72,7 +72,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "Следующий элемент", "agentManager.shortcuts.newWorktree": "Новый Worktree", "agentManager.shortcuts.openWorktree": "Открыть Worktree", - "agentManager.shortcuts.advancedWorktree": "Расширенный Worktree", + "agentManager.shortcuts.advancedWorktree": "Настроить новое worktree", "agentManager.shortcuts.deleteWorktree": "Удалить Worktree", "agentManager.shortcuts.previousTab": "Предыдущая вкладка", "agentManager.shortcuts.nextTab": "Следующая вкладка", @@ -94,6 +94,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "Удалить устаревший Worktree", "agentManager.dialog.openWorktree": "Новый worktree", + "agentManager.dialog.configureWorktree": "Настроить новое Worktree...", "agentManager.dialog.tab.new": "Новый", "agentManager.dialog.tab.import": "Импорт", "agentManager.dialog.namePlaceholder": "Имя Worktree (необязательно)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts index 0494959ffc8..506f6ba0fa7 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/th.ts @@ -69,7 +69,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "รายการถัดไป", "agentManager.shortcuts.newWorktree": "Worktree ใหม่", "agentManager.shortcuts.openWorktree": "เปิด Worktree", - "agentManager.shortcuts.advancedWorktree": "Worktree ขั้นสูง", + "agentManager.shortcuts.advancedWorktree": "กำหนดค่า worktree ใหม่", "agentManager.shortcuts.deleteWorktree": "ลบ Worktree", "agentManager.shortcuts.previousTab": "แท็บก่อนหน้า", "agentManager.shortcuts.nextTab": "แท็บถัดไป", @@ -91,6 +91,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "ลบ Worktree ที่ล้าสมัย", "agentManager.dialog.openWorktree": "Worktree ใหม่", + "agentManager.dialog.configureWorktree": "กำหนดค่า Worktree ใหม่...", "agentManager.dialog.tab.new": "ใหม่", "agentManager.dialog.tab.import": "นำเข้า", "agentManager.dialog.namePlaceholder": "ชื่อ Worktree (ไม่บังคับ)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts index 63ced21e2c2..1dac84b5990 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/tr.ts @@ -77,7 +77,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "Sonraki öğe", "agentManager.shortcuts.newWorktree": "Yeni worktree", "agentManager.shortcuts.openWorktree": "Worktree aç", - "agentManager.shortcuts.advancedWorktree": "Gelişmiş worktree", + "agentManager.shortcuts.advancedWorktree": "Yeni worktree yapılandır", "agentManager.shortcuts.deleteWorktree": "Worktree'yi sil", "agentManager.shortcuts.previousTab": "Önceki sekme", "agentManager.shortcuts.nextTab": "Sonraki sekme", @@ -100,6 +100,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "Eskimiş worktree'yi kaldır", "agentManager.dialog.openWorktree": "Yeni Worktree", + "agentManager.dialog.configureWorktree": "Yeni Worktree Yapılandır...", "agentManager.dialog.tab.new": "Yeni", "agentManager.dialog.tab.import": "İçe Aktar", "agentManager.dialog.namePlaceholder": "Worktree adı (isteğe bağlı)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts index c9038e02a94..d2d196c6604 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/uk.ts @@ -78,7 +78,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "Наступний елемент", "agentManager.shortcuts.newWorktree": "Нове робоче дерево", "agentManager.shortcuts.openWorktree": "Відкрити робоче дерево", - "agentManager.shortcuts.advancedWorktree": "Розширене робоче дерево", + "agentManager.shortcuts.advancedWorktree": "Налаштувати нове worktree", "agentManager.shortcuts.deleteWorktree": "Видалити робоче дерево", "agentManager.shortcuts.previousTab": "Попередня вкладка", "agentManager.shortcuts.nextTab": "Наступна вкладка", @@ -101,6 +101,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "Видалити застаріле робоче дерево", "agentManager.dialog.openWorktree": "Нове робоче дерево", + "agentManager.dialog.configureWorktree": "Налаштувати нове Worktree...", "agentManager.dialog.tab.new": "Нове", "agentManager.dialog.tab.import": "Імпортувати", "agentManager.dialog.namePlaceholder": "Назва робочого дерева (необов'язково)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts index ba08ae225b1..c125c2e385a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zh.ts @@ -69,7 +69,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "下一项", "agentManager.shortcuts.newWorktree": "新建 Worktree", "agentManager.shortcuts.openWorktree": "打开 Worktree", - "agentManager.shortcuts.advancedWorktree": "高级 Worktree", + "agentManager.shortcuts.advancedWorktree": "配置新 worktree", "agentManager.shortcuts.deleteWorktree": "删除 Worktree", "agentManager.shortcuts.previousTab": "上一个标签页", "agentManager.shortcuts.nextTab": "下一个标签页", @@ -90,6 +90,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "移除失效 Worktree", "agentManager.dialog.openWorktree": "新建工作树", + "agentManager.dialog.configureWorktree": "配置新 Worktree...", "agentManager.dialog.tab.new": "新建", "agentManager.dialog.tab.import": "导入", "agentManager.dialog.namePlaceholder": "Worktree 名称(可选)", diff --git a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts index 57b281eb832..4fcdc1f355e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/i18n/zht.ts @@ -69,7 +69,7 @@ export const dict = { "agentManager.shortcuts.nextItem": "下一個項目", "agentManager.shortcuts.newWorktree": "新建 Worktree", "agentManager.shortcuts.openWorktree": "開啟 Worktree", - "agentManager.shortcuts.advancedWorktree": "進階 Worktree", + "agentManager.shortcuts.advancedWorktree": "配置新 worktree", "agentManager.shortcuts.deleteWorktree": "刪除 Worktree", "agentManager.shortcuts.previousTab": "上一個分頁", "agentManager.shortcuts.nextTab": "下一個分頁", @@ -90,6 +90,7 @@ export const dict = { "agentManager.dialog.removeStaleWorktree.confirm": "移除失效 Worktree", "agentManager.dialog.openWorktree": "新建工作樹", + "agentManager.dialog.configureWorktree": "配置新 Worktree...", "agentManager.dialog.tab.new": "新建", "agentManager.dialog.tab.import": "匯入", "agentManager.dialog.namePlaceholder": "Worktree 名稱(選填)", diff --git a/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx b/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx index 96841f7b7f6..a3d288c34a4 100644 --- a/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx +++ b/packages/kilo-vscode/webview-ui/diff-virtual/DiffVirtualApp.tsx @@ -32,9 +32,10 @@ const DiffVirtualContent: Component = () => { const [style, setStyle] = createSignal("unified") const handler = (event: MessageEvent) => { - const msg = event.data as { type: string; diff?: DiffVirtualFile } + const msg = event.data as { type: string; diff?: DiffVirtualFile; initialDiffStyle?: DiffStyle } if (msg?.type === "diffVirtual.data" && msg.diff) { setDiff(msg.diff) + setStyle(msg.initialDiffStyle ?? "unified") } } diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index f8169319f4e..c774defdac5 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -16,6 +16,7 @@ import { VSCodeProvider, useVSCode } from "./context/vscode" import { ServerProvider, useServer } from "./context/server" import { ProviderProvider, useProvider } from "./context/provider" import { ConfigProvider } from "./context/config" +import { IndexingProvider } from "./context/indexing" import { SessionProvider, useSession } from "./context/session" import { LanguageProvider } from "./context/language" import { ChatView } from "./components/chat" @@ -132,6 +133,10 @@ export const DataBridge: Component<{ children: any }> = (props) => { vscode.postMessage({ type: "openFile", filePath, line, column }) } + const openDiff = (diff: { file: string; before: string; after: string; additions: number; deletions: number }) => { + vscode.postMessage({ type: "openDiffVirtual", diff, initialDiffStyle: "split" }) + } + const openUrl = (url: string) => { vscode.postMessage({ type: "openExternal", url }) } @@ -151,6 +156,7 @@ export const DataBridge: Component<{ children: any }> = (props) => { onQuestionReply={reply} onQuestionReject={reject} onOpenFile={open} + onOpenDiff={openDiff} onOpenUrl={openUrl} > {props.children} @@ -237,6 +243,7 @@ const AppContent: Component = () => { console.log("[Kilo New] App: 🧭 navigate:", message.view, message.tab ? `tab=${message.tab}` : "") if (message.tab) setSettingsTab(message.tab) setCurrentView(message.view as ViewType) + vscode.postMessage({ type: "settingsTabChanged", tab: message.tab }) } if (message?.type === "openCloudSession" && message.sessionId) { console.log("[Kilo New] App: ☁️ openCloudSession:", message.sessionId) @@ -342,13 +349,15 @@ const App: Component = () => { - - - - - - - + + + + + + + + + diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 7da3fa1dce4..0b656f5c02f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -1,14 +1,17 @@ +/** @jsxImportSource solid-js */ + /** * ChatView component * Main chat container that combines all chat components */ -import { Component, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { type Component, Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" import { Button } from "@kilocode/kilo-ui/button" import { Icon } from "@kilocode/kilo-ui/icon" import { Spinner } from "@kilocode/kilo-ui/spinner" import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { showToast } from "@kilocode/kilo-ui/toast" +import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu" import { TaskHeader } from "./TaskHeader" import { MessageList } from "./MessageList" import { PromptInput } from "./PromptInput" @@ -50,6 +53,8 @@ export const ChatView: Component = (props) => { // "Continue in Worktree" state const [transferring, setTransferring] = createSignal(false) const [transferDetail, setTransferDetail] = createSignal("") + const [repoBranch, setRepoBranch] = createSignal() + let worktreeRef: HTMLDivElement | undefined // Permissions and questions scoped to this session's family (self + subagents). // Each ChatView only sees its own session tree — no cross-session leakage. @@ -98,13 +103,17 @@ export const ChatView: Component = (props) => { // Listen for "Continue in Worktree" progress messages { const labels: Record = { - capturing: "Capturing changes...", - creating: "Creating worktree...", - setup: "Running setup...", - transferring: "Transferring changes...", - forking: "Starting session...", + capturing: language.t("sidebar.session.progress.capturing"), + creating: language.t("sidebar.session.progress.creating"), + setup: language.t("sidebar.session.progress.setup"), + transferring: language.t("sidebar.session.progress.transferring"), + forking: language.t("sidebar.session.progress.forking"), } const cleanup = vscode.onMessage((msg) => { + if (msg.type === "agentManager.repoInfo") { + setRepoBranch(msg.branch) + return + } if (msg.type !== "continueInWorktreeProgress") return const m = msg as { status: string; error?: string } if (m.status === "done") { @@ -115,10 +124,10 @@ export const ChatView: Component = (props) => { if (m.status === "error") { setTransferring(false) setTransferDetail("") - showToast({ title: m.error ?? "Failed to continue in worktree" }) + showToast({ title: m.error ?? language.t("sidebar.session.progress.failed") }) return } - setTransferDetail(labels[m.status] ?? "Working...") + setTransferDetail(labels[m.status] ?? language.t("session.status.working")) }) onCleanup(cleanup) } @@ -129,6 +138,170 @@ export const ChatView: Component = (props) => { session.respondToPermission(perm.id, response, approvedAlways, deniedAlways) } + const startSession = () => window.dispatchEvent(new CustomEvent("newTaskRequest")) + + const startWorktree = () => vscode.postMessage({ type: "agentManager.createWorktree" }) + + const startWorktreeFromBranch = () => + vscode.postMessage({ type: "agentManager.createWorktree", baseBranch: repoBranch()! }) + + const openAgentManager = () => vscode.postMessage({ type: "openAgentManager" }) + + const openChanges = () => vscode.postMessage({ type: "openChanges" }) + + const moveToWorktree = () => { + if (transferring()) return + const sid = id() + if (!sid) return + setTransferring(true) + setTransferDetail(language.t("sidebar.session.progress.capturing")) + vscode.postMessage({ type: "continueInWorktree", sessionId: sid }) + } + + const worktreeTooltip = language.t("sidebar.session.newWorktree.tooltip") + + const advancedTooltip = language.t("sidebar.session.configureWorktree.tooltip") + + const moveTooltip = () => { + const stats = session.worktreeStats() + if (!stats?.files) return language.t("sidebar.session.moveToWorktree.tooltip.empty") + if (stats.files === 1) return language.t("sidebar.session.moveToWorktree.tooltip.one") + return language.t("sidebar.session.moveToWorktree.tooltip.other", { files: stats.files }) + } + + const changesTooltip = () => { + const stats = session.worktreeStats() + if (!stats?.files) return language.t("sidebar.session.showChanges.tooltip.empty") + return ( + + {stats.files === 1 ? "1 file changed" : `${stats.files} files changed`} + · + +{stats.additions} + -{stats.deletions} + Open the changes view. + + ) + } + + const showAdvancedWorktree = () => vscode.postMessage({ type: "openAdvancedWorktree" }) + + createEffect(() => { + if (!isSidebar() || !server.gitInstalled()) return + vscode.postMessage({ type: "agentManager.requestRepoInfo" }) + }) + + const renderActions = (hasChat: boolean) => ( +
+
+ + + + +
+ + + + worktreeRef?.getBoundingClientRect()}> + + + + + + + + + + + + + {language.t("agentManager.dialog.configureWorktree")} + + + + + +
+
+ + <> + + + + + + + + +
+
+ ) + return (
@@ -159,70 +332,8 @@ export const ChatView: Component = (props) => { /> )} - -
-
- - - - - - - - - - 1 ? "s" : ""} changed · +${session.worktreeStats()!.additions} -${session.worktreeStats()!.deletions}` - : "No file changes" - } - placement="top" - class="session-diff-wrapper" - > - - - -
-
+ + {renderActions(hasMessages())} = (props) => { vscode.postMessage({ type: "openDiffVirtual", diff: { ...props.filediff, before, after }, + initialDiffStyle: "unified", }) } diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index fa1569c4692..b38204d94b4 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -14,9 +14,11 @@ import { showToast } from "@kilocode/kilo-ui/toast" import { useDialog } from "@kilocode/kilo-ui/context/dialog" import { useSession } from "../../context/session" import { useServer } from "../../context/server" +import { useIndexing } from "../../context/indexing" import { useLanguage } from "../../context/language" import { useVSCode } from "../../context/vscode" import { useWorktreeMode } from "../../context/worktree-mode" +import { useConfig } from "../../context/config" import { ModelSelector } from "../shared/ModelSelector" import { ModeSwitcher } from "../shared/ModeSwitcher" import { ThinkingSelector } from "../shared/ThinkingSelector" @@ -63,6 +65,8 @@ interface PromptInputProps { export const PromptInput: Component = (props) => { const session = useSession() const server = useServer() + const indexing = useIndexing() + const { features } = useConfig() const language = useLanguage() const vscode = useVSCode() const worktree = useWorktreeMode() @@ -573,6 +577,10 @@ export const PromptInput: Component = (props) => { const canEnhance = () => !isBusy() && !isDisabled() && !enhancing() + const handleOpenIndexingSettings = () => { + vscode.postMessage({ type: "openSettingsTab", tab: "indexing" }) + } + const handleEnhance = () => { if (isDisabled() || enhancing() || isBusy()) return const draft = text().trim() @@ -923,6 +931,32 @@ export const PromptInput: Component = (props) => {
+ + + + + +
+ + {(id, index) => ( +
+
+ + + {disabledName(id)} + + {language.t("settings.providers.disabled")} +
+ +
+ )} +
+ ) } diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx index 00baf09c9e3..6ddfb3dd705 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/settings/Settings.tsx @@ -22,6 +22,7 @@ import CommitMessageTab from "./CommitMessageTab" import ExperimentalTab from "./ExperimentalTab" import LanguageTab from "./LanguageTab" import AboutKiloCodeTab from "./AboutKiloCodeTab" +import IndexingTab from "./IndexingTab" import { useServer } from "../../context/server" export interface SettingsProps { @@ -34,7 +35,7 @@ const Settings: Component = (props) => { const server = useServer() const language = useLanguage() const vscode = useVSCode() - const { isDirty, saving, saveError, saveConfig, discardConfig } = useConfig() + const { isDirty, saving, saveError, saveConfig, discardConfig, features } = useConfig() const session = useSession() const [active, setActive] = createSignal(props.tab ?? "models") const [errorExpanded, setErrorExpanded] = createSignal(false) @@ -59,6 +60,37 @@ const Settings: Component = (props) => { }) } + const open = (scope: "local" | "global") => { + const label = + scope === "global" ? language.t("settings.config.scope.global") : language.t("settings.config.scope.local") + vscode.postMessage({ + type: "openConfigFile", + scope, + labels: { + scope: label, + statusLoaded: language.t("settings.config.status.loaded"), + statusLoadedLegacy: language.t("settings.config.status.loadedLegacy"), + statusNotLoaded: language.t("settings.config.status.notLoaded"), + statusCreate: language.t("settings.config.status.create"), + title: language.t("settings.config.title", { scope: label }), + placeholder: language.t("settings.config.placeholder"), + noWorkspace: language.t("settings.config.noWorkspace"), + openFailed: language.t("settings.config.openFailed", { scope: label, message: "{{message}}" }), + sourceXdg: language.t("settings.config.source.xdg"), + sourceHomeKilo: language.t("settings.config.source.homeKilo"), + sourceHomeKilocode: language.t("settings.config.source.homeKilocode"), + sourceHomeOpencode: language.t("settings.config.source.homeOpencode"), + sourceEnvFile: language.t("settings.config.source.envFile"), + sourceEnvDir: language.t("settings.config.source.envDir"), + sourceEnvContent: language.t("settings.config.source.envContent"), + sourceProjectKilo: language.t("settings.config.source.projectKilo"), + sourceProjectRoot: language.t("settings.config.source.projectRoot"), + sourceProjectKilocode: language.t("settings.config.source.projectKilocode"), + sourceProjectOpencode: language.t("settings.config.source.projectOpencode"), + }, + }) + } + // Sync when the parent changes the tab prop (e.g. via navigate message) createEffect( on( @@ -69,6 +101,11 @@ const Settings: Component = (props) => { ), ) + createEffect(() => { + if (features().indexing || active() !== "indexing") return + onTabChange("providers") + }) + const onTabChange = (tab: string) => { setActive(tab) props.onTabChange?.(tab) @@ -84,10 +121,19 @@ const Settings: Component = (props) => { "border-bottom": "1px solid var(--border-weak-base)", display: "flex", "align-items": "center", + "flex-wrap": "wrap", gap: "8px", }} > -

{language.t("sidebar.settings")}

+

+ {language.t("sidebar.settings")} +

+ + {/* Settings tabs */} @@ -144,6 +190,12 @@ const Settings: Component = (props) => { {language.t("settings.commitMessage.title")} + + + + {language.t("settings.indexing.title")} + + {language.t("settings.experimental.title")} @@ -203,6 +255,12 @@ const Settings: Component = (props) => {

{language.t("settings.commitMessage.title")}

+ + +

{language.t("settings.indexing.title")}

+ +
+

{language.t("settings.experimental.title")}

diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/provider-visibility.ts b/packages/kilo-vscode/webview-ui/src/components/settings/provider-visibility.ts index 7832d52311c..abf8f145428 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/provider-visibility.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/provider-visibility.ts @@ -1,6 +1,20 @@ import type { ProviderAuthState } from "../../types/messages" -import { KILO_PROVIDER_ID } from "../../../../src/shared/provider-model" +import type { Provider } from "../../types/messages" +import { KILO_PROVIDER_ID, createKiloFallbackProvider } from "../../../../src/shared/provider-model" export function visibleConnectedIds(connected: string[], authStates: Record) { return connected.filter((id) => id !== KILO_PROVIDER_ID || authStates[KILO_PROVIDER_ID] !== undefined) } + +export function disabledProviderOptions(providers: Record, disabled: string[]) { + const current = new Set(disabled) + return Object.values(providers) + .filter((item) => !current.has(item.id)) + .map((item) => ({ value: item.id, label: item.name })) + .sort((a, b) => a.label.localeCompare(b.label)) +} + +export function providersWithKiloFallback(providers: Record): Record { + if (providers[KILO_PROVIDER_ID]) return providers + return { [KILO_PROVIDER_ID]: createKiloFallbackProvider(), ...providers } +} diff --git a/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts b/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts index 56baf3aaec6..4c1f4a87cee 100644 --- a/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts +++ b/packages/kilo-vscode/webview-ui/src/components/settings/settings-io.ts @@ -32,6 +32,7 @@ export const KNOWN_KEYS: ReadonlyArray = [ "commit_message", "tools", "layout", + "indexing", "experimental", ] diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 356e74f5d9f..071b467be04 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -11,7 +11,7 @@ import { createContext, useContext, createSignal, onCleanup } from "solid-js" import type { ParentComponent, Accessor } from "solid-js" import { useVSCode } from "./vscode" -import type { Config, ExtensionMessage } from "../types/messages" +import type { Config, ExtensionMessage, FeatureFlags } from "../types/messages" import { deepMerge, stripNulls, resolveConfig } from "../utils/config-utils" export interface SaveError { @@ -21,6 +21,7 @@ export interface SaveError { interface ConfigContextValue { config: Accessor + features: Accessor loading: Accessor isDirty: Accessor saving: Accessor @@ -36,6 +37,7 @@ export const ConfigProvider: ParentComponent = (props) => { const vscode = useVSCode() const [config, setConfig] = createSignal({}) + const [features, setFeatures] = createSignal({ indexing: false }) const [loading, setLoading] = createSignal(true) const [draft, setDraft] = createSignal>({}) const [isDirty, setIsDirty] = createSignal(false) @@ -58,6 +60,7 @@ export const ConfigProvider: ParentComponent = (props) => { // Re-apply the draft on top so pending changes (e.g. a toggled switch the // user hasn't saved yet) stay visible instead of snapping back. setConfig(resolveConfig(message.config, draft(), isDirty())) + setFeatures(message.features) setSaved(message.config) setLoading(false) return @@ -71,10 +74,12 @@ export const ConfigProvider: ParentComponent = (props) => { setIsDirty(false) setSaveError(null) setConfig(message.config) + setFeatures(message.features) } else { // configUpdated from a different source (e.g. PermissionDock save). // Re-apply the draft on top so pending settings changes are preserved. setConfig(resolveConfig(message.config, draft(), isDirty())) + setFeatures(message.features) } setSaved(message.config) return @@ -144,6 +149,7 @@ export const ConfigProvider: ParentComponent = (props) => { const value: ConfigContextValue = { config, + features, loading, isDirty, saving, diff --git a/packages/kilo-vscode/webview-ui/src/context/indexing-utils.ts b/packages/kilo-vscode/webview-ui/src/context/indexing-utils.ts new file mode 100644 index 00000000000..59def4fc6fb --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/indexing-utils.ts @@ -0,0 +1,26 @@ +import type { IndexingStatus } from "../types/messages" + +export function formatIndexingLabel(status: IndexingStatus): string { + if (status.state === "In Progress") { + if (status.totalFiles <= 0) return "IDX In Progress" + return `IDX ${status.percent}% ${status.processedFiles}/${status.totalFiles}` + } + + if (status.state === "Error") { + return `IDX ${status.message}` + } + + if (status.state === "Standby") { + return "IDX Standby" + } + + return `IDX ${status.state}` +} + +export function indexingTone(status: IndexingStatus): "muted" | "warning" | "success" | "error" { + if (status.state === "Complete") return "success" + if (status.state === "Error") return "error" + if (status.state === "In Progress") return "warning" + if (status.state === "Standby") return "muted" + return "muted" +} diff --git a/packages/kilo-vscode/webview-ui/src/context/indexing.tsx b/packages/kilo-vscode/webview-ui/src/context/indexing.tsx new file mode 100644 index 00000000000..227a51d3cdf --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/indexing.tsx @@ -0,0 +1,84 @@ +import { createContext, useContext, createMemo, createSignal, onCleanup, createEffect } from "solid-js" +import type { ParentComponent, Accessor } from "solid-js" +import { useVSCode } from "./vscode" +import type { ExtensionMessage, IndexingStatus } from "../types/messages" +import { formatIndexingLabel, indexingTone } from "./indexing-utils" +import { useConfig } from "./config" + +interface IndexingContextValue { + status: Accessor + loading: Accessor + label: Accessor + tone: Accessor<"muted" | "warning" | "success" | "error"> +} + +const initial: IndexingStatus = { + state: "Disabled", + message: "Indexing disabled.", + processedFiles: 0, + totalFiles: 0, + percent: 0, +} + +export { formatIndexingLabel, indexingTone } from "./indexing-utils" + +const IndexingContext = createContext() + +export const IndexingProvider: ParentComponent = (props) => { + const vscode = useVSCode() + const { features } = useConfig() + const [status, setStatus] = createSignal(initial) + const [loading, setLoading] = createSignal(true) + + const unsubscribe = vscode.onMessage((message: ExtensionMessage) => { + if (!features().indexing) return + if (message.type !== "indexingStatusLoaded") return + setStatus(message.status) + setLoading(false) + }) + + onCleanup(unsubscribe) + + createEffect(() => { + if (!features().indexing) { + setStatus(initial) + setLoading(false) + return + } + + setLoading(true) + let retries = 0 + const maxRetries = 5 + const retryMs = 500 + + vscode.postMessage({ type: "requestIndexingStatus" }) + + const retryTimer = setInterval(() => { + retries++ + if (!loading() || retries >= maxRetries) { + clearInterval(retryTimer) + return + } + vscode.postMessage({ type: "requestIndexingStatus" }) + }, retryMs) + + onCleanup(() => clearInterval(retryTimer)) + }) + + const value: IndexingContextValue = { + status, + loading, + label: createMemo(() => formatIndexingLabel(status())), + tone: createMemo(() => indexingTone(status())), + } + + return {props.children} +} + +export function useIndexing(): IndexingContextValue { + const context = useContext(IndexingContext) + if (!context) { + throw new Error("useIndexing must be used within an IndexingProvider") + } + return context +} diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 960f4dcd253..07ad332822f 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -533,7 +533,36 @@ export const dict = { "session.new.worktree.mainWithBranch": "الفرع الرئيسي ({{branch}})", "session.new.worktree.create": "إنشاء شجرة عمل جديدة", "session.new.lastModified": "آخر تعديل", - + "sidebar.session.newSession": "جلسة جديدة", + "sidebar.session.newSession.tooltip": "ابدأ محادثة جديدة مع الاحتفاظ بالجلسة الحالية كما هي.", + "sidebar.session.newSession.disabled": "هذه الجلسة جديدة بالفعل. ابدأ الدردشة أو أنشئ worktree بدلاً من ذلك.", + "sidebar.session.newWorktree": "Worktree جديد", + "sidebar.session.newWorktree.tooltip": + "أنشئ git worktree معزول للتجربة بأمان، وفصل التغييرات، وتشغيل جلسات متوازية دون تعطيل فرعك الحالي.", + "sidebar.session.configureWorktree.tooltip": + "افتح مربع حوار worktree في Agent Manager لتكوين worktree جديد قبل إنشائه.", + "sidebar.session.newWorktree.from": "Worktree جديد من", + "sidebar.session.currentBranch": "الفرع الحالي", + "sidebar.session.moveToWorktree": "نقل إلى Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "انقل هذه المحادثة وتغييراتك المحلية الحالية إلى worktree مخصص لمتابعة العمل في بيئة معزولة.", + "sidebar.session.moveToWorktree.tooltip.one": + "انقل هذه المحادثة وملفًا واحدًا معدلًا إلى worktree مخصص لمتابعة العمل في بيئة معزولة.", + "sidebar.session.moveToWorktree.tooltip.other": + "انقل هذه المحادثة و{{files}} ملفات معدلة إلى worktree مخصص لمتابعة العمل في بيئة معزولة.", + "sidebar.session.showChanges.tooltip.empty": "افتح عرض التغييرات لفحص شجرة العمل الحالية.", + "sidebar.session.showChanges.tooltip.one": "تم تعديل ملف واحد · +{{additions}} -{{deletions}}. افتح عرض التغييرات.", + "sidebar.session.showChanges.tooltip.other": + "تم تعديل {{files}} ملفات · +{{additions}} -{{deletions}}. افتح عرض التغييرات.", + "sidebar.session.agentManager.tooltip": + "افتح Agent Manager للحصول على نظرة عامة كاملة على الجلسات المتوازية و worktrees، لتتمكن من تنسيق المهام الطويلة في مكان واحد.", + "sidebar.session.openAgentManager": "فتح Agent Manager", + "sidebar.session.progress.capturing": "جاري التقاط التغييرات...", + "sidebar.session.progress.creating": "جاري إنشاء worktree...", + "sidebar.session.progress.setup": "جاري تشغيل الإعداد...", + "sidebar.session.progress.transferring": "جاري نقل التغييرات...", + "sidebar.session.progress.forking": "جاري بدء الجلسة...", + "sidebar.session.progress.failed": "فشلت المتابعة في worktree", "session.header.search.placeholder": "بحث {{project}}", "session.header.searchFiles": "بحث عن الملفات", "session.header.openIn": "فتح في", @@ -734,10 +763,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "البيئة", "settings.providers.tag.config": "التكوين", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "مخصص", "settings.providers.tag.other": "أخرى", "settings.providers.tag.customProvider": "مزود مخصص", "settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك", + "settings.providers.action.signInChatGPT": "تسجيل الدخول باستخدام ChatGPT", "settings.providers.custom.description": "أضف مزوداً متوافقاً مع OpenAI عبر عنوان URL الأساسي.", "settings.providers.modeModels": "نموذج لكل وضع", "settings.providers.custom.note": "أضف موفرًا متوافقًا مع OpenAI عبر عنوان URL الأساسي.", @@ -820,6 +851,29 @@ export const dict = { "provider.custom.error.baseURL.format": "يجب أن يبدأ بـ http:// أو https://", "provider.custom.error.required": "مطلوب", "provider.custom.error.duplicate": "مكرر", + "settings.openLocalConfig": "تكوين محلي", + "settings.openGlobalConfig": "تكوين عام", + "settings.config.scope.local": "محلي", + "settings.config.scope.global": "عالمي", + "settings.config.status.loaded": "محمل", + "settings.config.status.loadedLegacy": "محمل تكوين قديم", + "settings.config.status.notLoaded": "غير محمل", + "settings.config.status.create": "غير موجود - قم بإنشاء هذا الملف", + "settings.config.title": "فتح ملف تكوين Kilo {{scope}}", + "settings.config.placeholder": "يتم دمج ملفات التكوين بالترتيب؛ الملفات المحددة كمحملة تؤثر حاليًا على الإعدادات.", + "settings.config.noWorkspace": "افتح مجلد مساحة عمل لتحرير ملف تكوين Kilo المحلي.", + "settings.config.openFailed": "فشل فتح ملف تكوين Kilo {{scope}}: {{message}}", + "settings.config.source.xdg": "تكوين XDG العالمي", + "settings.config.source.homeKilo": "تكوين .kilo في Home", + "settings.config.source.homeKilocode": "تكوين .kilocode في Home", + "settings.config.source.homeOpencode": "تكوين .opencode في Home", + "settings.config.source.envFile": "ملف بيئة KILO_CONFIG", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "تكوين البيئة المضمن", + "settings.config.source.projectKilo": "تكوين .kilo للمشروع", + "settings.config.source.projectRoot": "تكوين جذر المشروع", + "settings.config.source.projectKilocode": "تكوين .kilocode القديم", + "settings.config.source.projectOpencode": "تكوين .opencode القديم", "settings.models.title": "النماذج", "settings.models.description": "ستكون إعدادات النموذج قابلة للتكوين هنا.", "settings.agents.title": "الوكلاء", @@ -961,9 +1015,34 @@ export const dict = { "settings.checkpoints.title": "نقاط التحقق", "settings.display.title": "العرض", "settings.autocomplete.title": "الإكمال التلقائي", + "settings.autocomplete.model.title": "نموذج الإكمال التلقائي", + "settings.autocomplete.model.description": "حدد النموذج المستخدم لإكمال الكود المضمن (inline completions)", "settings.notifications.title": "الإشعارات", "settings.context.title": "السياق", - + "settings.indexing.title": "الفهرسة", + "settings.indexing.enable.title": "تمكين الفهرسة", + "settings.indexing.enable.description": "تشغيل أو إيقاف فهرسة قاعدة الكود الدلالية لمساحة العمل هذه.", + "settings.indexing.provider.title": "موفر التضمين", + "settings.indexing.provider.description": "اختر الموفر المستخدم لإنشاء التضمينات للبحث الدلالي.", + "settings.indexing.model.title": "نموذج التضمين", + "settings.indexing.model.description": "تجاوز نموذج التضمين الافتراضي للموفر المحدد.", + "settings.indexing.dimension.title": "بُعد المتجه", + "settings.indexing.dimension.description": "اتركه فارغًا للكشف التلقائي عن بُعد التضمين من النموذج.", + "settings.indexing.dimension.placeholder": "تلقائي", + "settings.indexing.vectorStore.title": "مخزن المتجهات", + "settings.indexing.vectorStore.description": "اختر مكان تخزين التضمينات المفهرسة.", + "settings.indexing.lancedbDirectory.title": "دليل LanceDB", + "settings.indexing.lancedbDirectory.description": "دليل اختياري لتخزين LanceDB المحلي.", + "settings.indexing.lancedbDirectory.placeholder": "اتركه فارغًا للافتراضي", + "settings.indexing.qdrantUrl.title": "عنوان URL لـ Qdrant", + "settings.indexing.qdrantUrl.description": "عنوان URL للخادم لمثيل Qdrant.", + "settings.indexing.qdrantApiKey.title": "مفتاح API لـ Qdrant", + "settings.indexing.qdrantApiKey.description": "مفتاح API اختياري لمثيل Qdrant.", + "settings.indexing.qdrantApiKey.placeholder": "مفتاح API اختياري", + "settings.indexing.status.title": "الحالة", + "settings.indexing.tuning.description": "معامل متقدم للبحث والدفعات.", + "settings.indexing.providerField.description": "إعداد اتصال خاص بالموفر.", + "prompt.action.indexing": "إعدادات الفهرسة", "settings.experimental.title": "تجريبي", "settings.language.title": "اللغة", "settings.aboutKiloCode.title": "حول Kilo Code", @@ -1096,6 +1175,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "عدم تلخيص المحتوى الملصق الكبير", "settings.experimental.batch.title": "أداة دفعية", "settings.experimental.batch.description": "تمكين المعالجة الدفعية لاستدعاءات الأدوات", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "بحث في قاعدة الكود", "settings.experimental.codebaseSearch.description": "تمكين البحث بالذكاء الاصطناعي باللغة الطبيعية عبر قاعدة الكود", "settings.experimental.continueOnDeny.title": "المتابعة عند الرفض", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index a32c6a96444..764c59b586c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -264,6 +264,7 @@ export const dict = { "prompt.action.resetModel": "Redefinir modelo para o padrão", "prompt.action.enhanceDescription": "O botão 'Aprimorar prompt' ajuda a melhorar seu pedido fornecendo contexto adicional, esclarecimentos ou reformulações. Tente digitar um pedido aqui e clique no botão novamente para ver como funciona.", + "prompt.action.indexing": "Configurações de indexação", "prompt.toast.pasteUnsupported.title": "Colagem não suportada", "prompt.toast.pasteUnsupported.description": "Somente imagens ou PDFs podem ser colados aqui.", @@ -535,7 +536,38 @@ export const dict = { "session.new.worktree.mainWithBranch": "Branch principal ({{branch}})", "session.new.worktree.create": "Criar novo worktree", "session.new.lastModified": "Última modificação", - + "sidebar.session.newSession": "Nova Sessão", + "sidebar.session.newSession.tooltip": "Inicie uma nova conversa mantendo a sessão atual intacta.", + "sidebar.session.newSession.disabled": "Esta sessão já é nova. Comece a conversar ou crie um worktree em vez disso.", + "sidebar.session.newWorktree": "Novo Worktree", + "sidebar.session.newWorktree.tooltip": + "Crie um git worktree isolado para experimentar com segurança, manter as alterações separadas e executar sessões paralelas sem interromper sua branch atual.", + "sidebar.session.configureWorktree.tooltip": + "Abra a caixa de diálogo de worktree do Agent Manager para configurar um novo worktree antes de criá-lo.", + "sidebar.session.newWorktree.from": "Novo Worktree de", + "sidebar.session.currentBranch": "branch atual", + "sidebar.session.moveToWorktree": "Mover para Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "Mova esta conversa e suas alterações locais atuais para um worktree dedicado para trabalho de acompanhamento isolado.", + "sidebar.session.moveToWorktree.tooltip.one": + "Mover esta conversa e 1 arquivo alterado para um worktree dedicado para trabalho de acompanhamento isolado.", + "sidebar.session.moveToWorktree.tooltip.other": + "Mover esta conversa e {{files}} arquivos alterados para um worktree dedicado para trabalho de acompanhamento isolado.", + "sidebar.session.showChanges.tooltip.empty": + "Abra a visualização de alterações para inspecionar a árvore de trabalho atual.", + "sidebar.session.showChanges.tooltip.one": + "1 arquivo alterado · +{{additions}} -{{deletions}}. Abrir a visualização de alterações.", + "sidebar.session.showChanges.tooltip.other": + "{{files}} arquivos alterados · +{{additions}} -{{deletions}}. Abrir a visualização de alterações.", + "sidebar.session.agentManager.tooltip": + "Abra o Agent Manager para uma visão geral completa de sessões paralelas e worktrees, para que você possa coordenar tarefas longas em um só lugar.", + "sidebar.session.openAgentManager": "Abrir Agent Manager", + "sidebar.session.progress.capturing": "Capturando alterações...", + "sidebar.session.progress.creating": "Criando worktree...", + "sidebar.session.progress.setup": "Executando configuração...", + "sidebar.session.progress.transferring": "Transferindo alterações...", + "sidebar.session.progress.forking": "Iniciando sessão...", + "sidebar.session.progress.failed": "Falha ao continuar no worktree", "session.header.search.placeholder": "Buscar {{project}}", "session.header.searchFiles": "Buscar arquivos", "session.header.openIn": "Abrir em", @@ -741,10 +773,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "Ambiente", "settings.providers.tag.config": "Configuração", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "Personalizado", "settings.providers.tag.other": "Outro", "settings.providers.tag.customProvider": "Provedor personalizado", "settings.providers.connected.environmentDescription": "Conectado a partir das suas variáveis de ambiente", + "settings.providers.action.signInChatGPT": "Entrar com ChatGPT", "settings.providers.custom.description": "Adicione um provedor compatível com OpenAI pela URL base.", "settings.providers.modeModels": "Modelo por Modo", "settings.providers.custom.note": "Adicione um provedor compatível com OpenAI por URL base.", @@ -828,6 +862,31 @@ export const dict = { "provider.custom.error.baseURL.format": "Deve começar com http:// ou https://", "provider.custom.error.required": "Obrigatório", "provider.custom.error.duplicate": "Duplicado", + "settings.openLocalConfig": "Config Local", + "settings.openGlobalConfig": "Config Global", + "settings.config.scope.local": "Local", + "settings.config.scope.global": "Global", + "settings.config.status.loaded": "carregado", + "settings.config.status.loadedLegacy": "configuração legada carregada", + "settings.config.status.notLoaded": "não carregado", + "settings.config.status.create": "não encontrado - criar este arquivo", + "settings.config.title": "Abrir arquivo de configuração {{scope}} do Kilo", + "settings.config.placeholder": + "Os arquivos de configuração são mesclados em ordem; os arquivos marcados como carregados afetam atualmente as configurações.", + "settings.config.noWorkspace": + "Abra uma pasta de espaço de trabalho para editar o arquivo de configuração local do Kilo.", + "settings.config.openFailed": "Falha ao abrir o arquivo de configuração {{scope}} do Kilo: {{message}}", + "settings.config.source.xdg": "Configuração global do XDG", + "settings.config.source.homeKilo": "Configuração .kilo da Home", + "settings.config.source.homeKilocode": "Configuração .kilocode da Home", + "settings.config.source.homeOpencode": "Configuração .opencode da Home", + "settings.config.source.envFile": "Arquivo de ambiente KILO_CONFIG", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "Configuração de ambiente embutida", + "settings.config.source.projectKilo": "Configuração .kilo do projeto", + "settings.config.source.projectRoot": "Configuração raiz do projeto", + "settings.config.source.projectKilocode": "Configuração .kilocode legada", + "settings.config.source.projectOpencode": "Configuração .opencode legada", "settings.models.title": "Modelos", "settings.models.description": "Configurações de modelos estarão disponíveis aqui.", "settings.agents.title": "Agentes", @@ -970,11 +1029,38 @@ export const dict = { "settings.checkpoints.title": "Pontos de Verificação", "settings.display.title": "Exibição", "settings.autocomplete.title": "Autocompletar", + "settings.autocomplete.model.title": "Modelo de autocompletar", + "settings.autocomplete.model.description": "Selecione o modelo usado para preenchimento de código inline", "settings.notifications.title": "Notificações", "settings.context.title": "Contexto", "settings.experimental.title": "Experimental", "settings.language.title": "Idioma", + "settings.indexing.title": "Indexação", + "settings.indexing.enable.title": "Ativar indexação", + "settings.indexing.enable.description": + "Ativar ou desativar a indexação semântica da base de código para este espaço de trabalho.", + "settings.indexing.provider.title": "Provedor de embedding", + "settings.indexing.provider.description": "Escolha o provedor usado para gerar embeddings para busca semântica.", + "settings.indexing.model.title": "Modelo de embedding", + "settings.indexing.model.description": "Substituir o modelo de embedding padrão para o provedor selecionado.", + "settings.indexing.vectorStore.title": "Armazenamento de vetores", + "settings.indexing.vectorStore.description": "Escolha onde os embeddings indexados são armazenados.", + "settings.indexing.lancedbDirectory.title": "Diretório do LanceDB", + "settings.indexing.lancedbDirectory.description": "Diretório opcional para o armazenamento local do LanceDB.", + "settings.indexing.lancedbDirectory.placeholder": "Deixe vazio para o padrão", + "settings.indexing.qdrantUrl.title": "URL do Qdrant", + "settings.indexing.qdrantUrl.description": "URL do servidor para a instância do Qdrant.", + "settings.indexing.qdrantApiKey.title": "Chave de API do Qdrant", + "settings.indexing.qdrantApiKey.description": "Chave de API opcional para a instância do Qdrant.", + "settings.indexing.qdrantApiKey.placeholder": "Chave de API opcional", + "settings.indexing.dimension.title": "Dimensão do vetor", + "settings.indexing.dimension.description": + "Deixe vazio para detectar automaticamente a dimensão de embedding do modelo.", + "settings.indexing.dimension.placeholder": "Auto", + "settings.indexing.status.title": "Status", + "settings.indexing.tuning.description": "Parâmetro avançado de busca e lote.", + "settings.indexing.providerField.description": "Configuração de conexão específica do provedor.", "settings.aboutKiloCode.title": "Sobre o Kilo Code", "session.messages.empty": "Inicie uma conversa digitando uma mensagem abaixo.", @@ -1113,6 +1199,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "Não resumir conteúdo colado grande", "settings.experimental.batch.title": "Ferramenta em lote", "settings.experimental.batch.description": "Ativar processamento em lote de chamadas de ferramentas", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Pesquisa de código", "settings.experimental.codebaseSearch.description": "Ativar pesquisa por linguagem natural com IA em toda a base de código", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index d6e930dc3c1..4ccde0e07c1 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -541,7 +541,38 @@ export const dict = { "session.new.worktree.mainWithBranch": "Glavna grana ({{branch}})", "session.new.worktree.create": "Kreiraj novi worktree", "session.new.lastModified": "Posljednja izmjena", - + "sidebar.session.newSession": "Nova Sesija", + "sidebar.session.newSession.tooltip": "Započnite novi razgovor držeći trenutnu sesiju netaknutom.", + "sidebar.session.newSession.disabled": + "Ova sesija je već nova. Započnite razgovor ili umjesto toga kreirajte worktree.", + "sidebar.session.newWorktree": "Novi Worktree", + "sidebar.session.newWorktree.tooltip": + "Kreirajte izolovani git worktree za sigurno eksperimentisanje, odvojeno čuvanje izmjena i pokretanje paralelnih sesija bez prekidanja vaše trenutne grane.", + "sidebar.session.configureWorktree.tooltip": + "Otvorite Agent Manager worktree dijalog za konfigurisanje novog worktree-a prije kreiranja.", + "sidebar.session.newWorktree.from": "Novi Worktree od", + "sidebar.session.currentBranch": "trenutna grana", + "sidebar.session.moveToWorktree": "Premjesti u Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "Premjestite ovaj razgovor i vaše trenutne lokalne izmjene u namjenski worktree za izolovani radni nastavak.", + "sidebar.session.moveToWorktree.tooltip.one": + "Premjesti ovaj razgovor i 1 izmijenjen fajl u namjenski worktree za izolovani naknadni rad.", + "sidebar.session.moveToWorktree.tooltip.other": + "Premjesti ovaj razgovor i {{files}} izmijenjenih fajlova u namjenski worktree za izolovani naknadni rad.", + "sidebar.session.showChanges.tooltip.empty": "Otvorite prikaz izmjena da pregledate trenutno radno stablo.", + "sidebar.session.showChanges.tooltip.one": + "1 fajl izmijenjen · +{{additions}} -{{deletions}}. Otvori pregled izmjena.", + "sidebar.session.showChanges.tooltip.other": + "{{files}} fajlova izmijenjeno · +{{additions}} -{{deletions}}. Otvori pregled izmjena.", + "sidebar.session.agentManager.tooltip": + "Otvorite Agent Manager za potpuni pregled paralelnih sesija i worktree-a, kako biste mogli koordinirati dugotrajne zadatke na jednom mjestu.", + "sidebar.session.openAgentManager": "Otvori Agent Manager", + "sidebar.session.progress.capturing": "Snimanje izmjena...", + "sidebar.session.progress.creating": "Kreiranje worktree-a...", + "sidebar.session.progress.setup": "Pokretanje podešavanja...", + "sidebar.session.progress.transferring": "Prijenos izmjena...", + "sidebar.session.progress.forking": "Pokretanje sesije...", + "sidebar.session.progress.failed": "Nije uspjelo nastavljanje u worktree-u", "session.header.search.placeholder": "Pretraži {{project}}", "session.header.searchFiles": "Pretraži datoteke", "session.header.openIn": "Otvori u", @@ -715,6 +746,34 @@ export const dict = { "settings.general.sounds.errors.title": "Greške", "settings.general.sounds.errors.description": "Pusti zvuk kada dođe do greške", + "prompt.action.indexing": "Postavke indeksiranja", + "settings.indexing.dimension.description": + "Ostavite prazno za automatsko prepoznavanje dimenzije embeddinga iz modela.", + "settings.indexing.dimension.placeholder": "Auto", + "settings.indexing.dimension.title": "Dimenzija vektora", + "settings.indexing.enable.description": + "Uključite ili isključite semantičko indeksiranje baze koda za ovaj radni prostor.", + "settings.indexing.enable.title": "Omogući indeksiranje", + "settings.indexing.lancedbDirectory.description": "Opcionalni direktorij za lokalno LanceDB skladište.", + "settings.indexing.lancedbDirectory.placeholder": "Ostavite prazno za zadano", + "settings.indexing.lancedbDirectory.title": "LanceDB direktorij", + "settings.indexing.model.description": "Prepišite zadani model embeddinga za odabranog provajdera.", + "settings.indexing.model.title": "Model embeddinga", + "settings.indexing.provider.description": + "Odaberite provajdera koji se koristi za generiranje embeddinga za semantičku pretragu.", + "settings.indexing.provider.title": "Provajder embeddinga", + "settings.indexing.providerField.description": "Postavka veze specifična za provajdera.", + "settings.indexing.qdrantApiKey.description": "Opcionalni API ključ za Qdrant instancu.", + "settings.indexing.qdrantApiKey.placeholder": "Opcionalni API ključ", + "settings.indexing.qdrantApiKey.title": "Qdrant API ključ", + "settings.indexing.qdrantUrl.description": "URL servera za Qdrant instancu.", + "settings.indexing.qdrantUrl.title": "Qdrant URL", + "settings.indexing.status.title": "Status", + "settings.indexing.title": "Indeksiranje", + "settings.indexing.tuning.description": "Napredni parametar pretrage i grupiranja.", + "settings.indexing.vectorStore.description": "Odaberite gdje se pohranjuju indeksirani embeddingi.", + "settings.indexing.vectorStore.title": "Vektorsko skladište", + "settings.shortcuts.title": "Prečice na tastaturi", "settings.shortcuts.reset.button": "Vrati na podrazumijevano", "settings.shortcuts.reset.toast.title": "Prečice resetovane", @@ -745,10 +804,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "Okruženje", "settings.providers.tag.config": "Konfiguracija", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "Prilagođeno", "settings.providers.tag.other": "Ostalo", "settings.providers.tag.customProvider": "Prilagođeni provajder", "settings.providers.connected.environmentDescription": "Povezano iz vaših varijabli okruženja", + "settings.providers.action.signInChatGPT": "Prijavi se putem ChatGPT", "settings.providers.custom.description": "Dodaj OpenAI-kompatibilan provajder putem osnovnog URL-a.", "settings.providers.modeModels": "Model po režimu", "settings.providers.custom.note": "Dodajte provajdera kompatibilnog s OpenAI putem osnovnog URL-a.", @@ -834,6 +895,30 @@ export const dict = { "provider.custom.error.baseURL.format": "Mora početi sa http:// ili https://", "provider.custom.error.required": "Obavezno", "provider.custom.error.duplicate": "Duplikat", + "settings.openLocalConfig": "Lokalna konfig.", + "settings.openGlobalConfig": "Globalna konfig.", + "settings.config.scope.local": "Lokalno", + "settings.config.scope.global": "Globalno", + "settings.config.status.loaded": "učitano", + "settings.config.status.loadedLegacy": "učitana zastarjela konfiguracija", + "settings.config.status.notLoaded": "nije učitano", + "settings.config.status.create": "nije pronađeno - kreiraj ovu datoteku", + "settings.config.title": "Otvori {{scope}} Kilo konfiguracijsku datoteku", + "settings.config.placeholder": + "Konfiguracijske datoteke se spajaju po redu; datoteke označene kao učitane trenutno utiču na postavke.", + "settings.config.noWorkspace": "Otvorite fasciklu radnog prostora da uredite lokalnu Kilo konfiguracijsku datoteku.", + "settings.config.openFailed": "Nije uspjelo otvaranje {{scope}} Kilo konfiguracijske datoteke: {{message}}", + "settings.config.source.xdg": "XDG globalna konfiguracija", + "settings.config.source.homeKilo": "Home .kilo konfiguracija", + "settings.config.source.homeKilocode": "Home .kilocode konfiguracija", + "settings.config.source.homeOpencode": "Home .opencode konfiguracija", + "settings.config.source.envFile": "KILO_CONFIG datoteka okruženja", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "Ugrađena konfiguracija okruženja", + "settings.config.source.projectKilo": "Projektna .kilo konfiguracija", + "settings.config.source.projectRoot": "Konfiguracija korijena projekta", + "settings.config.source.projectKilocode": "Zastarjela .kilocode konfiguracija", + "settings.config.source.projectOpencode": "Zastarjela .opencode konfiguracija", "settings.models.title": "Modeli", "settings.models.description": "Postavke modela će se ovdje moći podešavati.", "settings.agents.title": "Agenti", @@ -975,6 +1060,8 @@ export const dict = { "settings.checkpoints.title": "Kontrolne tačke", "settings.display.title": "Prikaz", "settings.autocomplete.title": "Automatsko dovršavanje", + "settings.autocomplete.model.title": "Model za automatsko dovršavanje", + "settings.autocomplete.model.description": "Odaberite model koji se koristi za inline dovršavanje koda", "settings.notifications.title": "Obavještenja", "settings.context.title": "Kontekst", @@ -1113,6 +1200,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "Ne sažimaj veliki zalijepljeni sadržaj", "settings.experimental.batch.title": "Batch alat", "settings.experimental.batch.description": "Omogući batch obradu poziva alata", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Pretraga koda", "settings.experimental.codebaseSearch.description": "Omogući AI pretragu prirodnim jezikom kroz bazu koda", "settings.experimental.continueOnDeny.title": "Nastavi pri odbijanju", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index e250d36dd8f..01b68c1a69e 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -536,7 +536,37 @@ export const dict = { "session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})", "session.new.worktree.create": "Opret nyt worktree", "session.new.lastModified": "Sidst ændret", - + "sidebar.session.newSession": "Ny Session", + "sidebar.session.newSession.tooltip": "Start en frisk samtale, mens den nuværende session holdes intakt.", + "sidebar.session.newSession.disabled": + "Denne session er allerede ny. Start med at chatte eller opret et worktree i stedet.", + "sidebar.session.newWorktree": "Nyt Worktree", + "sidebar.session.newWorktree.tooltip": + "Opret et isoleret git worktree for at eksperimentere sikkert, holde ændringer adskilt og køre parallelle sessioner uden at forstyrre din nuværende branch.", + "sidebar.session.configureWorktree.tooltip": + "Åbn Agent Manager worktree-dialogen for at konfigurere et nyt worktree før oprettelse.", + "sidebar.session.newWorktree.from": "Nyt Worktree fra", + "sidebar.session.currentBranch": "nuværende branch", + "sidebar.session.moveToWorktree": "Flyt til Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "Flyt denne samtale og dine nuværende lokale ændringer til et dedikeret worktree for isoleret opfølgningsarbejde.", + "sidebar.session.moveToWorktree.tooltip.one": + "Flyt denne samtale og 1 ændret fil til et dedikeret worktree for isoleret opfølgende arbejde.", + "sidebar.session.moveToWorktree.tooltip.other": + "Flyt denne samtale og {{files}} ændrede filer til et dedikeret worktree for isoleret opfølgende arbejde.", + "sidebar.session.showChanges.tooltip.empty": "Åbn ændringsvisningen for at inspicere det nuværende worktree.", + "sidebar.session.showChanges.tooltip.one": "1 fil ændret · +{{additions}} -{{deletions}}. Åbn ændringsvisningen.", + "sidebar.session.showChanges.tooltip.other": + "{{files}} filer ændret · +{{additions}} -{{deletions}}. Åbn ændringsvisningen.", + "sidebar.session.agentManager.tooltip": + "Åbn Agent Manager for et komplet overblik over parallelle sessioner og worktrees, så du kan koordinere langvarige opgaver ét sted.", + "sidebar.session.openAgentManager": "Åbn Agent Manager", + "sidebar.session.progress.capturing": "Fanger ændringer...", + "sidebar.session.progress.creating": "Opretter worktree...", + "sidebar.session.progress.setup": "Kører opsætning...", + "sidebar.session.progress.transferring": "Overfører ændringer...", + "sidebar.session.progress.forking": "Starter session...", + "sidebar.session.progress.failed": "Kunne ikke fortsætte i worktree", "session.header.search.placeholder": "Søg {{project}}", "session.header.searchFiles": "Søg efter filer", "session.header.openIn": "Åbn i", @@ -720,13 +750,40 @@ export const dict = { "settings.shortcuts.pressKeys": "Tryk på taster", "settings.shortcuts.search.placeholder": "Søg genveje", "settings.shortcuts.search.empty": "Ingen genveje fundet", - "settings.shortcuts.group.general": "Generelt", - "settings.shortcuts.group.session": "Session", - "settings.shortcuts.group.navigation": "Navigation", "settings.shortcuts.group.modelAndAgent": "Model og agent", - "settings.shortcuts.group.terminal": "Terminal", + "settings.shortcuts.group.navigation": "Navigation", "settings.shortcuts.group.prompt": "Prompt", + "settings.shortcuts.group.session": "Session", + "settings.shortcuts.group.terminal": "Terminal", + + "prompt.action.indexing": "Indekseringsindstillinger", + + "settings.indexing.title": "Indeksering", + "settings.indexing.enable.title": "Aktivér indeksering", + "settings.indexing.enable.description": "Slå semantisk kodebase-indeksering til eller fra for dette arbejdsområde.", + "settings.indexing.provider.title": "Embedding-udbyder", + "settings.indexing.provider.description": + "Vælg udbyderen, der bruges til at generere embeddings til semantisk søgning.", + "settings.indexing.model.title": "Embedding-model", + "settings.indexing.model.description": "Overskriv standard embedding-modellen for den valgte udbyder.", + "settings.indexing.providerField.description": "Udbyderspecifik forbindelsesindstilling.", + "settings.indexing.vectorStore.title": "Vektorlager", + "settings.indexing.vectorStore.description": "Vælg hvor indekserede embeddings gemmes.", + "settings.indexing.qdrantUrl.title": "Qdrant URL", + "settings.indexing.qdrantUrl.description": "Server-URL til Qdrant-instansen.", + "settings.indexing.qdrantApiKey.title": "Qdrant API-nøgle", + "settings.indexing.qdrantApiKey.description": "Valgfri API-nøgle til Qdrant-instansen.", + "settings.indexing.qdrantApiKey.placeholder": "Valgfri API-nøgle", + "settings.indexing.lancedbDirectory.title": "LanceDB-mappe", + "settings.indexing.lancedbDirectory.description": "Valgfri mappe til det lokale LanceDB-lager.", + "settings.indexing.lancedbDirectory.placeholder": "Lad være tom for standard", + "settings.indexing.dimension.title": "Vektordimension", + "settings.indexing.dimension.description": + "Lad være tom for automatisk at registrere embedding-dimensionen fra modellen.", + "settings.indexing.dimension.placeholder": "Auto", + "settings.indexing.status.title": "Status", + "settings.indexing.tuning.description": "Avanceret søge- og batchparameter.", "settings.providers.title": "Udbydere", "settings.providers.description": "Udbyderindstillinger vil kunne konfigureres her.", @@ -740,10 +797,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "Miljø", "settings.providers.tag.config": "Konfiguration", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "Brugerdefineret", "settings.providers.tag.other": "Andet", "settings.providers.tag.customProvider": "Brugerdefineret udbyder", "settings.providers.connected.environmentDescription": "Forbundet fra dine miljøvariabler", + "settings.providers.action.signInChatGPT": "Log ind med ChatGPT", "settings.providers.custom.description": "Tilføj en OpenAI-kompatibel udbyder via basis-URL.", "settings.providers.modeModels": "Model pr. tilstand", "settings.providers.custom.note": "Tilføj en OpenAI-kompatibel udbyder via basis-URL.", @@ -827,6 +886,30 @@ export const dict = { "provider.custom.error.baseURL.format": "Skal starte med http:// eller https://", "provider.custom.error.required": "Påkrævet", "provider.custom.error.duplicate": "Duplikat", + "settings.openLocalConfig": "Lokal konfig", + "settings.openGlobalConfig": "Global konfig", + "settings.config.scope.local": "Lokal", + "settings.config.scope.global": "Global", + "settings.config.status.loaded": "indlæst", + "settings.config.status.loadedLegacy": "indlæst forældet konfiguration", + "settings.config.status.notLoaded": "ikke indlæst", + "settings.config.status.create": "ikke fundet - opret denne fil", + "settings.config.title": "Åbn {{scope}} Kilo konfigurationsfil", + "settings.config.placeholder": + "Konfigurationsfiler flettes i rækkefølge; filer markeret som indlæst påvirker i øjeblikket indstillingerne.", + "settings.config.noWorkspace": "Åbn en arbejdsområdemappe for at redigere den lokale Kilo konfigurationsfil.", + "settings.config.openFailed": "Kunne ikke åbne {{scope}} Kilo konfigurationsfil: {{message}}", + "settings.config.source.xdg": "XDG global konfiguration", + "settings.config.source.homeKilo": "Home .kilo konfiguration", + "settings.config.source.homeKilocode": "Home .kilocode konfiguration", + "settings.config.source.homeOpencode": "Home .opencode konfiguration", + "settings.config.source.envFile": "KILO_CONFIG miljøfil", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "Indbygget miljøkonfiguration", + "settings.config.source.projectKilo": "Projekt .kilo konfiguration", + "settings.config.source.projectRoot": "Projekt rodkonfiguration", + "settings.config.source.projectKilocode": "Forældet .kilocode konfiguration", + "settings.config.source.projectOpencode": "Forældet .opencode konfiguration", "settings.models.title": "Modeller", "settings.models.description": "Modelindstillinger vil kunne konfigureres her.", "settings.agents.title": "Agenter", @@ -968,6 +1051,8 @@ export const dict = { "settings.checkpoints.title": "Kontrolpunkter", "settings.display.title": "Visning", "settings.autocomplete.title": "Autofuldførelse", + "settings.autocomplete.model.title": "Autocomplete-model", + "settings.autocomplete.model.description": "Vælg den model, der bruges til inline kodefuldførelse", "settings.notifications.title": "Notifikationer", "settings.context.title": "Kontekst", @@ -1107,6 +1192,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "Resumér ikke stort indsat indhold", "settings.experimental.batch.title": "Batchværktøj", "settings.experimental.batch.description": "Aktiver batchbehandling af flere værktøjskald", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Kodesøgning", "settings.experimental.codebaseSearch.description": "Aktiver AI-drevet naturlig sprogsøgning på tværs af kodebasen", "settings.experimental.continueOnDeny.title": "Fortsæt ved afvisning", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 6a9e9e59a75..9a80686a5e3 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -543,7 +543,39 @@ export const dict = { "session.new.worktree.mainWithBranch": "Haupt-Branch ({{branch}})", "session.new.worktree.create": "Neuen Worktree erstellen", "session.new.lastModified": "Zuletzt geändert", - + "sidebar.session.newSession": "Neue Sitzung", + "sidebar.session.newSession.tooltip": "Starte eine neue Unterhaltung, während die aktuelle Sitzung intakt bleibt.", + "sidebar.session.newSession.disabled": + "Diese Sitzung ist bereits neu. Beginne zu chatten oder erstelle stattdessen einen Worktree.", + "sidebar.session.newWorktree": "Neuer Worktree", + "sidebar.session.newWorktree.tooltip": + "Erstelle einen isolierten Git-Worktree, um sicher zu experimentieren, Änderungen getrennt zu halten und parallele Sitzungen auszuführen, ohne deinen aktuellen Branch zu stören.", + "sidebar.session.configureWorktree.tooltip": + "Öffne den Agent Manager Worktree-Dialog, um einen neuen Worktree vor der Erstellung zu konfigurieren.", + "sidebar.session.newWorktree.from": "Neuer Worktree von", + "sidebar.session.currentBranch": "aktueller Branch", + "sidebar.session.moveToWorktree": "In Worktree verschieben", + "sidebar.session.moveToWorktree.tooltip.empty": + "Verschiebe diese Unterhaltung und deine aktuellen lokalen Änderungen in einen dedizierten Worktree für isolierte Folgearbeiten.", + "sidebar.session.moveToWorktree.tooltip.one": + "Diese Unterhaltung und 1 geänderte Datei in einen dedizierten Worktree für isolierte Folgearbeiten verschieben.", + "sidebar.session.moveToWorktree.tooltip.other": + "Diese Unterhaltung und {{files}} geänderte Dateien in einen dedizierten Worktree für isolierte Folgearbeiten verschieben.", + "sidebar.session.showChanges.tooltip.empty": + "Öffne die Änderungsansicht, um den aktuellen Working Tree zu überprüfen.", + "sidebar.session.showChanges.tooltip.one": + "1 Datei geändert · +{{additions}} -{{deletions}}. Änderungsansicht öffnen.", + "sidebar.session.showChanges.tooltip.other": + "{{files}} Dateien geändert · +{{additions}} -{{deletions}}. Änderungsansicht öffnen.", + "sidebar.session.agentManager.tooltip": + "Öffne den Agent Manager für einen vollständigen Überblick über parallele Sitzungen und Worktrees, damit du langlaufende Aufgaben an einem Ort koordinieren kannst.", + "sidebar.session.openAgentManager": "Agent Manager öffnen", + "sidebar.session.progress.capturing": "Änderungen werden erfasst...", + "sidebar.session.progress.creating": "Worktree wird erstellt...", + "sidebar.session.progress.setup": "Setup wird ausgeführt...", + "sidebar.session.progress.transferring": "Änderungen werden übertragen...", + "sidebar.session.progress.forking": "Sitzung wird gestartet...", + "sidebar.session.progress.failed": "Fortsetzen im Worktree fehlgeschlagen", "session.header.search.placeholder": "{{project}} durchsuchen", "session.header.searchFiles": "Dateien suchen", "session.header.openIn": "Öffnen in", @@ -720,6 +752,34 @@ export const dict = { "settings.general.sounds.errors.title": "Fehler", "settings.general.sounds.errors.description": "Ton abspielen, wenn ein Fehler auftritt", + "prompt.action.indexing": "Indizierungseinstellungen", + "settings.indexing.dimension.description": + "Leer lassen, um die Embedding-Dimension automatisch aus dem Modell zu erkennen.", + "settings.indexing.dimension.placeholder": "Auto", + "settings.indexing.dimension.title": "Vektordimension", + "settings.indexing.enable.description": + "Semantische Codebasis-Indizierung für diesen Arbeitsbereich ein- oder ausschalten.", + "settings.indexing.enable.title": "Indizierung aktivieren", + "settings.indexing.lancedbDirectory.description": "Optionaler Ordner für den lokalen LanceDB-Speicher.", + "settings.indexing.lancedbDirectory.placeholder": "Leer lassen für Standard", + "settings.indexing.lancedbDirectory.title": "LanceDB-Ordner", + "settings.indexing.model.description": "Das Standard-Embedding-Modell für den ausgewählten Anbieter überschreiben.", + "settings.indexing.model.title": "Embedding-Modell", + "settings.indexing.provider.description": + "Wählen Sie den Anbieter, der für die Generierung von Embeddings für die semantische Suche verwendet wird.", + "settings.indexing.provider.title": "Embedding-Anbieter", + "settings.indexing.providerField.description": "Anbieterspezifische Verbindungseinstellung.", + "settings.indexing.qdrantApiKey.description": "Optionaler API-Schlüssel für die Qdrant-Instanz.", + "settings.indexing.qdrantApiKey.placeholder": "Optionaler API-Schlüssel", + "settings.indexing.qdrantApiKey.title": "Qdrant API-Schlüssel", + "settings.indexing.qdrantUrl.description": "Server-URL für die Qdrant-Instanz.", + "settings.indexing.qdrantUrl.title": "Qdrant URL", + "settings.indexing.status.title": "Status", + "settings.indexing.title": "Indizierung", + "settings.indexing.tuning.description": "Erweiterter Such- und Batch-Parameter.", + "settings.indexing.vectorStore.description": "Wählen Sie, wo indizierte Embeddings gespeichert werden.", + "settings.indexing.vectorStore.title": "Vektorspeicher", + "settings.shortcuts.title": "Tastenkombinationen", "settings.shortcuts.reset.button": "Auf Standard zurücksetzen", "settings.shortcuts.reset.toast.title": "Tastenkombinationen zurückgesetzt", @@ -750,10 +810,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "Umgebung", "settings.providers.tag.config": "Konfiguration", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "Benutzerdefiniert", "settings.providers.tag.other": "Andere", "settings.providers.tag.customProvider": "Benutzerdefinierter Anbieter", "settings.providers.connected.environmentDescription": "Verbunden über Ihre Umgebungsvariablen", + "settings.providers.action.signInChatGPT": "Mit ChatGPT anmelden", "settings.providers.custom.description": "Fügen Sie einen OpenAI-kompatiblen Anbieter über die Basis-URL hinzu.", "settings.providers.modeModels": "Modell pro Modus", "settings.providers.custom.note": "Fügen Sie einen OpenAI-kompatiblen Anbieter per Basis-URL hinzu.", @@ -839,6 +901,31 @@ export const dict = { "provider.custom.error.baseURL.format": "Muss mit http:// oder https:// beginnen", "provider.custom.error.required": "Erforderlich", "provider.custom.error.duplicate": "Duplikat", + "settings.openLocalConfig": "Lokale Config", + "settings.openGlobalConfig": "Globale Config", + "settings.config.scope.local": "Lokal", + "settings.config.scope.global": "Global", + "settings.config.status.loaded": "geladen", + "settings.config.status.loadedLegacy": "veraltete Konfiguration geladen", + "settings.config.status.notLoaded": "nicht geladen", + "settings.config.status.create": "nicht gefunden - diese Datei erstellen", + "settings.config.title": "{{scope}} Kilo-Konfigurationsdatei öffnen", + "settings.config.placeholder": + "Konfigurationsdateien werden der Reihe nach zusammengeführt; als geladen markierte Dateien wirken sich aktuell auf die Einstellungen aus.", + "settings.config.noWorkspace": + "Öffnen Sie einen Arbeitsbereichsordner, um die lokale Kilo-Konfigurationsdatei zu bearbeiten.", + "settings.config.openFailed": "Fehler beim Öffnen der {{scope}} Kilo-Konfigurationsdatei: {{message}}", + "settings.config.source.xdg": "Globale XDG-Konfiguration", + "settings.config.source.homeKilo": "Home .kilo-Konfiguration", + "settings.config.source.homeKilocode": "Home .kilocode-Konfiguration", + "settings.config.source.homeOpencode": "Home .opencode-Konfiguration", + "settings.config.source.envFile": "KILO_CONFIG Umgebungsdatei", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "Inline-Umgebungskonfiguration", + "settings.config.source.projectKilo": "Projekt .kilo-Konfiguration", + "settings.config.source.projectRoot": "Projektstamm-Konfiguration", + "settings.config.source.projectKilocode": "Veraltete .kilocode-Konfiguration", + "settings.config.source.projectOpencode": "Veraltete .opencode-Konfiguration", "settings.models.title": "Modelle", "settings.models.description": "Modelleinstellungen können hier konfiguriert werden.", "settings.agents.title": "Agenten", @@ -980,6 +1067,8 @@ export const dict = { "settings.checkpoints.title": "Prüfpunkte", "settings.display.title": "Anzeige", "settings.autocomplete.title": "Autovervollständigung", + "settings.autocomplete.model.title": "Autocomplete-Modell", + "settings.autocomplete.model.description": "Wählen Sie das Modell für Inline-Code-Vervollständigungen", "settings.notifications.title": "Benachrichtigungen", "settings.context.title": "Kontext", @@ -1125,6 +1214,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "Große eingefügte Inhalte nicht zusammenfassen", "settings.experimental.batch.title": "Batch-Werkzeug", "settings.experimental.batch.description": "Bündelung mehrerer Werkzeugaufrufe aktivieren", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Codebase-Suche", "settings.experimental.codebaseSearch.description": "KI-gestützte Suche in natürlicher Sprache über die gesamte Codebasis aktivieren", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index 0413b4ee0c3..0d49677bc09 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -106,7 +106,7 @@ export const dict = { "dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more", "dialog.provider.anthropic.note": "Direct access to Claude models, including Pro and Max", "dialog.provider.copilot.note": "Claude models for coding assistance", - "dialog.provider.openai.note": "GPT models for fast, capable general AI tasks", + "dialog.provider.openai.note": "GPT and Codex models with API key or ChatGPT login", "dialog.provider.google.note": "Gemini models for fast, structured responses", "dialog.provider.openrouter.note": "Access all supported models from one provider", "dialog.provider.vercel.note": "Unified access to AI models with smart routing", @@ -262,6 +262,7 @@ export const dict = { "prompt.action.send.blocked": "Answer or dismiss the pending question first", "prompt.action.stop": "Stop", "prompt.action.enhance": "Enhance prompt", + "prompt.action.indexing": "Indexing settings", "prompt.action.resetModel": "Reset model to default", "prompt.action.enhanceDescription": "The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.", @@ -744,10 +745,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "Environment", "settings.providers.tag.config": "Config", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "Custom", "settings.providers.tag.customProvider": "Custom provider", "settings.providers.tag.other": "Other", "settings.providers.connected.environmentDescription": "Connected from your environment variables", + "settings.providers.action.signInChatGPT": "Sign in with ChatGPT", "settings.providers.custom.description": "Add an OpenAI-compatible provider by base URL.", "provider.custom.title": "Custom provider", @@ -828,6 +831,29 @@ export const dict = { "provider.custom.error.baseURL.format": "Must start with http:// or https://", "provider.custom.error.required": "Required", "provider.custom.error.duplicate": "Duplicate", + "settings.openLocalConfig": "Local Config", + "settings.openGlobalConfig": "Global Config", + "settings.config.scope.local": "Local", + "settings.config.scope.global": "Global", + "settings.config.status.loaded": "loaded", + "settings.config.status.loadedLegacy": "loaded legacy config", + "settings.config.status.notLoaded": "not loaded", + "settings.config.status.create": "not found - create this file", + "settings.config.title": "Open {{scope}} Kilo config file", + "settings.config.placeholder": "Config files are merged in order; files marked loaded currently affect settings.", + "settings.config.noWorkspace": "Open a workspace folder to edit the local Kilo config file.", + "settings.config.openFailed": "Failed to open {{scope}} Kilo config file: {{message}}", + "settings.config.source.xdg": "XDG global config", + "settings.config.source.homeKilo": "Home .kilo config", + "settings.config.source.homeKilocode": "Home .kilocode config", + "settings.config.source.homeOpencode": "Home .opencode config", + "settings.config.source.envFile": "KILO_CONFIG environment file", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "Inline environment config", + "settings.config.source.projectKilo": "Project .kilo config", + "settings.config.source.projectRoot": "Project root config", + "settings.config.source.projectKilocode": "Legacy .kilocode config", + "settings.config.source.projectOpencode": "Legacy .opencode config", "settings.models.title": "Models", "settings.models.description": "Model settings will be configurable here.", "settings.agents.title": "Agents", @@ -970,7 +996,29 @@ export const dict = { "settings.autocomplete.title": "Autocomplete", "settings.notifications.title": "Notifications", "settings.context.title": "Context", - + "settings.indexing.title": "Indexing", + "settings.indexing.status.title": "Status", + "settings.indexing.enable.title": "Enable indexing", + "settings.indexing.enable.description": "Turn semantic codebase indexing on or off for this workspace.", + "settings.indexing.provider.title": "Embedding provider", + "settings.indexing.provider.description": "Choose the provider used to generate embeddings for semantic search.", + "settings.indexing.model.title": "Embedding model", + "settings.indexing.model.description": "Override the default embedding model for the selected provider.", + "settings.indexing.dimension.title": "Vector dimension", + "settings.indexing.dimension.description": "Leave empty to auto-detect the embedding dimension from the model.", + "settings.indexing.dimension.placeholder": "Auto", + "settings.indexing.providerField.description": "Provider-specific connection setting.", + "settings.indexing.vectorStore.title": "Vector store", + "settings.indexing.vectorStore.description": "Choose where indexed embeddings are stored.", + "settings.indexing.lancedbDirectory.title": "LanceDB directory", + "settings.indexing.lancedbDirectory.description": "Optional directory for the local LanceDB store.", + "settings.indexing.lancedbDirectory.placeholder": "Leave empty for default", + "settings.indexing.qdrantUrl.title": "Qdrant URL", + "settings.indexing.qdrantUrl.description": "Server URL for the Qdrant instance.", + "settings.indexing.qdrantApiKey.title": "Qdrant API key", + "settings.indexing.qdrantApiKey.description": "Optional API key for the Qdrant instance.", + "settings.indexing.qdrantApiKey.placeholder": "Optional API key", + "settings.indexing.tuning.description": "Advanced search and batching parameter.", "settings.experimental.title": "Experimental", "settings.language.title": "Language", "settings.aboutKiloCode.title": "About Kilo Code", @@ -986,6 +1034,36 @@ export const dict = { "session.status.retrying": "Retrying (attempt {{ attempt }})… {{ message }}", "session.status.working": "Working...", "session.status.offline": "Network disconnected — reconnecting...", + "sidebar.session.newSession": "New Session", + "sidebar.session.newSession.tooltip": "Start a fresh conversation while keeping the current session intact.", + "sidebar.session.newSession.disabled": "This session is already new. Start chatting or create a worktree instead.", + "sidebar.session.newWorktree": "New Worktree", + "sidebar.session.newWorktree.tooltip": + "Create an isolated git worktree to experiment safely, keep changes separated, and run parallel sessions without disrupting your current branch.", + "sidebar.session.configureWorktree.tooltip": + "Open the Agent Manager worktree dialog to configure a new worktree before creating it.", + "sidebar.session.newWorktree.from": "New Worktree from", + "sidebar.session.currentBranch": "current branch", + "sidebar.session.moveToWorktree": "Move to Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "Move this conversation and your current local changes into a dedicated worktree for isolated follow-up work.", + "sidebar.session.moveToWorktree.tooltip.one": + "Move this conversation and 1 changed file into a dedicated worktree for isolated follow-up work.", + "sidebar.session.moveToWorktree.tooltip.other": + "Move this conversation and {{files}} changed files into a dedicated worktree for isolated follow-up work.", + "sidebar.session.showChanges.tooltip.empty": "Open the changes view to inspect the current working tree.", + "sidebar.session.showChanges.tooltip.one": "1 file changed · +{{additions}} -{{deletions}}. Open the changes view.", + "sidebar.session.showChanges.tooltip.other": + "{{files}} files changed · +{{additions}} -{{deletions}}. Open the changes view.", + "sidebar.session.agentManager.tooltip": + "Open Agent Manager for a full overview of parallel sessions and worktrees, so you can coordinate long-running tasks in one place.", + "sidebar.session.openAgentManager": "Open Agent Manager", + "sidebar.session.progress.capturing": "Capturing changes...", + "sidebar.session.progress.creating": "Creating worktree...", + "sidebar.session.progress.setup": "Running setup...", + "sidebar.session.progress.transferring": "Transferring changes...", + "sidebar.session.progress.forking": "Starting session...", + "sidebar.session.progress.failed": "Failed to continue in worktree", "ui.sessionTurn.cancel": "Cancel", "ui.sessionTurn.status.thinking": "Thinking...", @@ -1072,6 +1150,8 @@ export const dict = { "common.add": "Add", "common.choose": "Choose…", + "settings.autocomplete.model.title": "Autocomplete model", + "settings.autocomplete.model.description": "Select the model used for inline code completions", "settings.autocomplete.autoTrigger.title": "Enable automatic inline completions", "settings.autocomplete.autoTrigger.description": "Automatically show inline completion suggestions as you type", "settings.autocomplete.smartKeybinding.title": "Enable smart inline task keybinding", @@ -1108,6 +1188,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "Don't summarize large pasted content", "settings.experimental.batch.title": "Batch Tool", "settings.experimental.batch.description": "Enable batching of multiple tool calls", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Codebase Search", "settings.experimental.codebaseSearch.description": "Enable AI-powered natural language search across your codebase", "settings.experimental.continueOnDeny.title": "Continue on Deny", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 782eb7436ec..340d291f815 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -264,6 +264,7 @@ export const dict = { "prompt.action.resetModel": "Restablecer modelo al predeterminado", "prompt.action.enhanceDescription": "El botón 'Mejorar el mensaje' ayuda a mejorar tu petición proporcionando contexto adicional, aclaraciones o reformulaciones. Intenta escribir una petición aquí y haz clic en el botón nuevamente para ver cómo funciona.", + "prompt.action.indexing": "Configuración de indexación", "prompt.toast.pasteUnsupported.title": "Pegado no soportado", "prompt.toast.pasteUnsupported.description": "Solo se pueden pegar imágenes o PDFs aquí.", @@ -538,7 +539,37 @@ export const dict = { "session.new.worktree.mainWithBranch": "Rama principal ({{branch}})", "session.new.worktree.create": "Crear nuevo árbol de trabajo", "session.new.lastModified": "Última modificación", - + "sidebar.session.newSession": "Nueva Sesión", + "sidebar.session.newSession.tooltip": "Inicia una nueva conversación manteniendo intacta la sesión actual.", + "sidebar.session.newSession.disabled": "Esta sesión ya es nueva. Empieza a chatear o crea un worktree en su lugar.", + "sidebar.session.newWorktree": "Nuevo Worktree", + "sidebar.session.newWorktree.tooltip": + "Crea un git worktree aislado para experimentar de forma segura, mantener los cambios separados y ejecutar sesiones paralelas sin interrumpir tu rama actual.", + "sidebar.session.configureWorktree.tooltip": + "Abre el diálogo de worktree en el Agent Manager para configurar un nuevo worktree antes de crearlo.", + "sidebar.session.newWorktree.from": "Nuevo Worktree desde", + "sidebar.session.currentBranch": "rama actual", + "sidebar.session.moveToWorktree": "Mover a Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "Mueve esta conversación y tus cambios locales actuales a un worktree dedicado para un trabajo de seguimiento aislado.", + "sidebar.session.moveToWorktree.tooltip.one": + "Mover esta conversación y 1 archivo modificado a un worktree dedicado para trabajo de seguimiento aislado.", + "sidebar.session.moveToWorktree.tooltip.other": + "Mover esta conversación y {{files}} archivos modificados a un worktree dedicado para trabajo de seguimiento aislado.", + "sidebar.session.showChanges.tooltip.empty": "Abre la vista de cambios para inspeccionar el árbol de trabajo actual.", + "sidebar.session.showChanges.tooltip.one": + "1 archivo modificado · +{{additions}} -{{deletions}}. Abrir la vista de cambios.", + "sidebar.session.showChanges.tooltip.other": + "{{files}} archivos modificados · +{{additions}} -{{deletions}}. Abrir la vista de cambios.", + "sidebar.session.agentManager.tooltip": + "Abre el Agent Manager para obtener una vista general completa de las sesiones paralelas y los worktrees, para que puedas coordinar tareas de larga duración en un solo lugar.", + "sidebar.session.openAgentManager": "Abrir Agent Manager", + "sidebar.session.progress.capturing": "Capturando cambios...", + "sidebar.session.progress.creating": "Creando worktree...", + "sidebar.session.progress.setup": "Ejecutando configuración...", + "sidebar.session.progress.transferring": "Transfiriendo cambios...", + "sidebar.session.progress.forking": "Iniciando sesión...", + "sidebar.session.progress.failed": "No se pudo continuar en el worktree", "session.header.search.placeholder": "Buscar {{project}}", "session.header.searchFiles": "Buscar archivos", "session.header.openIn": "Abrir en", @@ -715,6 +746,34 @@ export const dict = { "settings.general.sounds.errors.title": "Errores", "settings.general.sounds.errors.description": "Reproducir sonido cuando ocurra un error", + "settings.indexing.title": "Indexación", + "settings.indexing.enable.title": "Habilitar indexación", + "settings.indexing.enable.description": + "Activar o desactivar la indexación semántica de la base de código para este espacio de trabajo.", + "settings.indexing.provider.title": "Proveedor de embeddings", + "settings.indexing.provider.description": + "Elige el proveedor utilizado para generar embeddings para búsqueda semántica.", + "settings.indexing.model.title": "Modelo de embeddings", + "settings.indexing.model.description": + "Anular el modelo de embeddings predeterminado para el proveedor seleccionado.", + "settings.indexing.dimension.title": "Dimensión vectorial", + "settings.indexing.dimension.description": + "Dejar vacío para detectar automáticamente la dimensión de embeddings del modelo.", + "settings.indexing.dimension.placeholder": "Auto", + "settings.indexing.vectorStore.title": "Almacén de vectores", + "settings.indexing.vectorStore.description": "Elige dónde se almacenan los embeddings indexados.", + "settings.indexing.lancedbDirectory.title": "Directorio de LanceDB", + "settings.indexing.lancedbDirectory.description": "Directorio opcional para el almacenamiento local de LanceDB.", + "settings.indexing.lancedbDirectory.placeholder": "Dejar vacío para el valor predeterminado", + "settings.indexing.qdrantUrl.title": "URL de Qdrant", + "settings.indexing.qdrantUrl.description": "URL del servidor para la instancia de Qdrant.", + "settings.indexing.qdrantApiKey.title": "Clave API de Qdrant", + "settings.indexing.qdrantApiKey.description": "Clave API opcional para la instancia de Qdrant.", + "settings.indexing.qdrantApiKey.placeholder": "Clave API opcional", + "settings.indexing.providerField.description": "Configuración de conexión específica del proveedor.", + "settings.indexing.status.title": "Estado", + "settings.indexing.tuning.description": "Parámetro avanzado de búsqueda y procesamiento por lotes.", + "settings.shortcuts.title": "Atajos de teclado", "settings.shortcuts.reset.button": "Restablecer a valores predeterminados", "settings.shortcuts.reset.toast.title": "Atajos restablecidos", @@ -746,10 +805,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "Entorno", "settings.providers.tag.config": "Configuración", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "Personalizado", "settings.providers.tag.other": "Otro", "settings.providers.tag.customProvider": "Proveedor personalizado", "settings.providers.connected.environmentDescription": "Conectado desde tus variables de entorno", + "settings.providers.action.signInChatGPT": "Iniciar sesión con ChatGPT", "settings.providers.custom.description": "Añade un proveedor compatible con OpenAI por URL base.", "settings.providers.modeModels": "Modelo por modo", "settings.providers.custom.note": "Agrega un proveedor compatible con OpenAI mediante URL base.", @@ -834,6 +895,31 @@ export const dict = { "provider.custom.error.baseURL.format": "Debe empezar con http:// o https://", "provider.custom.error.required": "Obligatorio", "provider.custom.error.duplicate": "Duplicado", + "settings.openLocalConfig": "Config local", + "settings.openGlobalConfig": "Config global", + "settings.config.scope.local": "Local", + "settings.config.scope.global": "Global", + "settings.config.status.loaded": "cargado", + "settings.config.status.loadedLegacy": "configuración heredada cargada", + "settings.config.status.notLoaded": "no cargado", + "settings.config.status.create": "no encontrado - crear este archivo", + "settings.config.title": "Abrir archivo de configuración Kilo {{scope}}", + "settings.config.placeholder": + "Los archivos de configuración se combinan en orden; los archivos marcados como cargados afectan actualmente a los ajustes.", + "settings.config.noWorkspace": + "Abre una carpeta de espacio de trabajo para editar el archivo de configuración Kilo local.", + "settings.config.openFailed": "Error al abrir el archivo de configuración Kilo {{scope}}: {{message}}", + "settings.config.source.xdg": "Configuración global XDG", + "settings.config.source.homeKilo": "Configuración .kilo de Home", + "settings.config.source.homeKilocode": "Configuración .kilocode de Home", + "settings.config.source.homeOpencode": "Configuración .opencode de Home", + "settings.config.source.envFile": "Archivo de entorno KILO_CONFIG", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "Configuración de entorno en línea", + "settings.config.source.projectKilo": "Configuración .kilo del proyecto", + "settings.config.source.projectRoot": "Configuración raíz del proyecto", + "settings.config.source.projectKilocode": "Configuración heredada .kilocode", + "settings.config.source.projectOpencode": "Configuración heredada .opencode", "settings.models.title": "Modelos", "settings.models.description": "La configuración de modelos estará disponible aquí.", "settings.agents.title": "Agentes", @@ -976,6 +1062,8 @@ export const dict = { "settings.checkpoints.title": "Puntos de control", "settings.display.title": "Pantalla", "settings.autocomplete.title": "Autocompletado", + "settings.autocomplete.model.title": "Modelo de autocompletado", + "settings.autocomplete.model.description": "Selecciona el modelo utilizado para el autocompletado de código en línea", "settings.notifications.title": "Notificaciones", "settings.context.title": "Contexto", @@ -1117,6 +1205,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "No resumir contenido pegado grande", "settings.experimental.batch.title": "Herramienta por lotes", "settings.experimental.batch.description": "Habilitar procesamiento por lotes de llamadas a herramientas", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Búsqueda de código", "settings.experimental.codebaseSearch.description": "Habilitar búsqueda por lenguaje natural con IA en toda la base de código", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index 9d7fe4562ef..eab365ebae5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -265,6 +265,7 @@ export const dict = { "prompt.action.resetModel": "Réinitialiser le modèle par défaut", "prompt.action.enhanceDescription": "Le bouton 'Améliorer la requête' aide à améliorer votre demande en fournissant un contexte supplémentaire, des clarifications ou des reformulations. Essayez de taper une demande ici et cliquez à nouveau sur le bouton pour voir comment cela fonctionne.", + "prompt.action.indexing": "Paramètres d'indexation", "prompt.toast.pasteUnsupported.title": "Collage non supporté", "prompt.toast.pasteUnsupported.description": "Seules les images ou les PDF peuvent être collés ici.", @@ -543,7 +544,40 @@ export const dict = { "session.new.worktree.mainWithBranch": "Branche principale ({{branch}})", "session.new.worktree.create": "Créer un nouvel arbre de travail", "session.new.lastModified": "Dernière modification", - + "sidebar.session.newSession": "Nouvelle Session", + "sidebar.session.newSession.tooltip": + "Démarrer une nouvelle conversation tout en gardant la session actuelle intacte.", + "sidebar.session.newSession.disabled": + "Cette session est déjà nouvelle. Commencez à discuter ou créez plutôt un worktree.", + "sidebar.session.newWorktree": "Nouveau Worktree", + "sidebar.session.newWorktree.tooltip": + "Créer un git worktree isolé pour expérimenter en toute sécurité, séparer les modifications et exécuter des sessions parallèles sans perturber votre branche actuelle.", + "sidebar.session.configureWorktree.tooltip": + "Ouvrez la boîte de dialogue worktree du Agent Manager pour configurer un nouveau worktree avant de le créer.", + "sidebar.session.newWorktree.from": "Nouveau Worktree depuis", + "sidebar.session.currentBranch": "branche actuelle", + "sidebar.session.moveToWorktree": "Déplacer vers un Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "Déplacez cette conversation et vos modifications locales actuelles dans un worktree dédié pour un travail de suivi isolé.", + "sidebar.session.moveToWorktree.tooltip.one": + "Déplacer cette conversation et 1 fichier modifié vers un worktree dédié pour un travail de suivi isolé.", + "sidebar.session.moveToWorktree.tooltip.other": + "Déplacer cette conversation et {{files}} fichiers modifiés vers un worktree dédié pour un travail de suivi isolé.", + "sidebar.session.showChanges.tooltip.empty": + "Ouvrez la vue des modifications pour inspecter l'arborescence de travail actuelle.", + "sidebar.session.showChanges.tooltip.one": + "1 fichier modifié · +{{additions}} -{{deletions}}. Ouvrir la vue des modifications.", + "sidebar.session.showChanges.tooltip.other": + "{{files}} fichiers modifiés · +{{additions}} -{{deletions}}. Ouvrir la vue des modifications.", + "sidebar.session.agentManager.tooltip": + "Ouvrez Agent Manager pour une vue d'ensemble complète des sessions parallèles et des worktrees, afin de coordonner les tâches longues en un seul endroit.", + "sidebar.session.openAgentManager": "Ouvrir Agent Manager", + "sidebar.session.progress.capturing": "Capture des modifications...", + "sidebar.session.progress.creating": "Création du worktree...", + "sidebar.session.progress.setup": "Exécution de la configuration...", + "sidebar.session.progress.transferring": "Transfert des modifications...", + "sidebar.session.progress.forking": "Démarrage de la session...", + "sidebar.session.progress.failed": "Échec de la poursuite dans le worktree", "session.header.search.placeholder": "Rechercher {{project}}", "session.header.searchFiles": "Rechercher des fichiers", "session.header.openIn": "Ouvrir dans", @@ -638,6 +672,33 @@ export const dict = { "settings.general.row.releaseNotes.title": "Notes de version", "settings.general.row.releaseNotes.description": 'Afficher des pop-ups "Quoi de neuf" après les mises à jour', + "settings.indexing.dimension.description": + "Laissez vide pour détecter automatiquement la dimension d'embedding à partir du modèle.", + "settings.indexing.dimension.placeholder": "Auto", + "settings.indexing.dimension.title": "Dimension vectorielle", + "settings.indexing.enable.description": + "Activer ou désactiver l'indexation sémantique de la base de code pour cet espace de travail.", + "settings.indexing.enable.title": "Activer l'indexation", + "settings.indexing.lancedbDirectory.description": "Répertoire optionnel pour le stockage local LanceDB.", + "settings.indexing.lancedbDirectory.placeholder": "Laissez vide pour la valeur par défaut", + "settings.indexing.lancedbDirectory.title": "Répertoire LanceDB", + "settings.indexing.model.description": "Remplacer le modèle d'embedding par défaut pour le fournisseur sélectionné.", + "settings.indexing.model.title": "Modèle d'embedding", + "settings.indexing.provider.description": + "Choisissez le fournisseur utilisé pour générer les embeddings pour la recherche sémantique.", + "settings.indexing.provider.title": "Fournisseur d'embedding", + "settings.indexing.providerField.description": "Paramètre de connexion spécifique au fournisseur.", + "settings.indexing.qdrantApiKey.description": "Clé API optionnelle pour l'instance Qdrant.", + "settings.indexing.qdrantApiKey.placeholder": "Clé API optionnelle", + "settings.indexing.qdrantApiKey.title": "Clé API Qdrant", + "settings.indexing.qdrantUrl.description": "URL du serveur pour l'instance Qdrant.", + "settings.indexing.qdrantUrl.title": "URL Qdrant", + "settings.indexing.status.title": "Statut", + "settings.indexing.title": "Indexation", + "settings.indexing.tuning.description": "Paramètre avancé de recherche et de traitement par lots.", + "settings.indexing.vectorStore.description": "Choisissez où les embeddings indexés sont stockés.", + "settings.indexing.vectorStore.title": "Stockage vectoriel", + "settings.updates.row.startup.title": "Vérifier les mises à jour au démarrage", "settings.updates.row.startup.description": "Vérifier automatiquement les mises à jour au lancement d'Kilo", "settings.updates.row.check.title": "Vérifier les mises à jour", @@ -750,10 +811,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "Environnement", "settings.providers.tag.config": "Configuration", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "Personnalisé", "settings.providers.tag.other": "Autre", "settings.providers.tag.customProvider": "Fournisseur personnalisé", "settings.providers.connected.environmentDescription": "Connecté depuis vos variables d'environnement", + "settings.providers.action.signInChatGPT": "Se connecter avec ChatGPT", "settings.providers.custom.description": "Ajoutez un fournisseur compatible OpenAI par URL de base.", "settings.providers.modeModels": "Modèle par mode", "settings.providers.custom.note": "Ajoutez un fournisseur compatible OpenAI par URL de base.", @@ -839,6 +902,31 @@ export const dict = { "provider.custom.error.baseURL.format": "Doit commencer par http:// ou https://", "provider.custom.error.required": "Requis", "provider.custom.error.duplicate": "Doublon", + "settings.openLocalConfig": "Config locale", + "settings.openGlobalConfig": "Config globale", + "settings.config.scope.local": "Local", + "settings.config.scope.global": "Global", + "settings.config.status.loaded": "chargé", + "settings.config.status.loadedLegacy": "configuration obsolète chargée", + "settings.config.status.notLoaded": "non chargé", + "settings.config.status.create": "introuvable - créer ce fichier", + "settings.config.title": "Ouvrir le fichier de configuration Kilo {{scope}}", + "settings.config.placeholder": + "Les fichiers de configuration sont fusionnés dans l'ordre ; les fichiers marqués comme chargés affectent actuellement les paramètres.", + "settings.config.noWorkspace": + "Ouvrez un dossier d'espace de travail pour modifier le fichier de configuration Kilo local.", + "settings.config.openFailed": "Échec de l'ouverture du fichier de configuration Kilo {{scope}} : {{message}}", + "settings.config.source.xdg": "Configuration globale XDG", + "settings.config.source.homeKilo": "Configuration .kilo de Home", + "settings.config.source.homeKilocode": "Configuration .kilocode de Home", + "settings.config.source.homeOpencode": "Configuration .opencode de Home", + "settings.config.source.envFile": "Fichier d'environnement KILO_CONFIG", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "Configuration d'environnement en ligne", + "settings.config.source.projectKilo": "Configuration .kilo du projet", + "settings.config.source.projectRoot": "Configuration racine du projet", + "settings.config.source.projectKilocode": "Configuration obsolète .kilocode", + "settings.config.source.projectOpencode": "Configuration obsolète .opencode", "settings.models.title": "Modèles", "settings.models.description": "Les paramètres des modèles seront configurables ici.", "settings.agents.title": "Agents", @@ -982,6 +1070,8 @@ export const dict = { "settings.checkpoints.title": "Points de contrôle", "settings.display.title": "Affichage", "settings.autocomplete.title": "Autocomplétion", + "settings.autocomplete.model.title": "Modèle d'autocomplétion", + "settings.autocomplete.model.description": "Sélectionnez le modèle utilisé pour les complétions de code en ligne", "settings.notifications.title": "Notifications", "settings.context.title": "Contexte", @@ -1127,6 +1217,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "Ne pas résumer le contenu collé volumineux", "settings.experimental.batch.title": "Outil par lot", "settings.experimental.batch.description": "Activer le traitement par lot d'appels d'outils", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Recherche de code", "settings.experimental.codebaseSearch.description": "Activer la recherche en langage naturel par IA dans toute la base de code", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index dc5c465c5fc..33057898833 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -263,6 +263,7 @@ export const dict = { "prompt.action.resetModel": "モデルをデフォルトにリセット", "prompt.action.enhanceDescription": "「プロンプトを強化」ボタンは、追加コンテキスト、説明、または言い換えを提供することで、リクエストを改善します。ここにリクエストを入力し、ボタンを再度クリックして動作を確認してください。", + "prompt.action.indexing": "インデックス設定", "prompt.toast.pasteUnsupported.title": "サポートされていない貼り付け", "prompt.toast.pasteUnsupported.description": "ここでは画像またはPDFのみ貼り付け可能です。", @@ -534,7 +535,38 @@ export const dict = { "session.new.worktree.mainWithBranch": "メインブランチ ({{branch}})", "session.new.worktree.create": "新しいワークツリーを作成", "session.new.lastModified": "最終更新", - + "sidebar.session.newSession": "新規セッション", + "sidebar.session.newSession.tooltip": "現在のセッションを維持したまま、新しい会話を開始します。", + "sidebar.session.newSession.disabled": + "このセッションは既に新規です。チャットを開始するか、代わりに worktree を作成してください。", + "sidebar.session.newWorktree": "新規 Worktree", + "sidebar.session.newWorktree.tooltip": + "隔離された git worktree を作成して、安全に実験し、変更を分離し、現在のブランチを中断することなく並行セッションを実行します。", + "sidebar.session.configureWorktree.tooltip": + "Agent Manager の worktree ダイアログを開き、作成する前に新しい worktree を構成します。", + "sidebar.session.newWorktree.from": "新規 Worktree の作成元", + "sidebar.session.currentBranch": "現在のブランチ", + "sidebar.session.moveToWorktree": "Worktree に移動", + "sidebar.session.moveToWorktree.tooltip.empty": + "この会話と現在のローカルの変更を専用の worktree に移動して、隔離された環境で作業を続行します。", + "sidebar.session.moveToWorktree.tooltip.one": + "この会話と1つの変更されたファイルを、分離されたフォローアップ作業用の専用 worktree に移動します。", + "sidebar.session.moveToWorktree.tooltip.other": + "この会話と{{files}}個の変更されたファイルを、分離されたフォローアップ作業用の専用 worktree に移動します。", + "sidebar.session.showChanges.tooltip.empty": "変更ビューを開いて、現在の作業ツリーを検査します。", + "sidebar.session.showChanges.tooltip.one": + "1つのファイルが変更されました · +{{additions}} -{{deletions}}。変更ビューを開きます。", + "sidebar.session.showChanges.tooltip.other": + "{{files}}個のファイルが変更されました · +{{additions}} -{{deletions}}。変更ビューを開きます。", + "sidebar.session.agentManager.tooltip": + "Agent Manager を開いて、並行セッションと worktree の全体像を把握し、時間のかかるタスクを 1 か所で調整できます。", + "sidebar.session.openAgentManager": "Agent Manager を開く", + "sidebar.session.progress.capturing": "変更をキャプチャ中...", + "sidebar.session.progress.creating": "worktree を作成中...", + "sidebar.session.progress.setup": "セットアップを実行中...", + "sidebar.session.progress.transferring": "変更を転送中...", + "sidebar.session.progress.forking": "セッションを開始中...", + "sidebar.session.progress.failed": "worktree での再開に失敗しました", "session.header.search.placeholder": "{{project}}を検索", "session.header.searchFiles": "ファイルを検索", "session.header.openIn": "で開く", @@ -709,6 +741,31 @@ export const dict = { "settings.general.sounds.errors.title": "エラー", "settings.general.sounds.errors.description": "エラーが発生した場合に音を再生します", + "settings.indexing.dimension.description": "空のままにすると、モデルから埋め込み次元を自動検出します。", + "settings.indexing.dimension.placeholder": "自動", + "settings.indexing.dimension.title": "ベクトル次元", + "settings.indexing.enable.description": + "このワークスペースのセマンティックコードベースインデックスをオンまたはオフにします。", + "settings.indexing.enable.title": "インデックスを有効にする", + "settings.indexing.lancedbDirectory.description": "ローカルLanceDBストアのオプションのディレクトリ。", + "settings.indexing.lancedbDirectory.placeholder": "デフォルトの場合は空のままにする", + "settings.indexing.lancedbDirectory.title": "LanceDBディレクトリ", + "settings.indexing.model.description": "選択したプロバイダーのデフォルト埋め込みモデルを上書きします。", + "settings.indexing.model.title": "埋め込みモデル", + "settings.indexing.provider.description": "セマンティック検索用の埋め込みを生成するプロバイダーを選択します。", + "settings.indexing.provider.title": "埋め込みプロバイダー", + "settings.indexing.providerField.description": "プロバイダー固有の接続設定。", + "settings.indexing.qdrantApiKey.description": "QdrantインスタンスのオプションのAPIキー。", + "settings.indexing.qdrantApiKey.placeholder": "オプションのAPIキー", + "settings.indexing.qdrantApiKey.title": "Qdrant APIキー", + "settings.indexing.qdrantUrl.description": "QdrantインスタンスのサーバーURL。", + "settings.indexing.qdrantUrl.title": "Qdrant URL", + "settings.indexing.status.title": "ステータス", + "settings.indexing.title": "インデックス", + "settings.indexing.tuning.description": "高度な検索とバッチ処理パラメータ。", + "settings.indexing.vectorStore.description": "インデックスされた埋め込みを保存する場所を選択します。", + "settings.indexing.vectorStore.title": "ベクトルストア", + "settings.shortcuts.title": "キーボードショートカット", "settings.shortcuts.reset.button": "デフォルトにリセット", "settings.shortcuts.reset.toast.title": "ショートカットをリセットしました", @@ -739,10 +796,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "環境", "settings.providers.tag.config": "設定", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "カスタム", "settings.providers.tag.other": "その他", "settings.providers.tag.customProvider": "カスタムプロバイダー", "settings.providers.connected.environmentDescription": "環境変数から接続されています", + "settings.providers.action.signInChatGPT": "ChatGPT でサインイン", "settings.providers.custom.description": "ベースURLでOpenAI互換プロバイダーを追加します。", "settings.providers.modeModels": "モードごとのモデル", "settings.providers.custom.note": "Base URL で OpenAI 互換プロバイダーを追加します。", @@ -825,6 +884,30 @@ export const dict = { "provider.custom.error.baseURL.format": "http:// または https:// で始まる必要があります", "provider.custom.error.required": "必須", "provider.custom.error.duplicate": "重複", + "settings.openLocalConfig": "ローカル設定", + "settings.openGlobalConfig": "グローバル設定", + "settings.config.scope.local": "ローカル", + "settings.config.scope.global": "グローバル", + "settings.config.status.loaded": "読み込み済み", + "settings.config.status.loadedLegacy": "旧構成を読み込み済み", + "settings.config.status.notLoaded": "読み込まれていません", + "settings.config.status.create": "見つかりません - このファイルを作成する", + "settings.config.title": "{{scope}}のKilo構成ファイルを開く", + "settings.config.placeholder": + "構成ファイルは順番にマージされます。読み込み済みとしてマークされているファイルが現在設定に影響しています。", + "settings.config.noWorkspace": "ローカルのKilo構成ファイルを編集するには、ワークスペースフォルダーを開いてください。", + "settings.config.openFailed": "{{scope}}のKilo構成ファイルを開けませんでした: {{message}}", + "settings.config.source.xdg": "XDGグローバル構成", + "settings.config.source.homeKilo": "Homeの.kilo構成", + "settings.config.source.homeKilocode": "Homeの.kilocode構成", + "settings.config.source.homeOpencode": "Homeの.opencode構成", + "settings.config.source.envFile": "KILO_CONFIG環境ファイル", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "インライン環境構成", + "settings.config.source.projectKilo": "プロジェクトの.kilo構成", + "settings.config.source.projectRoot": "プロジェクトルート構成", + "settings.config.source.projectKilocode": "旧.kilocode構成", + "settings.config.source.projectOpencode": "旧.opencode構成", "settings.models.title": "モデル", "settings.models.description": "モデル設定はここで構成できます。", "settings.agents.title": "エージェント", @@ -966,6 +1049,8 @@ export const dict = { "settings.checkpoints.title": "チェックポイント", "settings.display.title": "表示", "settings.autocomplete.title": "オートコンプリート", + "settings.autocomplete.model.title": "オートコンプリートモデル", + "settings.autocomplete.model.description": "インラインでのコード補完に使用するモデルを選択します", "settings.notifications.title": "通知", "settings.context.title": "コンテキスト", @@ -1106,6 +1191,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "大量のペーストコンテンツを要約しない", "settings.experimental.batch.title": "バッチツール", "settings.experimental.batch.description": "複数のツール呼び出しのバッチ処理を有効にする", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "コードベース検索", "settings.experimental.codebaseSearch.description": "コードベース全体でAIによる自然言語検索を有効にする", "settings.experimental.continueOnDeny.title": "拒否時に続行", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index b042919779d..f6990e51485 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -536,7 +536,36 @@ export const dict = { "session.new.worktree.mainWithBranch": "메인 브랜치 ({{branch}})", "session.new.worktree.create": "새 작업 트리 생성", "session.new.lastModified": "최근 수정", - + "sidebar.session.newSession": "새 세션", + "sidebar.session.newSession.tooltip": "현재 세션을 그대로 유지하면서 새로운 대화를 시작합니다.", + "sidebar.session.newSession.disabled": "이 세션은 이미 새 세션입니다. 채팅을 시작하거나 대신 worktree를 만드세요.", + "sidebar.session.newWorktree": "새 Worktree", + "sidebar.session.newWorktree.tooltip": + "격리된 git worktree를 만들어 안전하게 실험하고, 변경 사항을 분리하며, 현재 브랜치를 방해하지 않고 병렬 세션을 실행합니다.", + "sidebar.session.configureWorktree.tooltip": + "Agent Manager worktree 대화상자를 열어 생성하기 전에 새 worktree를 구성합니다.", + "sidebar.session.newWorktree.from": "다음에서 새 Worktree 생성:", + "sidebar.session.currentBranch": "현재 브랜치", + "sidebar.session.moveToWorktree": "Worktree로 이동", + "sidebar.session.moveToWorktree.tooltip.empty": + "이 대화 및 현재 로컬 변경 사항을 전용 worktree로 이동하여 격리된 후속 작업을 수행합니다.", + "sidebar.session.moveToWorktree.tooltip.one": + "이 대화와 변경된 파일 1개를 격리된 후속 작업을 위해 전용 worktree로 이동합니다.", + "sidebar.session.moveToWorktree.tooltip.other": + "이 대화와 변경된 파일 {{files}}개를 격리된 후속 작업을 위해 전용 worktree로 이동합니다.", + "sidebar.session.showChanges.tooltip.empty": "변경 사항 뷰를 열어 현재 작업 트리를 검사합니다.", + "sidebar.session.showChanges.tooltip.one": "파일 1개 변경됨 · +{{additions}} -{{deletions}}. 변경 사항 뷰 열기.", + "sidebar.session.showChanges.tooltip.other": + "파일 {{files}}개 변경됨 · +{{additions}} -{{deletions}}. 변경 사항 뷰 열기.", + "sidebar.session.agentManager.tooltip": + "Agent Manager를 열어 병렬 세션 및 worktree의 전체 개요를 확인하고, 장기 실행 작업을 한 곳에서 조정할 수 있습니다.", + "sidebar.session.openAgentManager": "Agent Manager 열기", + "sidebar.session.progress.capturing": "변경 사항 캡처 중...", + "sidebar.session.progress.creating": "worktree 생성 중...", + "sidebar.session.progress.setup": "설정 실행 중...", + "sidebar.session.progress.transferring": "변경 사항 전송 중...", + "sidebar.session.progress.forking": "세션 시작 중...", + "sidebar.session.progress.failed": "worktree에서 계속하지 못했습니다", "session.header.search.placeholder": "{{project}} 검색", "session.header.searchFiles": "파일 검색", "session.header.openIn": "열기", @@ -739,10 +768,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "환경", "settings.providers.tag.config": "구성", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "사용자 지정", "settings.providers.tag.other": "기타", "settings.providers.tag.customProvider": "사용자 정의 공급자", "settings.providers.connected.environmentDescription": "환경 변수에서 연결됨", + "settings.providers.action.signInChatGPT": "ChatGPT로 로그인", "settings.providers.custom.description": "기본 URL로 OpenAI 호환 공급자를 추가합니다.", "settings.providers.modeModels": "모드별 모델", "settings.providers.custom.note": "Base URL로 OpenAI 호환 공급자를 추가합니다.", @@ -825,6 +856,30 @@ export const dict = { "provider.custom.error.baseURL.format": "http:// 또는 https://로 시작해야 합니다", "provider.custom.error.required": "필수", "provider.custom.error.duplicate": "중복", + "settings.openLocalConfig": "로컬 설정", + "settings.openGlobalConfig": "전역 설정", + "settings.config.scope.local": "로컬", + "settings.config.scope.global": "글로벌", + "settings.config.status.loaded": "로드됨", + "settings.config.status.loadedLegacy": "레거시 구성 로드됨", + "settings.config.status.notLoaded": "로드되지 않음", + "settings.config.status.create": "찾을 수 없음 - 이 파일 만들기", + "settings.config.title": "{{scope}} Kilo 구성 파일 열기", + "settings.config.placeholder": + "구성 파일은 순서대로 병합됩니다. 로드됨으로 표시된 파일이 현재 설정에 영향을 미칩니다.", + "settings.config.noWorkspace": "로컬 Kilo 구성 파일을 편집하려면 작업 영역 폴더를 엽니다.", + "settings.config.openFailed": "{{scope}} Kilo 구성 파일을 열지 못했습니다: {{message}}", + "settings.config.source.xdg": "XDG 글로벌 구성", + "settings.config.source.homeKilo": "Home .kilo 구성", + "settings.config.source.homeKilocode": "Home .kilocode 구성", + "settings.config.source.homeOpencode": "Home .opencode 구성", + "settings.config.source.envFile": "KILO_CONFIG 환경 파일", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "인라인 환경 구성", + "settings.config.source.projectKilo": "프로젝트 .kilo 구성", + "settings.config.source.projectRoot": "프로젝트 루트 구성", + "settings.config.source.projectKilocode": "레거시 .kilocode 구성", + "settings.config.source.projectOpencode": "레거시 .opencode 구성", "settings.models.title": "모델", "settings.models.description": "모델 설정은 여기서 구성할 수 있습니다.", "settings.agents.title": "에이전트", @@ -965,6 +1020,8 @@ export const dict = { "settings.checkpoints.title": "체크포인트", "settings.display.title": "디스플레이", "settings.autocomplete.title": "자동 완성", + "settings.autocomplete.model.title": "자동 완성 모델", + "settings.autocomplete.model.description": "인라인 코드 완성에 사용되는 모델 선택", "settings.notifications.title": "알림", "settings.context.title": "컨텍스트", @@ -972,6 +1029,31 @@ export const dict = { "settings.language.title": "언어", "settings.aboutKiloCode.title": "Kilo Code 정보", + "prompt.action.indexing": "인덱싱 설정", + "settings.indexing.dimension.description": "비워두면 모델에서 임베딩 차원을 자동으로 감지합니다.", + "settings.indexing.dimension.placeholder": "자동", + "settings.indexing.dimension.title": "벡터 차원", + "settings.indexing.enable.description": "이 작업 공간의 의미적 코드베이스 인덱싱을 켜거나 끕니다.", + "settings.indexing.enable.title": "인덱싱 활성화", + "settings.indexing.lancedbDirectory.description": "로컬 LanceDB 저장소의 선택적 디렉터리입니다.", + "settings.indexing.lancedbDirectory.placeholder": "기본값 사용을 위해 비워두세요", + "settings.indexing.lancedbDirectory.title": "LanceDB 디렉터리", + "settings.indexing.model.description": "선택한 공급자의 기본 임베딩 모델을 재정의합니다.", + "settings.indexing.model.title": "임베딩 모델", + "settings.indexing.provider.description": "의미 검색을 위한 임베딩 생성에 사용할 공급자를 선택하세요.", + "settings.indexing.provider.title": "임베딩 공급자", + "settings.indexing.providerField.description": "공급자별 연결 설정.", + "settings.indexing.qdrantApiKey.description": "Qdrant 인스턴스에 대한 선택적 API 키입니다.", + "settings.indexing.qdrantApiKey.placeholder": "선택적 API 키", + "settings.indexing.qdrantApiKey.title": "Qdrant API 키", + "settings.indexing.qdrantUrl.description": "Qdrant 인스턴스의 서버 URL입니다.", + "settings.indexing.qdrantUrl.title": "Qdrant URL", + "settings.indexing.status.title": "상태", + "settings.indexing.title": "인덱싱", + "settings.indexing.tuning.description": "고급 검색 및 배치 매개변수입니다.", + "settings.indexing.vectorStore.description": "인덱싱된 임베딩을 저장할 위치를 선택하세요.", + "settings.indexing.vectorStore.title": "벡터 저장소", + "session.messages.empty": "아래에 메시지를 입력하여 대화를 시작하세요.", "session.messages.welcome": "Kilo Code는 AI 코딩 어시스턴트입니다. 기능 구축, 버그 수정 또는 코드베이스 설명을 요청하세요.", @@ -1102,6 +1184,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "대량 붙여넣기 콘텐츠를 요약하지 않음", "settings.experimental.batch.title": "배치 도구", "settings.experimental.batch.description": "여러 도구 호출의 배치 처리 활성화", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "코드베이스 검색", "settings.experimental.codebaseSearch.description": "코드베이스 전체에서 AI 기반 자연어 검색 활성화", "settings.experimental.continueOnDeny.title": "거부 시 계속", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 4c44e82911b..3f0605a7ba5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -262,6 +262,7 @@ export const dict = { "prompt.action.send.blocked": "Beantwoord of negeer eerst de openstaande vraag", "prompt.action.stop": "Stop", "prompt.action.enhance": "Prompt verbeteren", + "prompt.action.indexing": "Indexeringsinstellingen", "prompt.action.resetModel": "Model terugzetten naar standaard", "prompt.action.enhanceDescription": "De knop 'Prompt verbeteren' helpt je prompt te verbeteren door extra context, verduidelijking of herformulering te bieden. Typ hier een prompt en klik nogmaals op de knop om te zien hoe het werkt.", @@ -540,7 +541,39 @@ export const dict = { "session.new.worktree.mainWithBranch": "Main-branch ({{branch}})", "session.new.worktree.create": "Nieuwe worktree aanmaken", "session.new.lastModified": "Laatst gewijzigd", - + "sidebar.session.newSession": "Nieuwe Sessie", + "sidebar.session.newSession.tooltip": "Start een nieuw gesprek terwijl je de huidige sessie intact houdt.", + "sidebar.session.newSession.disabled": + "Deze sessie is al nieuw. Begin met chatten of maak in plaats daarvan een worktree aan.", + "sidebar.session.newWorktree": "Nieuwe Worktree", + "sidebar.session.newWorktree.tooltip": + "Maak een geïsoleerde git worktree aan om veilig te experimenteren, wijzigingen gescheiden te houden en parallelle sessies uit te voeren zonder je huidige branch te verstoren.", + "sidebar.session.configureWorktree.tooltip": + "Open het Agent Manager worktree-dialoogvenster om een nieuwe worktree te configureren voordat je deze aanmaakt.", + "sidebar.session.newWorktree.from": "Nieuwe Worktree van", + "sidebar.session.currentBranch": "huidige branch", + "sidebar.session.moveToWorktree": "Verplaats naar Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "Verplaats dit gesprek en je huidige lokale wijzigingen naar een toegewijde worktree voor geïsoleerd vervolgwerk.", + "sidebar.session.moveToWorktree.tooltip.one": + "Verplaats deze conversatie en 1 gewijzigd bestand naar een specifieke worktree voor geïsoleerd vervolgwerk.", + "sidebar.session.moveToWorktree.tooltip.other": + "Verplaats deze conversatie en {{files}} gewijzigde bestanden naar een specifieke worktree voor geïsoleerd vervolgwerk.", + "sidebar.session.showChanges.tooltip.empty": + "Open de weergave met wijzigingen om de huidige worktree te inspecteren.", + "sidebar.session.showChanges.tooltip.one": + "1 bestand gewijzigd · +{{additions}} -{{deletions}}. Open de wijzigingenweergave.", + "sidebar.session.showChanges.tooltip.other": + "{{files}} bestanden gewijzigd · +{{additions}} -{{deletions}}. Open de wijzigingenweergave.", + "sidebar.session.agentManager.tooltip": + "Open Agent Manager voor een compleet overzicht van parallelle sessies en worktrees, zodat je langlopende taken op één plek kunt coördineren.", + "sidebar.session.openAgentManager": "Open Agent Manager", + "sidebar.session.progress.capturing": "Wijzigingen vastleggen...", + "sidebar.session.progress.creating": "Worktree aanmaken...", + "sidebar.session.progress.setup": "Setup uitvoeren...", + "sidebar.session.progress.transferring": "Wijzigingen overzetten...", + "sidebar.session.progress.forking": "Sessie starten...", + "sidebar.session.progress.failed": "Kan niet doorgaan in worktree", "session.header.search.placeholder": "Zoek in {{project}}", "session.header.searchFiles": "Bestanden zoeken", "session.header.openIn": "Open in", @@ -743,10 +776,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "Omgeving", "settings.providers.tag.config": "Config", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "Aangepast", "settings.providers.tag.customProvider": "Aangepaste provider", "settings.providers.tag.other": "Overige", "settings.providers.connected.environmentDescription": "Gekoppeld via je omgevingsvariabelen", + "settings.providers.action.signInChatGPT": "Inloggen met ChatGPT", "settings.providers.custom.description": "Voeg een OpenAI-compatibele provider toe via basis-URL.", "provider.custom.title": "Aangepaste provider", @@ -828,6 +863,30 @@ export const dict = { "provider.custom.error.baseURL.format": "Moet beginnen met http:// of https://", "provider.custom.error.required": "Vereist", "provider.custom.error.duplicate": "Duplicaat", + "settings.openLocalConfig": "Lokale config", + "settings.openGlobalConfig": "Globale config", + "settings.config.scope.local": "Lokaal", + "settings.config.scope.global": "Globaal", + "settings.config.status.loaded": "geladen", + "settings.config.status.loadedLegacy": "verouderde configuratie geladen", + "settings.config.status.notLoaded": "niet geladen", + "settings.config.status.create": "niet gevonden - maak dit bestand", + "settings.config.title": "Open {{scope}} Kilo-configuratiebestand", + "settings.config.placeholder": + "Configuratiebestanden worden op volgorde samengevoegd; bestanden gemarkeerd als geladen hebben momenteel invloed op de instellingen.", + "settings.config.noWorkspace": "Open een werkruimtemap om het lokale Kilo-configuratiebestand te bewerken.", + "settings.config.openFailed": "Kan {{scope}} Kilo-configuratiebestand niet openen: {{message}}", + "settings.config.source.xdg": "XDG globale configuratie", + "settings.config.source.homeKilo": "Home .kilo-configuratie", + "settings.config.source.homeKilocode": "Home .kilocode-configuratie", + "settings.config.source.homeOpencode": "Home .opencode-configuratie", + "settings.config.source.envFile": "KILO_CONFIG omgevingsbestand", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "Inline omgevingsconfiguratie", + "settings.config.source.projectKilo": "Project .kilo-configuratie", + "settings.config.source.projectRoot": "Project root configuratie", + "settings.config.source.projectKilocode": "Verouderde .kilocode-configuratie", + "settings.config.source.projectOpencode": "Verouderde .opencode-configuratie", "settings.models.title": "Modellen", "settings.models.description": "Model-instellingen zullen hier configureerbaar zijn.", "settings.agents.title": "Agenten", @@ -969,8 +1028,35 @@ export const dict = { "settings.checkpoints.title": "Controlepunten", "settings.display.title": "Weergave", "settings.autocomplete.title": "Automatisch Aanvullen", + "settings.autocomplete.model.title": "Autocomplete-model", + "settings.autocomplete.model.description": "Selecteer het model dat wordt gebruikt voor inline code-aanvullingen", "settings.notifications.title": "Meldingen", "settings.context.title": "Context", + "settings.indexing.title": "Indexering", + "settings.indexing.status.title": "Status", + "settings.indexing.enable.title": "Indexering inschakelen", + "settings.indexing.enable.description": "Schakel semantische codebase-indexering in of uit voor deze werkruimte.", + "settings.indexing.provider.title": "Embedding-provider", + "settings.indexing.provider.description": + "Kies de provider die wordt gebruikt om embeddings te genereren voor semantisch zoeken.", + "settings.indexing.model.title": "Embedding-model", + "settings.indexing.model.description": "Overschrijf het standaard embedding-model voor de geselecteerde provider.", + "settings.indexing.dimension.title": "Vectordimensie", + "settings.indexing.dimension.description": + "Laat leeg om de embedding-dimensie automatisch te detecteren vanuit het model.", + "settings.indexing.dimension.placeholder": "Auto", + "settings.indexing.providerField.description": "Provider-specifieke verbindingsinstelling.", + "settings.indexing.vectorStore.title": "Vectoropslag", + "settings.indexing.vectorStore.description": "Kies waar geïndexeerde embeddings worden opgeslagen.", + "settings.indexing.lancedbDirectory.title": "LanceDB-directory", + "settings.indexing.lancedbDirectory.description": "Optionele directory voor de lokale LanceDB-opslag.", + "settings.indexing.lancedbDirectory.placeholder": "Laat leeg voor standaard", + "settings.indexing.qdrantUrl.title": "Qdrant-URL", + "settings.indexing.qdrantUrl.description": "Server-URL voor de Qdrant-instantie.", + "settings.indexing.qdrantApiKey.title": "Qdrant API-sleutel", + "settings.indexing.qdrantApiKey.description": "Optionele API-sleutel voor de Qdrant-instantie.", + "settings.indexing.qdrantApiKey.placeholder": "Optionele API-sleutel", + "settings.indexing.tuning.description": "Geavanceerde parameter voor zoeken en batching.", "settings.experimental.title": "Experimenteel", "settings.language.title": "Taal", @@ -1115,6 +1201,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "Vat grote geplakte inhoud niet samen", "settings.experimental.batch.title": "Batch Tool", "settings.experimental.batch.description": "Schakel batching van meerdere tool calls in", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Codebase Zoeken", "settings.experimental.codebaseSearch.description": "Schakel AI-aangedreven zoeken in natuurlijke taal door je codebase in", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index be8327ccab6..486d5d7cc28 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -265,6 +265,7 @@ export const dict = { "prompt.action.stop": "Stopp", "prompt.action.enhance": "Forbedre prompt", "prompt.action.resetModel": "Tilbakestill modell til standard", + "prompt.action.indexing": "Indekseringsinnstillinger", "prompt.action.enhanceDescription": "Knappen 'Forbedre prompt' hjelper med å forbedre forespørselen din ved å gi ekstra kontekst, avklaring eller omformulering. Prøv å skrive en forespørsel her og klikk på knappen igjen for å se hvordan det fungerer.", @@ -539,7 +540,37 @@ export const dict = { "session.new.worktree.mainWithBranch": "Hovedgren ({{branch}})", "session.new.worktree.create": "Opprett nytt worktree", "session.new.lastModified": "Sist endret", - + "sidebar.session.newSession": "Ny Økt", + "sidebar.session.newSession.tooltip": "Start en ny samtale mens den nåværende økten holdes intakt.", + "sidebar.session.newSession.disabled": + "Denne økten er allerede ny. Start en chat eller opprett et worktree i stedet.", + "sidebar.session.newWorktree": "Nytt Worktree", + "sidebar.session.newWorktree.tooltip": + "Opprett et isolert git worktree for å eksperimentere trygt, holde endringer adskilt og kjøre parallelle økter uten å forstyrre din nåværende branch.", + "sidebar.session.configureWorktree.tooltip": + "Åpne Agent Manager worktree-dialogen for å konfigurere et nytt worktree før opprettelse.", + "sidebar.session.newWorktree.from": "Nytt Worktree fra", + "sidebar.session.currentBranch": "nåværende branch", + "sidebar.session.moveToWorktree": "Flytt til Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "Flytt denne samtalen og dine nåværende lokale endringer til et dedikert worktree for isolert oppfølgingsarbeid.", + "sidebar.session.moveToWorktree.tooltip.one": + "Flytt denne samtalen og 1 endret fil til en dedikert worktree for isolert oppfølgingsarbeid.", + "sidebar.session.moveToWorktree.tooltip.other": + "Flytt denne samtalen og {{files}} endrede filer til en dedikert worktree for isolert oppfølgingsarbeid.", + "sidebar.session.showChanges.tooltip.empty": "Åpne endringsvisningen for å inspisere det nåværende arbeidstreet.", + "sidebar.session.showChanges.tooltip.one": "1 fil endret · +{{additions}} -{{deletions}}. Åpne endringsvisningen.", + "sidebar.session.showChanges.tooltip.other": + "{{files}} filer endret · +{{additions}} -{{deletions}}. Åpne endringsvisningen.", + "sidebar.session.agentManager.tooltip": + "Åpne Agent Manager for full oversikt over parallelle økter og worktrees, slik at du kan koordinere langvarige oppgaver på ett sted.", + "sidebar.session.openAgentManager": "Åpne Agent Manager", + "sidebar.session.progress.capturing": "Fanger endringer...", + "sidebar.session.progress.creating": "Oppretter worktree...", + "sidebar.session.progress.setup": "Kjører oppsett...", + "sidebar.session.progress.transferring": "Overfører endringer...", + "sidebar.session.progress.forking": "Starter økt...", + "sidebar.session.progress.failed": "Kunne ikke fortsette i worktree", "session.header.search.placeholder": "Søk i {{project}}", "session.header.searchFiles": "Søk etter filer", "session.header.openIn": "Åpne i", @@ -744,10 +775,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "Miljø", "settings.providers.tag.config": "Konfigurasjon", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "Tilpasset", "settings.providers.tag.other": "Annet", "settings.providers.tag.customProvider": "Egendefinert leverandør", "settings.providers.connected.environmentDescription": "Koblet til fra dine miljøvariabler", + "settings.providers.action.signInChatGPT": "Logg inn med ChatGPT", "settings.providers.custom.description": "Legg til en OpenAI-kompatibel leverandør via basis-URL.", "settings.providers.modeModels": "Modell per modus", "settings.providers.custom.note": "Legg til en OpenAI-kompatibel leverandør via basis-URL.", @@ -831,6 +864,30 @@ export const dict = { "provider.custom.error.baseURL.format": "Må starte med http:// eller https://", "provider.custom.error.required": "Påkrevd", "provider.custom.error.duplicate": "Duplikat", + "settings.openLocalConfig": "Lokal konfig", + "settings.openGlobalConfig": "Global konfig", + "settings.config.scope.local": "Lokal", + "settings.config.scope.global": "Global", + "settings.config.status.loaded": "lastet", + "settings.config.status.loadedLegacy": "lastet inn eldre konfigurasjon", + "settings.config.status.notLoaded": "ikke lastet", + "settings.config.status.create": "ikke funnet - opprett denne filen", + "settings.config.title": "Åpne {{scope}} Kilo-konfigurasjonsfil", + "settings.config.placeholder": + "Konfigurasjonsfiler slås sammen i rekkefølge; filer merket som lastet påvirker for øyeblikket innstillingene.", + "settings.config.noWorkspace": "Åpne en arbeidsområdemappe for å redigere den lokale Kilo-konfigurasjonsfilen.", + "settings.config.openFailed": "Klarte ikke å åpne {{scope}} Kilo-konfigurasjonsfil: {{message}}", + "settings.config.source.xdg": "XDG global konfigurasjon", + "settings.config.source.homeKilo": "Home .kilo-konfigurasjon", + "settings.config.source.homeKilocode": "Home .kilocode-konfigurasjon", + "settings.config.source.homeOpencode": "Home .opencode-konfigurasjon", + "settings.config.source.envFile": "KILO_CONFIG miljøfil", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "Innebygd miljøkonfigurasjon", + "settings.config.source.projectKilo": "Prosjekt .kilo-konfigurasjon", + "settings.config.source.projectRoot": "Prosjektets rotkonfigurasjon", + "settings.config.source.projectKilocode": "Eldre .kilocode-konfigurasjon", + "settings.config.source.projectOpencode": "Eldre .opencode-konfigurasjon", "settings.models.title": "Modeller", "settings.models.description": "Modellinnstillinger vil kunne konfigureres her.", "settings.agents.title": "Agenter", @@ -972,6 +1029,8 @@ export const dict = { "settings.checkpoints.title": "Kontrollpunkter", "settings.display.title": "Visning", "settings.autocomplete.title": "Autofullfør", + "settings.autocomplete.model.title": "Autocomplete-modell", + "settings.autocomplete.model.description": "Velg modellen som brukes for inline kodefullføring", "settings.notifications.title": "Varslinger", "settings.context.title": "Kontekst", @@ -1110,6 +1169,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "Ikke oppsummer stort limt innhold", "settings.experimental.batch.title": "Batchverktøy", "settings.experimental.batch.description": "Aktiver batchbehandling av verktøykall", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Kodesøk", "settings.experimental.codebaseSearch.description": "Aktiver AI-drevet naturlig språksøk på tvers av kodebasen", "settings.experimental.continueOnDeny.title": "Fortsett ved avvisning", @@ -1125,6 +1187,29 @@ export const dict = { "settings.experimental.remote.inactive": "Inaktiv", "settings.experimental.remote.hint": "Bruk /remote i chatten for å veksle", "settings.experimental.toolToggles": "Verktøybrytere", + "settings.indexing.title": "Indeksering", + "settings.indexing.enable.title": "Aktiver indeksering", + "settings.indexing.enable.description": "Slå semantisk kodebaseindeksering på eller av for dette arbeidsområdet.", + "settings.indexing.provider.title": "Embedding-leverandør", + "settings.indexing.provider.description": "Velg leverandøren som brukes til å generere embeddings for semantisk søk.", + "settings.indexing.model.title": "Embedding-modell", + "settings.indexing.model.description": "Overstyr standard embedding-modell for den valgte leverandøren.", + "settings.indexing.vectorStore.title": "Vektordatabase", + "settings.indexing.vectorStore.description": "Velg hvor indekserte embeddings lagres.", + "settings.indexing.lancedbDirectory.title": "LanceDB-mappe", + "settings.indexing.lancedbDirectory.description": "Valgfri mappe for den lokale LanceDB-lagringen.", + "settings.indexing.lancedbDirectory.placeholder": "La stå tom for standard", + "settings.indexing.qdrantUrl.title": "Qdrant URL", + "settings.indexing.qdrantUrl.description": "Server-URL for Qdrant-instansen.", + "settings.indexing.qdrantApiKey.title": "Qdrant API-nøkkel", + "settings.indexing.qdrantApiKey.description": "Valgfri API-nøkkel for Qdrant-instansen.", + "settings.indexing.qdrantApiKey.placeholder": "Valgfri API-nøkkel", + "settings.indexing.dimension.title": "Vektordimensjon", + "settings.indexing.dimension.description": "La stå tom for automatisk å oppdage embedding-dimensjonen fra modellen.", + "settings.indexing.dimension.placeholder": "Auto", + "settings.indexing.status.title": "Status", + "settings.indexing.tuning.description": "Avansert søke- og batch-parameter.", + "settings.indexing.providerField.description": "Leverandørspesifikk tilkoblingsinnstilling.", "settings.agentBehaviour.defaultAgent.title": "Standardagent", "settings.agentBehaviour.defaultAgent.description": "Agent å bruke når ingen er angitt", "settings.agentBehaviour.availableAgents": "Tilgjengelige agenter", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index 61f95eb5961..8e2db5ea27b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -264,6 +264,7 @@ export const dict = { "prompt.action.resetModel": "Zresetuj model do domyślnego", "prompt.action.enhanceDescription": "Przycisk 'Ulepsz podpowiedź' pomaga ulepszyć Twoją prośbę, dostarczając dodatkowy kontekst, wyjaśnienia lub przeformułowania. Spróbuj wpisać prośbę tutaj i kliknij przycisk ponownie, aby zobaczyć, jak to działa.", + "prompt.action.indexing": "Ustawienia indeksowania", "prompt.toast.pasteUnsupported.title": "Nieobsługiwane wklejanie", "prompt.toast.pasteUnsupported.description": "Tylko obrazy lub pliki PDF mogą być tutaj wklejane.", @@ -537,7 +538,36 @@ export const dict = { "session.new.worktree.mainWithBranch": "Główna gałąź ({{branch}})", "session.new.worktree.create": "Utwórz nowe drzewo robocze", "session.new.lastModified": "Ostatnio zmodyfikowano", - + "sidebar.session.newSession": "Nowa Sesja", + "sidebar.session.newSession.tooltip": "Rozpocznij nową konwersację, zachowując obecną sesję bez zmian.", + "sidebar.session.newSession.disabled": "Ta sesja jest już nowa. Rozpocznij czat lub zamiast tego utwórz worktree.", + "sidebar.session.newWorktree": "Nowe Worktree", + "sidebar.session.newWorktree.tooltip": + "Utwórz odizolowane git worktree, aby bezpiecznie eksperymentować, oddzielać zmiany i uruchamiać równoległe sesje bez zakłócania obecnej gałęzi.", + "sidebar.session.configureWorktree.tooltip": + "Otwórz okno dialogowe worktree w Agent Manager, aby skonfigurować nowe worktree przed jego utworzeniem.", + "sidebar.session.newWorktree.from": "Nowe Worktree z", + "sidebar.session.currentBranch": "obecna gałąź", + "sidebar.session.moveToWorktree": "Przenieś do Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "Przenieś tę konwersację i obecne lokalne zmiany do dedykowanego worktree w celu izolowanej dalszej pracy.", + "sidebar.session.moveToWorktree.tooltip.one": + "Przenieś tę rozmowę i 1 zmieniony plik do dedykowanego worktree w celu izolowanej dalszej pracy.", + "sidebar.session.moveToWorktree.tooltip.other": + "Przenieś tę rozmowę i {{files}} zmienionych plików do dedykowanego worktree w celu izolowanej dalszej pracy.", + "sidebar.session.showChanges.tooltip.empty": "Otwórz widok zmian, aby sprawdzić obecne drzewo robocze.", + "sidebar.session.showChanges.tooltip.one": "1 plik zmieniony · +{{additions}} -{{deletions}}. Otwórz widok zmian.", + "sidebar.session.showChanges.tooltip.other": + "{{files}} zmienionych plików · +{{additions}} -{{deletions}}. Otwórz widok zmian.", + "sidebar.session.agentManager.tooltip": + "Otwórz Agent Manager, aby uzyskać pełny przegląd równoległych sesji i worktree, ułatwiając koordynację długotrwałych zadań w jednym miejscu.", + "sidebar.session.openAgentManager": "Otwórz Agent Manager", + "sidebar.session.progress.capturing": "Przechwytywanie zmian...", + "sidebar.session.progress.creating": "Tworzenie worktree...", + "sidebar.session.progress.setup": "Uruchamianie konfiguracji...", + "sidebar.session.progress.transferring": "Przesyłanie zmian...", + "sidebar.session.progress.forking": "Uruchamianie sesji...", + "sidebar.session.progress.failed": "Nie udało się kontynuować w worktree", "session.header.search.placeholder": "Szukaj {{project}}", "session.header.searchFiles": "Szukaj plików", "session.header.openIn": "Otwórz w", @@ -743,10 +773,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "Środowisko", "settings.providers.tag.config": "Konfiguracja", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "Niestandardowe", "settings.providers.tag.other": "Inne", "settings.providers.tag.customProvider": "Niestandardowy dostawca", "settings.providers.connected.environmentDescription": "Połączony z twoich zmiennych środowiskowych", + "settings.providers.action.signInChatGPT": "Zaloguj przez ChatGPT", "settings.providers.custom.description": "Dodaj dostawcę kompatybilnego z OpenAI przez bazowy URL.", "settings.providers.modeModels": "Model na tryb", "settings.providers.custom.note": "Dodaj dostawcę kompatybilnego z OpenAI przez bazowy URL.", @@ -832,6 +864,30 @@ export const dict = { "provider.custom.error.baseURL.format": "Musi zaczynać się od http:// lub https://", "provider.custom.error.required": "Wymagane", "provider.custom.error.duplicate": "Duplikat", + "settings.openLocalConfig": "Konfig. lokalna", + "settings.openGlobalConfig": "Konfig. globalna", + "settings.config.scope.local": "Lokalne", + "settings.config.scope.global": "Globalne", + "settings.config.status.loaded": "wczytano", + "settings.config.status.loadedLegacy": "wczytano przestarzałą konfigurację", + "settings.config.status.notLoaded": "nie wczytano", + "settings.config.status.create": "nie znaleziono - utwórz ten plik", + "settings.config.title": "Otwórz plik konfiguracyjny Kilo ({{scope}})", + "settings.config.placeholder": + "Pliki konfiguracyjne są scalane po kolei; pliki oznaczone jako wczytane mają obecnie wpływ na ustawienia.", + "settings.config.noWorkspace": "Otwórz folder obszaru roboczego, aby edytować lokalny plik konfiguracyjny Kilo.", + "settings.config.openFailed": "Nie udało się otworzyć pliku konfiguracyjnego Kilo ({{scope}}): {{message}}", + "settings.config.source.xdg": "Globalna konfiguracja XDG", + "settings.config.source.homeKilo": "Konfiguracja .kilo (Home)", + "settings.config.source.homeKilocode": "Konfiguracja .kilocode (Home)", + "settings.config.source.homeOpencode": "Konfiguracja .opencode (Home)", + "settings.config.source.envFile": "Plik środowiskowy KILO_CONFIG", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "Wbudowana konfiguracja środowiska", + "settings.config.source.projectKilo": "Konfiguracja .kilo projektu", + "settings.config.source.projectRoot": "Konfiguracja główna projektu", + "settings.config.source.projectKilocode": "Przestarzała konfiguracja .kilocode", + "settings.config.source.projectOpencode": "Przestarzała konfiguracja .opencode", "settings.models.title": "Modele", "settings.models.description": "Ustawienia modeli będą tutaj konfigurowalne.", "settings.agents.title": "Agenci", @@ -972,6 +1028,8 @@ export const dict = { "settings.checkpoints.title": "Punkty kontrolne", "settings.display.title": "Wyświetlanie", "settings.autocomplete.title": "Autouzupełnianie", + "settings.autocomplete.model.title": "Model autouzupełniania", + "settings.autocomplete.model.description": "Wybierz model używany do uzupełniania kodu w linii (inline)", "settings.notifications.title": "Powiadomienia", "settings.context.title": "Kontekst", @@ -1111,6 +1169,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "Nie podsumowuj dużego wklejonego tekstu", "settings.experimental.batch.title": "Narzędzie wsadowe", "settings.experimental.batch.description": "Włącz przetwarzanie wsadowe wywołań narzędzi", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Wyszukiwanie kodu", "settings.experimental.codebaseSearch.description": "Włącz wyszukiwanie w języku naturalnym z AI w całej bazie kodu", "settings.experimental.continueOnDeny.title": "Kontynuuj przy odmowie", @@ -1126,6 +1187,31 @@ export const dict = { "settings.experimental.remote.inactive": "Nieaktywny", "settings.experimental.remote.hint": "Użyj /remote na czacie, aby przełączyć", "settings.experimental.toolToggles": "Przełączniki narzędzi", + "settings.indexing.title": "Indeksowanie", + "settings.indexing.dimension.description": "Pozostaw puste, aby automatycznie wykryć wymiar osadzania z modelu.", + "settings.indexing.dimension.placeholder": "Automatycznie", + "settings.indexing.dimension.title": "Wymiar wektora", + "settings.indexing.enable.description": + "Włącz lub wyłącz semantyczne indeksowanie bazy kodu dla tego obszaru roboczego.", + "settings.indexing.enable.title": "Włącz indeksowanie", + "settings.indexing.lancedbDirectory.description": "Opcjonalny katalog dla lokalnego magazynu LanceDB.", + "settings.indexing.lancedbDirectory.placeholder": "Pozostaw puste dla domyślnego", + "settings.indexing.lancedbDirectory.title": "Katalog LanceDB", + "settings.indexing.model.description": "Zastąp domyślny model osadzania dla wybranego dostawcy.", + "settings.indexing.model.title": "Model osadzania", + "settings.indexing.provider.description": + "Wybierz dostawcę używanego do generowania osadzeń dla wyszukiwania semantycznego.", + "settings.indexing.provider.title": "Dostawca osadzania", + "settings.indexing.providerField.description": "Ustawienie połączenia specyficzne dla dostawcy.", + "settings.indexing.qdrantApiKey.description": "Opcjonalny klucz API dla instancji Qdrant.", + "settings.indexing.qdrantApiKey.placeholder": "Opcjonalny klucz API", + "settings.indexing.qdrantApiKey.title": "Klucz API Qdranta", + "settings.indexing.qdrantUrl.description": "Adres URL serwera dla instancji Qdrant.", + "settings.indexing.qdrantUrl.title": "Adres URL Qdranta", + "settings.indexing.status.title": "Status", + "settings.indexing.tuning.description": "Zaawansowane parametry wyszukiwania i batchowania.", + "settings.indexing.vectorStore.description": "Wybierz, gdzie są przechowywane zaindeksowane osadzenia.", + "settings.indexing.vectorStore.title": "Magazyn wektorów", "settings.agentBehaviour.defaultAgent.title": "Domyślny agent", "settings.agentBehaviour.defaultAgent.description": "Agent używany, gdy żaden nie jest określony", "settings.agentBehaviour.availableAgents": "Dostępni agenci", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 63c152bab07..0f535a9ce23 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -261,6 +261,7 @@ export const dict = { "prompt.action.stop": "Остановить", "prompt.action.enhance": "Улучшить промпт", "prompt.action.resetModel": "Сбросить модель на значение по умолчанию", + "prompt.action.indexing": "Настройки индексации", "prompt.action.enhanceDescription": "Кнопка 'Улучшить запрос' помогает сделать ваш запрос лучше, предоставляя дополнительный контекст, уточнения или переформулировку. Попробуйте ввести запрос и снова нажать кнопку, чтобы увидеть, как это работает.", @@ -538,7 +539,37 @@ export const dict = { "session.new.worktree.mainWithBranch": "Основная ветка ({{branch}})", "session.new.worktree.create": "Создать новый worktree", "session.new.lastModified": "Последнее изменение", - + "sidebar.session.newSession": "Новая сессия", + "sidebar.session.newSession.tooltip": "Начать новую беседу, сохранив текущую сессию нетронутой.", + "sidebar.session.newSession.disabled": "Эта сессия уже новая. Начните чат или создайте worktree.", + "sidebar.session.newWorktree": "Новое Worktree", + "sidebar.session.newWorktree.tooltip": + "Создайте изолированное git worktree для безопасных экспериментов, разделения изменений и запуска параллельных сессий без прерывания вашей текущей ветки.", + "sidebar.session.configureWorktree.tooltip": + "Откройте диалоговое окно worktree в Agent Manager, чтобы настроить новое worktree перед созданием.", + "sidebar.session.newWorktree.from": "Новое Worktree из", + "sidebar.session.currentBranch": "текущей ветки", + "sidebar.session.moveToWorktree": "Перенести в Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "Перенесите эту беседу и ваши текущие локальные изменения в выделенное worktree для изолированной последующей работы.", + "sidebar.session.moveToWorktree.tooltip.one": + "Переместить этот разговор и 1 измененный файл в выделенный worktree для изолированной дальнейшей работы.", + "sidebar.session.moveToWorktree.tooltip.other": + "Переместить этот разговор и {{files}} измененных файлов в выделенный worktree для изолированной дальнейшей работы.", + "sidebar.session.showChanges.tooltip.empty": "Откройте просмотр изменений, чтобы изучить текущее рабочее дерево.", + "sidebar.session.showChanges.tooltip.one": + "1 файл изменен · +{{additions}} -{{deletions}}. Открыть просмотр изменений.", + "sidebar.session.showChanges.tooltip.other": + "{{files}} файлов изменено · +{{additions}} -{{deletions}}. Открыть просмотр изменений.", + "sidebar.session.agentManager.tooltip": + "Откройте Agent Manager для полного обзора параллельных сессий и worktree, чтобы координировать длительные задачи в одном месте.", + "sidebar.session.openAgentManager": "Открыть Agent Manager", + "sidebar.session.progress.capturing": "Захват изменений...", + "sidebar.session.progress.creating": "Создание worktree...", + "sidebar.session.progress.setup": "Выполнение настройки...", + "sidebar.session.progress.transferring": "Перенос изменений...", + "sidebar.session.progress.forking": "Запуск сессии...", + "sidebar.session.progress.failed": "Не удалось продолжить в worktree", "session.header.search.placeholder": "Поиск {{project}}", "session.header.searchFiles": "Поиск файлов", "session.header.openIn": "Открыть в", @@ -715,6 +746,32 @@ export const dict = { "settings.general.sounds.errors.title": "Ошибки", "settings.general.sounds.errors.description": "Воспроизводить звук когда происходит ошибка", + "settings.indexing.title": "Индексация", + "settings.indexing.enable.title": "Включить индексацию", + "settings.indexing.enable.description": + "Включить или отключить семантическую индексацию кодовой базы для этого рабочего пространства.", + "settings.indexing.status.title": "Статус", + "settings.indexing.provider.title": "Провайдер эмбеддингов", + "settings.indexing.provider.description": "Выберите провайдера для генерации эмбеддингов при семантическом поиске.", + "settings.indexing.model.title": "Модель эмбеддингов", + "settings.indexing.model.description": "Переопределить модель эмбеддингов по умолчанию для выбранного провайдера.", + "settings.indexing.dimension.title": "Размерность вектора", + "settings.indexing.dimension.description": + "Оставьте пустым для автоматического определения размерности эмбеддингов из модели.", + "settings.indexing.dimension.placeholder": "Авто", + "settings.indexing.vectorStore.title": "Векторное хранилище", + "settings.indexing.vectorStore.description": "Выберите, где хранить проиндексированные эмбеддинги.", + "settings.indexing.lancedbDirectory.title": "Директория LanceDB", + "settings.indexing.lancedbDirectory.description": "Необязательная директория для локального хранилища LanceDB.", + "settings.indexing.lancedbDirectory.placeholder": "Оставьте пустым для значения по умолчанию", + "settings.indexing.qdrantUrl.title": "URL Qdrant", + "settings.indexing.qdrantUrl.description": "URL сервера для экземпляра Qdrant.", + "settings.indexing.qdrantApiKey.title": "API-ключ Qdrant", + "settings.indexing.qdrantApiKey.description": "Необязательный API-ключ для экземпляра Qdrant.", + "settings.indexing.qdrantApiKey.placeholder": "Необязательный API-ключ", + "settings.indexing.providerField.description": "Настройка подключения, специфичная для провайдера.", + "settings.indexing.tuning.description": "Параметры расширенного поиска и пакетной обработки.", + "settings.shortcuts.title": "Горячие клавиши", "settings.shortcuts.reset.button": "Сбросить к умолчаниям", "settings.shortcuts.reset.toast.title": "Горячие клавиши сброшены", @@ -745,10 +802,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "Среда", "settings.providers.tag.config": "Конфигурация", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "Пользовательский", "settings.providers.tag.other": "Другое", "settings.providers.tag.customProvider": "Пользовательский провайдер", "settings.providers.connected.environmentDescription": "Подключён из ваших переменных окружения", + "settings.providers.action.signInChatGPT": "Войти через ChatGPT", "settings.providers.custom.description": "Добавьте OpenAI-совместимый провайдер по базовому URL.", "settings.providers.modeModels": "Модель для режима", "settings.providers.custom.note": "Добавьте OpenAI-совместимого провайдера по базовому URL.", @@ -833,6 +892,31 @@ export const dict = { "provider.custom.error.baseURL.format": "Должен начинаться с http:// или https://", "provider.custom.error.required": "Обязательно", "provider.custom.error.duplicate": "Дубликат", + "settings.openLocalConfig": "Локальный конфиг", + "settings.openGlobalConfig": "Глобальный конфиг", + "settings.config.scope.local": "Локальный", + "settings.config.scope.global": "Глобальный", + "settings.config.status.loaded": "загружено", + "settings.config.status.loadedLegacy": "загружена устаревшая конфигурация", + "settings.config.status.notLoaded": "не загружено", + "settings.config.status.create": "не найдено - создать этот файл", + "settings.config.title": "Открыть файл конфигурации Kilo ({{scope}})", + "settings.config.placeholder": + "Файлы конфигурации объединяются по порядку; файлы, отмеченные как загруженные, в данный момент влияют на настройки.", + "settings.config.noWorkspace": + "Откройте папку рабочей области для редактирования локального файла конфигурации Kilo.", + "settings.config.openFailed": "Не удалось открыть файл конфигурации Kilo ({{scope}}): {{message}}", + "settings.config.source.xdg": "Глобальная конфигурация XDG", + "settings.config.source.homeKilo": "Конфигурация .kilo (Home)", + "settings.config.source.homeKilocode": "Конфигурация .kilocode (Home)", + "settings.config.source.homeOpencode": "Конфигурация .opencode (Home)", + "settings.config.source.envFile": "Файл среды KILO_CONFIG", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "Встроенная конфигурация среды", + "settings.config.source.projectKilo": "Конфигурация .kilo проекта", + "settings.config.source.projectRoot": "Корневая конфигурация проекта", + "settings.config.source.projectKilocode": "Устаревшая конфигурация .kilocode", + "settings.config.source.projectOpencode": "Устаревшая конфигурация .opencode", "settings.models.title": "Модели", "settings.models.description": "Настройки моделей будут доступны здесь.", "settings.agents.title": "Агенты", @@ -974,6 +1058,8 @@ export const dict = { "settings.checkpoints.title": "Контрольные точки", "settings.display.title": "Отображение", "settings.autocomplete.title": "Автодополнение", + "settings.autocomplete.model.title": "Модель автодополнения", + "settings.autocomplete.model.description": "Выберите модель для встроенного (inline) автодополнения кода", "settings.notifications.title": "Уведомления", "settings.context.title": "Контекст", @@ -1112,6 +1198,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "Не суммировать большой вставленный контент", "settings.experimental.batch.title": "Пакетный инструмент", "settings.experimental.batch.description": "Включить пакетную обработку вызовов инструментов", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Поиск по коду", "settings.experimental.codebaseSearch.description": "Включить поиск на естественном языке с ИИ по всей кодовой базе", "settings.experimental.continueOnDeny.title": "Продолжить при отказе", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 3d992623d36..3a272ea63be 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -264,6 +264,7 @@ export const dict = { "prompt.action.resetModel": "รีเซ็ตโมเดลเป็นค่าเริ่มต้น", "prompt.action.enhanceDescription": "ปุ่ม 'ปรับปรุงพรอมต์' ช่วยปรับปรุงพรอมต์ของคุณโดยให้บริบทเพิ่มเติม ชี้แจง หรือเขียนใหม่ ลองพิมพ์พรอมต์ที่นี่และคลิกปุ่มอีกครั้งเพื่อดูว่ามันทำงานอย่างไร", + "prompt.action.indexing": "การตั้งค่าการสร้างดัชนี", "prompt.toast.pasteUnsupported.title": "การวางไม่รองรับ", "prompt.toast.pasteUnsupported.description": "สามารถวางรูปภาพหรือ PDF เท่านั้น", @@ -536,7 +537,37 @@ export const dict = { "session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})", "session.new.worktree.create": "สร้าง worktree ใหม่", "session.new.lastModified": "แก้ไขล่าสุด", - + "sidebar.session.newSession": "เซสชันใหม่", + "sidebar.session.newSession.tooltip": "เริ่มการสนทนาใหม่โดยที่ยังคงเซสชันปัจจุบันไว้", + "sidebar.session.newSession.disabled": "เซสชันนี้เป็นเซสชันใหม่อยู่แล้ว เริ่มแชทหรือสร้าง worktree แทน", + "sidebar.session.newWorktree": "Worktree ใหม่", + "sidebar.session.newWorktree.tooltip": + "สร้าง git worktree ที่แยกออกมาต่างหากเพื่อทดลองอย่างปลอดภัย แยกการเปลี่ยนแปลงออกจากกัน และรันเซสชันคู่ขนานโดยไม่รบกวน branch ปัจจุบันของคุณ", + "sidebar.session.configureWorktree.tooltip": + "เปิดกล่องโต้ตอบ worktree ของ Agent Manager เพื่อกำหนดค่า worktree ใหม่ก่อนที่จะสร้าง", + "sidebar.session.newWorktree.from": "Worktree ใหม่จาก", + "sidebar.session.currentBranch": "branch ปัจจุบัน", + "sidebar.session.moveToWorktree": "ย้ายไปยัง Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "ย้ายบทสนทนานี้และการเปลี่ยนแปลงในเครื่องปัจจุบันของคุณไปยัง worktree เฉพาะสำหรับการทำงานต่อเนื่องแบบแยกส่วน", + "sidebar.session.moveToWorktree.tooltip.one": + "ย้ายบทสนทนานี้และ 1 ไฟล์ที่ถูกแก้ไขไปยัง worktree เฉพาะสำหรับการทำงานต่อเนื่องแบบแยกส่วน", + "sidebar.session.moveToWorktree.tooltip.other": + "ย้ายบทสนทนานี้และ {{files}} ไฟล์ที่ถูกแก้ไขไปยัง worktree เฉพาะสำหรับการทำงานต่อเนื่องแบบแยกส่วน", + "sidebar.session.showChanges.tooltip.empty": "เปิดมุมมองการเปลี่ยนแปลงเพื่อตรวจสอบ working tree ปัจจุบัน", + "sidebar.session.showChanges.tooltip.one": + "เปลี่ยนแปลง 1 ไฟล์ · +{{additions}} -{{deletions}} เปิดมุมมองการเปลี่ยนแปลง", + "sidebar.session.showChanges.tooltip.other": + "เปลี่ยนแปลง {{files}} ไฟล์ · +{{additions}} -{{deletions}} เปิดมุมมองการเปลี่ยนแปลง", + "sidebar.session.agentManager.tooltip": + "เปิด Agent Manager เพื่อดูภาพรวมของเซสชันและ worktree คู่ขนาน เพื่อให้คุณสามารถประสานงานงานที่ใช้เวลานานได้ในที่เดียว", + "sidebar.session.openAgentManager": "เปิด Agent Manager", + "sidebar.session.progress.capturing": "กำลังบันทึกการเปลี่ยนแปลง...", + "sidebar.session.progress.creating": "กำลังสร้าง worktree...", + "sidebar.session.progress.setup": "กำลังรันการตั้งค่า...", + "sidebar.session.progress.transferring": "กำลังถ่ายโอนการเปลี่ยนแปลง...", + "sidebar.session.progress.forking": "กำลังเริ่มเซสชัน...", + "sidebar.session.progress.failed": "ดำเนินการต่อใน worktree ไม่สำเร็จ", "session.header.search.placeholder": "ค้นหา {{project}}", "session.header.searchFiles": "ค้นหาไฟล์", "session.header.openIn": "เปิดใน", @@ -707,6 +738,30 @@ export const dict = { "settings.general.sounds.errors.title": "ข้อผิดพลาด", "settings.general.sounds.errors.description": "เล่นเสียงเมื่อเกิดข้อผิดพลาด", + "settings.indexing.title": "การสร้างดัชนี", + "settings.indexing.enable.title": "เปิดใช้งานการสร้างดัชนี", + "settings.indexing.enable.description": "เปิดหรือปิดการสร้างดัชนีโค้ดเบสเชิงความหมายสำหรับพื้นที่ทำงานนี้", + "settings.indexing.provider.title": "ผู้ให้บริการการฝัง", + "settings.indexing.provider.description": "เลือกผู้ให้บริการที่ใช้สร้างการฝังสำหรับการค้นหาเชิงความหมาย", + "settings.indexing.model.title": "โมเดลการฝัง", + "settings.indexing.model.description": "แทนที่โมเดลการฝังเริ่มต้นสำหรับผู้ให้บริการที่เลือก", + "settings.indexing.dimension.title": "ขนาดเวกเตอร์", + "settings.indexing.dimension.description": "ปล่อยว่างเพื่อตรวจจับขนาดการฝังอัตโนมัติจากโมเดล", + "settings.indexing.dimension.placeholder": "อัตโนมัติ", + "settings.indexing.vectorStore.title": "ที่เก็บเวกเตอร์", + "settings.indexing.vectorStore.description": "เลือกที่เก็บการฝังที่สร้างดัชนี", + "settings.indexing.lancedbDirectory.title": "ไดเรกทอรี LanceDB", + "settings.indexing.lancedbDirectory.description": "ไดเรกทอรีเสริมสำหรับที่เก็บ LanceDB ในเครื่อง", + "settings.indexing.lancedbDirectory.placeholder": "ปล่อยว่างสำหรับค่าเริ่มต้น", + "settings.indexing.qdrantUrl.title": "URL Qdrant", + "settings.indexing.qdrantUrl.description": "URL เซิร์ฟเวอร์สำหรับอินสแตนซ์ Qdrant", + "settings.indexing.qdrantApiKey.title": "คีย์ API Qdrant", + "settings.indexing.qdrantApiKey.description": "คีย์ API เสริมสำหรับอินสแตนซ์ Qdrant", + "settings.indexing.qdrantApiKey.placeholder": "คีย์ API เสริม", + "settings.indexing.providerField.description": "การตั้งค่าการเชื่อมต่อเฉพาะผู้ให้บริการ", + "settings.indexing.status.title": "สถานะ", + "settings.indexing.tuning.description": "พารามิเตอร์การค้นหาขั้นสูงและการประมวลผลแบทช์", + "settings.shortcuts.title": "ทางลัดแป้นพิมพ์", "settings.shortcuts.reset.button": "รีเซ็ตเป็นค่าเริ่มต้น", "settings.shortcuts.reset.toast.title": "รีเซ็ตทางลัดแล้ว", @@ -737,10 +792,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "สภาพแวดล้อม", "settings.providers.tag.config": "กำหนดค่า", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "กำหนดเอง", "settings.providers.tag.other": "อื่น ๆ", "settings.providers.tag.customProvider": "ผู้ให้บริการที่กำหนดเอง", "settings.providers.connected.environmentDescription": "เชื่อมต่อจากตัวแปรสภาพแวดล้อมของคุณ", + "settings.providers.action.signInChatGPT": "ลงชื่อเข้าใช้ด้วย ChatGPT", "settings.providers.custom.description": "เพิ่มผู้ให้บริการที่เข้ากันได้กับ OpenAI ด้วย URL พื้นฐาน", "settings.providers.modeModels": "โมเดลต่อโหมด", "settings.providers.custom.note": "เพิ่มผู้ให้บริการที่รองรับ OpenAI ด้วย Base URL", @@ -824,6 +881,30 @@ export const dict = { "provider.custom.error.baseURL.format": "ต้องขึ้นต้นด้วย http:// หรือ https://", "provider.custom.error.required": "จำเป็น", "provider.custom.error.duplicate": "ซ้ำ", + "settings.openLocalConfig": "คอนฟิก Local", + "settings.openGlobalConfig": "คอนฟิก Global", + "settings.config.scope.local": "ภายใน", + "settings.config.scope.global": "ทั่วโลก", + "settings.config.status.loaded": "โหลดแล้ว", + "settings.config.status.loadedLegacy": "โหลดการตั้งค่าแบบเก่าแล้ว", + "settings.config.status.notLoaded": "ยังไม่ได้โหลด", + "settings.config.status.create": "ไม่พบ - สร้างไฟล์นี้", + "settings.config.title": "เปิดไฟล์การตั้งค่า Kilo ({{scope}})", + "settings.config.placeholder": + "ไฟล์การตั้งค่าจะถูกผสานตามลำดับ ไฟล์ที่ถูกทำเครื่องหมายว่าโหลดแล้วจะมีผลกับการตั้งค่าในปัจจุบัน", + "settings.config.noWorkspace": "เปิดโฟลเดอร์พื้นที่ทำงานเพื่อแก้ไขไฟล์การตั้งค่า Kilo ภายใน", + "settings.config.openFailed": "ไม่สามารถเปิดไฟล์การตั้งค่า Kilo ({{scope}}): {{message}}", + "settings.config.source.xdg": "การตั้งค่า XDG ทั่วโลก", + "settings.config.source.homeKilo": "การตั้งค่า .kilo ของ Home", + "settings.config.source.homeKilocode": "การตั้งค่า .kilocode ของ Home", + "settings.config.source.homeOpencode": "การตั้งค่า .opencode ของ Home", + "settings.config.source.envFile": "ไฟล์สภาพแวดล้อม KILO_CONFIG", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "การตั้งค่าสภาพแวดล้อมแบบอินไลน์", + "settings.config.source.projectKilo": "การตั้งค่า .kilo ของโปรเจกต์", + "settings.config.source.projectRoot": "การตั้งค่ารูทของโปรเจกต์", + "settings.config.source.projectKilocode": "การตั้งค่า .kilocode แบบเก่า", + "settings.config.source.projectOpencode": "การตั้งค่า .opencode แบบเก่า", "settings.models.title": "โมเดล", "settings.models.description": "การตั้งค่าโมเดลจะสามารถกำหนดค่าได้ที่นี่", "settings.agents.title": "เอเจนต์", @@ -964,6 +1045,8 @@ export const dict = { "settings.checkpoints.title": "จุดตรวจสอบ", "settings.display.title": "การแสดงผล", "settings.autocomplete.title": "เติมข้อความอัตโนมัติ", + "settings.autocomplete.model.title": "โมเดล Autocomplete", + "settings.autocomplete.model.description": "เลือกโมเดลที่ใช้สำหรับการเติมโค้ดแบบอินไลน์ (inline completions)", "settings.notifications.title": "การแจ้งเตือน", "settings.context.title": "บริบท", @@ -1099,6 +1182,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "ไม่สรุปเนื้อหาที่วางขนาดใหญ่", "settings.experimental.batch.title": "เครื่องมือแบทช์", "settings.experimental.batch.description": "เปิดใช้งานการประมวลผลแบทช์ของการเรียกเครื่องมือ", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "ค้นหาโค้ดเบส", "settings.experimental.codebaseSearch.description": "เปิดใช้งานการค้นหาด้วยภาษาธรรมชาติโดย AI ทั่วทั้งโค้ดเบส", "settings.experimental.continueOnDeny.title": "ดำเนินต่อเมื่อถูกปฏิเสธ", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 3470e2c36b9..33f36896b69 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -261,6 +261,7 @@ export const dict = { "prompt.action.send.blocked": "Bekleyen soruyu önce yanıtlayın veya kapatın", "prompt.action.stop": "Durdur", "prompt.action.enhance": "Komutu geliştir", + "prompt.action.indexing": "İndeksleme ayarları", "prompt.action.resetModel": "Modeli varsayılana sıfırla", "prompt.action.enhanceDescription": "'Komutu Geliştir' düğmesi, ek bağlam, açıklama veya yeniden ifadelendirme sağlayarak komutunuzu iyileştirmeye yardımcı olur. Buraya bir komut yazıp düğmeye tekrar tıklayarak nasıl çalıştığını görebilirsiniz.", @@ -541,7 +542,38 @@ export const dict = { "session.new.worktree.mainWithBranch": "Ana dal ({{branch}})", "session.new.worktree.create": "Yeni çalışma ağacı oluştur", "session.new.lastModified": "Son değişiklik", - + "sidebar.session.newSession": "Yeni Oturum", + "sidebar.session.newSession.tooltip": "Mevcut oturumu koruyarak yeni bir konuşma başlatın.", + "sidebar.session.newSession.disabled": + "Bu oturum zaten yeni. Sohbet etmeye başlayın veya bunun yerine bir worktree oluşturun.", + "sidebar.session.newWorktree": "Yeni Worktree", + "sidebar.session.newWorktree.tooltip": + "Güvenli bir şekilde deneme yapmak, değişiklikleri ayrı tutmak ve mevcut dalınızı kesintiye uğratmadan paralel oturumlar çalıştırmak için izole bir git worktree oluşturun.", + "sidebar.session.configureWorktree.tooltip": + "Oluşturmadan önce yeni bir worktree yapılandırmak için Agent Manager worktree iletişim kutusunu açın.", + "sidebar.session.newWorktree.from": "Şuradan Yeni Worktree:", + "sidebar.session.currentBranch": "mevcut dal", + "sidebar.session.moveToWorktree": "Worktree'ye Taşı", + "sidebar.session.moveToWorktree.tooltip.empty": + "İzole bir takip çalışması için bu konuşmayı ve mevcut yerel değişikliklerinizi özel bir worktree'ye taşıyın.", + "sidebar.session.moveToWorktree.tooltip.one": + "Bu konuşmayı ve değiştirilen 1 dosyayı, izole edilmiş takip çalışmaları için özel bir worktree'ye taşıyın.", + "sidebar.session.moveToWorktree.tooltip.other": + "Bu konuşmayı ve değiştirilen {{files}} dosyayı, izole edilmiş takip çalışmaları için özel bir worktree'ye taşıyın.", + "sidebar.session.showChanges.tooltip.empty": "Mevcut çalışma ağacını incelemek için değişiklikler görünümünü açın.", + "sidebar.session.showChanges.tooltip.one": + "1 dosya değiştirildi · +{{additions}} -{{deletions}}. Değişiklikler görünümünü aç.", + "sidebar.session.showChanges.tooltip.other": + "{{files}} dosya değiştirildi · +{{additions}} -{{deletions}}. Değişiklikler görünümünü aç.", + "sidebar.session.agentManager.tooltip": + "Paralel oturumların ve worktree'lerin tam bir genel görünümü için Agent Manager'ı açın, böylece uzun süren görevleri tek bir yerden koordine edebilirsiniz.", + "sidebar.session.openAgentManager": "Agent Manager'ı Aç", + "sidebar.session.progress.capturing": "Değişiklikler yakalanıyor...", + "sidebar.session.progress.creating": "Worktree oluşturuluyor...", + "sidebar.session.progress.setup": "Kurulum çalıştırılıyor...", + "sidebar.session.progress.transferring": "Değişiklikler aktarılıyor...", + "sidebar.session.progress.forking": "Oturum başlatılıyor...", + "sidebar.session.progress.failed": "Worktree'de devam edilemedi", "session.header.search.placeholder": "{{project}} ara", "session.header.searchFiles": "Dosya ara", "session.header.openIn": "Aç", @@ -744,10 +776,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "Ortam", "settings.providers.tag.config": "Yapılandırma", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "Özel", "settings.providers.tag.customProvider": "Özel sağlayıcı", "settings.providers.tag.other": "Diğer", "settings.providers.connected.environmentDescription": "Ortam değişkenlerinizden bağlandı", + "settings.providers.action.signInChatGPT": "ChatGPT ile oturum aç", "settings.providers.custom.description": "Temel URL üzerinden OpenAI uyumlu bir sağlayıcı ekleyin.", "provider.custom.title": "Özel sağlayıcı", @@ -830,6 +864,30 @@ export const dict = { "provider.custom.error.baseURL.format": "http:// veya https:// ile başlamalı", "provider.custom.error.required": "Gerekli", "provider.custom.error.duplicate": "Tekrar", + "settings.openLocalConfig": "Yerel Config", + "settings.openGlobalConfig": "Global Config", + "settings.config.scope.local": "Yerel", + "settings.config.scope.global": "Küresel", + "settings.config.status.loaded": "yüklendi", + "settings.config.status.loadedLegacy": "eski yapılandırma yüklendi", + "settings.config.status.notLoaded": "yüklenmedi", + "settings.config.status.create": "bulunamadı - bu dosyayı oluştur", + "settings.config.title": "{{scope}} Kilo yapılandırma dosyasını aç", + "settings.config.placeholder": + "Yapılandırma dosyaları sırayla birleştirilir; yüklendi olarak işaretlenen dosyalar şu anda ayarları etkiler.", + "settings.config.noWorkspace": "Yerel Kilo yapılandırma dosyasını düzenlemek için bir çalışma alanı klasörü açın.", + "settings.config.openFailed": "{{scope}} Kilo yapılandırma dosyası açılamadı: {{message}}", + "settings.config.source.xdg": "XDG küresel yapılandırma", + "settings.config.source.homeKilo": "Home .kilo yapılandırması", + "settings.config.source.homeKilocode": "Home .kilocode yapılandırması", + "settings.config.source.homeOpencode": "Home .opencode yapılandırması", + "settings.config.source.envFile": "KILO_CONFIG ortam dosyası", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "Satır içi ortam yapılandırması", + "settings.config.source.projectKilo": "Proje .kilo yapılandırması", + "settings.config.source.projectRoot": "Proje kök yapılandırması", + "settings.config.source.projectKilocode": "Eski .kilocode yapılandırması", + "settings.config.source.projectOpencode": "Eski .opencode yapılandırması", "settings.models.title": "Modeller", "settings.models.description": "Model ayarları burada yapılandırılabilecek.", "settings.agents.title": "Ajanlar", @@ -971,8 +1029,34 @@ export const dict = { "settings.checkpoints.title": "Kontrol Noktaları", "settings.display.title": "Görünüm", "settings.autocomplete.title": "Otomatik Tamamlama", + "settings.autocomplete.model.title": "Otomatik tamamlama modeli", + "settings.autocomplete.model.description": "Satır içi (inline) kod tamamlamaları için kullanılacak modeli seçin", "settings.notifications.title": "Bildirimler", "settings.context.title": "Bağlam", + "settings.indexing.title": "İndeksleme", + "settings.indexing.status.title": "Durum", + "settings.indexing.enable.title": "İndekslemeyi etkinleştir", + "settings.indexing.enable.description": "Bu çalışma alanı için anlamsal kod tabanı indekslemeyi açın veya kapatın.", + "settings.indexing.provider.title": "Yerleştirme sağlayıcısı", + "settings.indexing.provider.description": + "Anlamsal arama için yerleştirmeleri oluşturmak amacıyla kullanılacak sağlayıcıyı seçin.", + "settings.indexing.model.title": "Yerleştirme modeli", + "settings.indexing.model.description": "Seçili sağlayıcı için varsayılan yerleştirme modelini geçersiz kılın.", + "settings.indexing.dimension.title": "Vektör boyutu", + "settings.indexing.dimension.description": "Modelden yerleştirme boyutunu otomatik algılamak için boş bırakın.", + "settings.indexing.dimension.placeholder": "Otomatik", + "settings.indexing.providerField.description": "Sağlayıcıya özel bağlantı ayarı.", + "settings.indexing.vectorStore.title": "Vektör deposu", + "settings.indexing.vectorStore.description": "İndekslenen yerleştirmelerin nerede saklanacağını seçin.", + "settings.indexing.lancedbDirectory.title": "LanceDB dizini", + "settings.indexing.lancedbDirectory.description": "Yerel LanceDB deposu için isteğe bağlı dizin.", + "settings.indexing.lancedbDirectory.placeholder": "Varsayılan için boş bırakın", + "settings.indexing.qdrantUrl.title": "Qdrant URL", + "settings.indexing.qdrantUrl.description": "Qdrant örneği için sunucu URL'si.", + "settings.indexing.qdrantApiKey.title": "Qdrant API anahtarı", + "settings.indexing.qdrantApiKey.description": "Qdrant örneği için isteğe bağlı API anahtarı.", + "settings.indexing.qdrantApiKey.placeholder": "İsteğe bağlı API anahtarı", + "settings.indexing.tuning.description": "Gelişmiş arama ve toplu işlem parametresi.", "settings.experimental.title": "Deneysel", "settings.language.title": "Dil", @@ -1112,6 +1196,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "Büyük yapıştırılan içeriği özetleme", "settings.experimental.batch.title": "Toplu Araç", "settings.experimental.batch.description": "Birden fazla araç çağrısının toplu işlenmesini etkinleştir", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Kod Tabanı Araması", "settings.experimental.codebaseSearch.description": "Kod tabanınız genelinde yapay zeka destekli doğal dil aramasını etkinleştir", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index b352336b62f..26c5349d13c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -262,6 +262,7 @@ export const dict = { "prompt.action.send.blocked": "Спочатку дайте відповідь або закрийте очікуюче питання", "prompt.action.stop": "Зупинити", "prompt.action.enhance": "Покращити запит", + "prompt.action.indexing": "Налаштування індексування", "prompt.action.resetModel": "Скинути модель до стандартної", "prompt.action.enhanceDescription": "Кнопка 'Покращити запит' допомагає вдосконалити ваш запит, надаючи додатковий контекст, уточнення або перефразування. Введіть запит тут і натисніть кнопку ще раз, щоб побачити, як це працює.", @@ -542,7 +543,36 @@ export const dict = { "session.new.worktree.mainWithBranch": "Основна гілка ({{branch}})", "session.new.worktree.create": "Створити нове робоче дерево", "session.new.lastModified": "Останнє змінення", - + "sidebar.session.newSession": "Нова сесія", + "sidebar.session.newSession.tooltip": "Почати нову розмову, зберігши поточну сесію незмінною.", + "sidebar.session.newSession.disabled": "Ця сесія вже нова. Почніть спілкуватися або створіть worktree.", + "sidebar.session.newWorktree": "Нове Worktree", + "sidebar.session.newWorktree.tooltip": + "Створіть ізольоване git worktree для безпечних експериментів, розділення змін та запуску паралельних сесій без переривання поточної гілки.", + "sidebar.session.configureWorktree.tooltip": + "Відкрийте діалогове вікно worktree в Agent Manager, щоб налаштувати нове worktree перед створенням.", + "sidebar.session.newWorktree.from": "Нове Worktree з", + "sidebar.session.currentBranch": "поточна гілка", + "sidebar.session.moveToWorktree": "Перенести у Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "Перенесіть цю розмову та ваші поточні локальні зміни до виділеного worktree для ізольованої подальшої роботи.", + "sidebar.session.moveToWorktree.tooltip.one": + "Перемістити цю розмову та 1 змінений файл у виділений worktree для ізольованої подальшої роботи.", + "sidebar.session.moveToWorktree.tooltip.other": + "Перемістити цю розмову та {{files}} змінених файлів у виділений worktree для ізольованої подальшої роботи.", + "sidebar.session.showChanges.tooltip.empty": "Відкрийте перегляд змін, щоб оглянути поточне робоче дерево.", + "sidebar.session.showChanges.tooltip.one": "1 файл змінено · +{{additions}} -{{deletions}}. Відкрити перегляд змін.", + "sidebar.session.showChanges.tooltip.other": + "{{files}} файлів змінено · +{{additions}} -{{deletions}}. Відкрити перегляд змін.", + "sidebar.session.agentManager.tooltip": + "Відкрийте Agent Manager для повного огляду паралельних сесій та worktree, щоб координивати тривалі завдання в одному місці.", + "sidebar.session.openAgentManager": "Відкрити Agent Manager", + "sidebar.session.progress.capturing": "Захоплення змін...", + "sidebar.session.progress.creating": "Створення worktree...", + "sidebar.session.progress.setup": "Виконання налаштування...", + "sidebar.session.progress.transferring": "Перенесення змін...", + "sidebar.session.progress.forking": "Запуск сесії...", + "sidebar.session.progress.failed": "Не вдалося продовжити у worktree", "session.header.search.placeholder": "Пошук {{project}}", "session.header.searchFiles": "Пошук файлів", "session.header.openIn": "Відкрити в", @@ -745,10 +775,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "Середовище", "settings.providers.tag.config": "Конфігурація", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "Власний", "settings.providers.tag.customProvider": "Власний провайдер", "settings.providers.tag.other": "Інші", "settings.providers.connected.environmentDescription": "Підключено зі змінних середовища", + "settings.providers.action.signInChatGPT": "Увійти через ChatGPT", "settings.providers.custom.description": "Додати OpenAI-сумісного провайдера через базовий URL.", "provider.custom.title": "Власний провайдер", @@ -830,6 +862,30 @@ export const dict = { "provider.custom.error.baseURL.format": "Має починатися з http:// або https://", "provider.custom.error.required": "Обов'язкове поле", "provider.custom.error.duplicate": "Дублікат", + "settings.openLocalConfig": "Локальний конфіг", + "settings.openGlobalConfig": "Глобальний конфіг", + "settings.config.scope.local": "Локальний", + "settings.config.scope.global": "Глобальний", + "settings.config.status.loaded": "завантажено", + "settings.config.status.loadedLegacy": "завантажено застарілу конфігурацію", + "settings.config.status.notLoaded": "не завантажено", + "settings.config.status.create": "не знайдено - створити цей файл", + "settings.config.title": "Відкрити файл конфігурації Kilo ({{scope}})", + "settings.config.placeholder": + "Файли конфігурації об'єднуються по порядку; файли, позначені як завантажені, наразі впливають на налаштування.", + "settings.config.noWorkspace": "Відкрийте папку робочої області, щоб відредагувати локальний файл конфігурації Kilo.", + "settings.config.openFailed": "Не вдалося відкрити файл конфігурації Kilo ({{scope}}): {{message}}", + "settings.config.source.xdg": "Глобальна конфігурація XDG", + "settings.config.source.homeKilo": "Конфігурація .kilo (Home)", + "settings.config.source.homeKilocode": "Конфігурація .kilocode (Home)", + "settings.config.source.homeOpencode": "Конфігурація .opencode (Home)", + "settings.config.source.envFile": "Файл середовища KILO_CONFIG", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "Вбудована конфігурація середовища", + "settings.config.source.projectKilo": "Конфігурація .kilo проєкту", + "settings.config.source.projectRoot": "Коренева конфігурація проєкту", + "settings.config.source.projectKilocode": "Застаріла конфігурація .kilocode", + "settings.config.source.projectOpencode": "Застаріла конфігурація .opencode", "settings.models.title": "Моделі", "settings.models.description": "Тут можна буде налаштовувати параметри моделей.", "settings.agents.title": "Агенти", @@ -972,8 +1028,35 @@ export const dict = { "settings.checkpoints.title": "Контрольні точки", "settings.display.title": "Відображення", "settings.autocomplete.title": "Автодоповнення", + "settings.autocomplete.model.title": "Модель автодоповнення", + "settings.autocomplete.model.description": "Виберіть модель для вбудованого (inline) автодоповнення коду", "settings.notifications.title": "Сповіщення", "settings.context.title": "Контекст", + "settings.indexing.title": "Індексування", + "settings.indexing.status.title": "Статус", + "settings.indexing.enable.title": "Увімкнути індексування", + "settings.indexing.enable.description": + "Увімкніть або вимкніть семантичне індексування кодової бази для цього робочого простору.", + "settings.indexing.provider.title": "Провайдер ембедингів", + "settings.indexing.provider.description": "Виберіть провайдера для генерації ембедингів для семантичного пошуку.", + "settings.indexing.model.title": "Модель ембедингів", + "settings.indexing.model.description": "Перевизначити типову модель ембедингів для вибраного провайдера.", + "settings.indexing.dimension.title": "Розмірність вектора", + "settings.indexing.dimension.description": + "Залиште порожнім, щоб автоматично визначити розмірність ембедингу з моделі.", + "settings.indexing.dimension.placeholder": "Авто", + "settings.indexing.providerField.description": "Налаштування підключення, специфічне для провайдера.", + "settings.indexing.vectorStore.title": "Векторне сховище", + "settings.indexing.vectorStore.description": "Виберіть, де зберігаються проіндексовані ембединги.", + "settings.indexing.lancedbDirectory.title": "Директорія LanceDB", + "settings.indexing.lancedbDirectory.description": "Необов'язкова директорія для локального сховища LanceDB.", + "settings.indexing.lancedbDirectory.placeholder": "Залиште порожнім для типового значення", + "settings.indexing.qdrantUrl.title": "URL-адреса Qdrant", + "settings.indexing.qdrantUrl.description": "URL-адреса сервера для екземпляра Qdrant.", + "settings.indexing.qdrantApiKey.title": "Ключ API Qdrant", + "settings.indexing.qdrantApiKey.description": "Необов'язковий ключ API для екземпляра Qdrant.", + "settings.indexing.qdrantApiKey.placeholder": "Необов'язковий ключ API", + "settings.indexing.tuning.description": "Розширений параметр пошуку та пакетної обробки.", "settings.experimental.title": "Експериментальне", "settings.language.title": "Мова", @@ -1115,6 +1198,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "Підсумовувати великий вставлений вміст", "settings.experimental.batch.title": "Пакетний інструмент", "settings.experimental.batch.description": "Увімкнути пакетну обробку кількох викликів інструментів", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "Пошук по кодовій базі", "settings.experimental.codebaseSearch.description": "Увімкнути пошук природною мовою на основі ШІ по всій кодовій базі", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index 0e1e39fe752..c57c6553376 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -255,6 +255,7 @@ export const dict = { "prompt.context.removeActiveFile": "从上下文移除活动文件", "prompt.context.removeFile": "从上下文移除文件", "prompt.action.attachFile": "附加文件", + "prompt.action.indexing": "索引设置", "prompt.attachment.remove": "移除附件", "prompt.action.send": "发送", "prompt.action.send.blocked": "请先回答或忽略待处理的问题", @@ -529,7 +530,35 @@ export const dict = { "session.new.worktree.mainWithBranch": "主分支({{branch}})", "session.new.worktree.create": "创建新的 worktree", "session.new.lastModified": "最后修改", - + "sidebar.session.newSession": "新会话", + "sidebar.session.newSession.tooltip": "在保持当前会话完整的同时开始全新的对话。", + "sidebar.session.newSession.disabled": "此会话已经是新的。请开始聊天或创建一个 worktree。", + "sidebar.session.newWorktree": "新 Worktree", + "sidebar.session.newWorktree.tooltip": + "创建一个隔离的 git worktree 以安全地进行实验、保持更改分离并在不中断当前分支的情况下运行并行会话。", + "sidebar.session.configureWorktree.tooltip": + "打开 Agent Manager 的 worktree 对话框,以便在创建之前配置新的 worktree。", + "sidebar.session.newWorktree.from": "新 Worktree 来源:", + "sidebar.session.currentBranch": "当前分支", + "sidebar.session.moveToWorktree": "移动到 Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "将此对话和您当前的本地更改移动到专用的 worktree 中,以便进行隔离的后续工作。", + "sidebar.session.moveToWorktree.tooltip.one": + "将此对话和 1 个更改的文件移动到专用的 worktree 中,以便进行隔离的后续工作。", + "sidebar.session.moveToWorktree.tooltip.other": + "将此对话和 {{files}} 个更改的文件移动到专用的 worktree 中,以便进行隔离的后续工作。", + "sidebar.session.showChanges.tooltip.empty": "打开更改视图以检查当前工作树。", + "sidebar.session.showChanges.tooltip.one": "1 个文件已更改 · +{{additions}} -{{deletions}}。打开更改视图。", + "sidebar.session.showChanges.tooltip.other": "{{files}} 个文件已更改 · +{{additions}} -{{deletions}}。打开更改视图。", + "sidebar.session.agentManager.tooltip": + "打开 Agent Manager 以全面了解并行会话和 worktree,让您可以在一个地方协调长期运行的任务。", + "sidebar.session.openAgentManager": "打开 Agent Manager", + "sidebar.session.progress.capturing": "正在捕获更改...", + "sidebar.session.progress.creating": "正在创建 worktree...", + "sidebar.session.progress.setup": "正在运行设置...", + "sidebar.session.progress.transferring": "正在传输更改...", + "sidebar.session.progress.forking": "正在启动会话...", + "sidebar.session.progress.failed": "在 worktree 中继续失败", "session.header.search.placeholder": "搜索 {{project}}", "session.header.searchFiles": "搜索文件", "session.header.openIn": "打开方式", @@ -699,6 +728,30 @@ export const dict = { "settings.general.sounds.errors.title": "错误", "settings.general.sounds.errors.description": "发生错误时播放声音", + "settings.indexing.title": "索引", + "settings.indexing.enable.title": "启用索引", + "settings.indexing.enable.description": "为此工作区打开或关闭语义代码库索引。", + "settings.indexing.provider.title": "嵌入提供商", + "settings.indexing.provider.description": "选择用于生成语义搜索嵌入的提供商。", + "settings.indexing.model.title": "嵌入模型", + "settings.indexing.model.description": "覆盖所选提供商的默认嵌入模型。", + "settings.indexing.vectorStore.title": "向量存储", + "settings.indexing.vectorStore.description": "选择索引嵌入的存储位置。", + "settings.indexing.lancedbDirectory.title": "LanceDB 目录", + "settings.indexing.lancedbDirectory.description": "本地 LanceDB 存储的可选目录。", + "settings.indexing.lancedbDirectory.placeholder": "留空使用默认值", + "settings.indexing.qdrantUrl.title": "Qdrant URL", + "settings.indexing.qdrantUrl.description": "Qdrant 实例的服务器 URL。", + "settings.indexing.qdrantApiKey.title": "Qdrant API 密钥", + "settings.indexing.qdrantApiKey.description": "Qdrant 实例的可选 API 密钥。", + "settings.indexing.qdrantApiKey.placeholder": "可选 API 密钥", + "settings.indexing.dimension.title": "向量维度", + "settings.indexing.dimension.description": "留空以从模型自动检测嵌入维度。", + "settings.indexing.dimension.placeholder": "自动", + "settings.indexing.tuning.description": "高级搜索和批处理参数。", + "settings.indexing.status.title": "状态", + "settings.indexing.providerField.description": "提供商特定的连接设置。", + "settings.shortcuts.title": "键盘快捷键", "settings.shortcuts.reset.button": "重置为默认值", "settings.shortcuts.reset.toast.title": "快捷键已重置", @@ -729,10 +782,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "环境", "settings.providers.tag.config": "配置", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "自定义", "settings.providers.tag.other": "其他", "settings.providers.tag.customProvider": "自定义提供商", "settings.providers.connected.environmentDescription": "从您的环境变量连接", + "settings.providers.action.signInChatGPT": "使用 ChatGPT 登录", "settings.providers.custom.description": "通过基础 URL 添加 OpenAI 兼容的提供商。", "settings.providers.modeModels": "按模式选择模型", "settings.providers.custom.note": "通过 Base URL 添加 OpenAI 兼容提供商。", @@ -814,6 +869,29 @@ export const dict = { "provider.custom.error.baseURL.format": "必须以 http:// 或 https:// 开头", "provider.custom.error.required": "必填", "provider.custom.error.duplicate": "重复", + "settings.openLocalConfig": "本地配置", + "settings.openGlobalConfig": "全局配置", + "settings.config.scope.local": "本地", + "settings.config.scope.global": "全局", + "settings.config.status.loaded": "已加载", + "settings.config.status.loadedLegacy": "已加载旧版配置", + "settings.config.status.notLoaded": "未加载", + "settings.config.status.create": "未找到 - 创建此文件", + "settings.config.title": "打开 {{scope}} Kilo 配置文件", + "settings.config.placeholder": "配置文件按顺序合并;标记为已加载的文件目前会影响设置。", + "settings.config.noWorkspace": "打开工作区文件夹以编辑本地 Kilo 配置文件。", + "settings.config.openFailed": "无法打开 {{scope}} Kilo 配置文件:{{message}}", + "settings.config.source.xdg": "XDG 全局配置", + "settings.config.source.homeKilo": "主目录 .kilo 配置", + "settings.config.source.homeKilocode": "主目录 .kilocode 配置", + "settings.config.source.homeOpencode": "主目录 .opencode 配置", + "settings.config.source.envFile": "KILO_CONFIG 环境变量文件", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "内联环境配置", + "settings.config.source.projectKilo": "项目 .kilo 配置", + "settings.config.source.projectRoot": "项目根目录配置", + "settings.config.source.projectKilocode": "旧版 .kilocode 配置", + "settings.config.source.projectOpencode": "旧版 .opencode 配置", "settings.models.title": "模型", "settings.models.description": "模型设置将在此处可配置。", "settings.agents.title": "智能体", @@ -954,6 +1032,8 @@ export const dict = { "settings.checkpoints.title": "检查点", "settings.display.title": "显示", "settings.autocomplete.title": "自动补全", + "settings.autocomplete.model.title": "自动补全模型", + "settings.autocomplete.model.description": "选择用于内联代码补全的模型", "settings.notifications.title": "通知", "settings.context.title": "上下文", @@ -1087,6 +1167,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "不对大量粘贴内容进行摘要", "settings.experimental.batch.title": "批量工具", "settings.experimental.batch.description": "启用多个工具调用的批处理", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "代码库搜索", "settings.experimental.codebaseSearch.description": "启用 AI 驱动的自然语言代码库搜索", "settings.experimental.continueOnDeny.title": "拒绝后继续", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index a281c20b0e8..06738e55c42 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -530,7 +530,35 @@ export const dict = { "session.new.worktree.mainWithBranch": "主分支 ({{branch}})", "session.new.worktree.create": "建立新的 worktree", "session.new.lastModified": "最後修改", - + "sidebar.session.newSession": "新會話", + "sidebar.session.newSession.tooltip": "在保持當前會話完整的同時開始全新的對話。", + "sidebar.session.newSession.disabled": "此會話已經是新的。請開始聊天或建立一個 worktree。", + "sidebar.session.newWorktree": "新 Worktree", + "sidebar.session.newWorktree.tooltip": + "建立一個隔離的 git worktree 以安全地進行實驗、保持變更分離並在不中斷當前分支的情況下執行平行會話。", + "sidebar.session.configureWorktree.tooltip": + "打開 Agent Manager 的 worktree 對話框,以便在建立之前配置新的 worktree。", + "sidebar.session.newWorktree.from": "新 Worktree 來源:", + "sidebar.session.currentBranch": "當前分支", + "sidebar.session.moveToWorktree": "移動到 Worktree", + "sidebar.session.moveToWorktree.tooltip.empty": + "將此對話和您當前的本地變更移動到專用的 worktree 中,以便進行隔離的後續工作。", + "sidebar.session.moveToWorktree.tooltip.one": + "將此對話和 1 個變更的檔案移動到專用的 worktree 中,以便進行隔離的後續工作。", + "sidebar.session.moveToWorktree.tooltip.other": + "將此對話和 {{files}} 個變更的檔案移動到專用的 worktree 中,以便進行隔離的後續工作。", + "sidebar.session.showChanges.tooltip.empty": "打開變更檢視以檢查當前工作樹。", + "sidebar.session.showChanges.tooltip.one": "1 個檔案已變更 · +{{additions}} -{{deletions}}。開啟變更檢視。", + "sidebar.session.showChanges.tooltip.other": "{{files}} 個檔案已變更 · +{{additions}} -{{deletions}}。開啟變更檢視。", + "sidebar.session.agentManager.tooltip": + "打開 Agent Manager 以全面了解平行會話和 worktree,讓您可以在一個地方協調長期執行的任務。", + "sidebar.session.openAgentManager": "打開 Agent Manager", + "sidebar.session.progress.capturing": "正在捕獲變更...", + "sidebar.session.progress.creating": "正在建立 worktree...", + "sidebar.session.progress.setup": "正在執行設定...", + "sidebar.session.progress.transferring": "正在傳輸變更...", + "sidebar.session.progress.forking": "正在啟動會話...", + "sidebar.session.progress.failed": "在 worktree 中繼續失敗", "session.header.search.placeholder": "搜尋 {{project}}", "session.header.searchFiles": "搜尋檔案", "session.header.openIn": "開啟方式", @@ -731,10 +759,12 @@ export const dict = { "settings.providers.tag.gateway": "Gateway", "settings.providers.tag.environment": "環境", "settings.providers.tag.config": "設定", + "settings.providers.tag.chatgpt": "ChatGPT", "settings.providers.tag.custom": "自訂", "settings.providers.tag.other": "其他", "settings.providers.tag.customProvider": "自訂提供商", "settings.providers.connected.environmentDescription": "從您的環境變數連線", + "settings.providers.action.signInChatGPT": "使用 ChatGPT 登入", "settings.providers.custom.description": "透過基礎 URL 新增 OpenAI 相容的提供商。", "settings.providers.modeModels": "按模式選擇模型", "settings.providers.custom.note": "透過 Base URL 新增 OpenAI 相容供應商。", @@ -816,6 +846,29 @@ export const dict = { "provider.custom.error.baseURL.format": "必須以 http:// 或 https:// 開頭", "provider.custom.error.required": "必填", "provider.custom.error.duplicate": "重複", + "settings.openLocalConfig": "本機設定", + "settings.openGlobalConfig": "全域設定", + "settings.config.scope.local": "本地", + "settings.config.scope.global": "全域", + "settings.config.status.loaded": "已載入", + "settings.config.status.loadedLegacy": "已載入舊版設定", + "settings.config.status.notLoaded": "未載入", + "settings.config.status.create": "找不到 - 建立此檔案", + "settings.config.title": "開啟 {{scope}} Kilo 設定檔", + "settings.config.placeholder": "設定檔會按順序合併;標記為已載入的檔案目前會影響設定。", + "settings.config.noWorkspace": "開啟工作區資料夾以編輯本地 Kilo 設定檔。", + "settings.config.openFailed": "無法開啟 {{scope}} Kilo 設定檔:{{message}}", + "settings.config.source.xdg": "XDG 全域設定", + "settings.config.source.homeKilo": "主目錄 .kilo 設定", + "settings.config.source.homeKilocode": "主目錄 .kilocode 設定", + "settings.config.source.homeOpencode": "主目錄 .opencode 設定", + "settings.config.source.envFile": "KILO_CONFIG 環境變數檔案", + "settings.config.source.envDir": "KILO_CONFIG_DIR", + "settings.config.source.envContent": "內聯環境設定", + "settings.config.source.projectKilo": "專案 .kilo 設定", + "settings.config.source.projectRoot": "專案根目錄設定", + "settings.config.source.projectKilocode": "舊版 .kilocode 設定", + "settings.config.source.projectOpencode": "舊版 .opencode 設定", "settings.models.title": "模型", "settings.models.description": "可在此調整模型設定。", "settings.agents.title": "代理程式", @@ -956,6 +1009,8 @@ export const dict = { "settings.checkpoints.title": "檢查點", "settings.display.title": "顯示", "settings.autocomplete.title": "自動完成", + "settings.autocomplete.model.title": "自動補全模型", + "settings.autocomplete.model.description": "選擇用於內聯程式碼補全的模型", "settings.notifications.title": "通知", "settings.context.title": "上下文", @@ -1089,6 +1144,9 @@ export const dict = { "settings.experimental.pasteSummary.description": "不對大量貼上內容進行摘要", "settings.experimental.batch.title": "批次工具", "settings.experimental.batch.description": "啟用多個工具呼叫的批次處理", + "settings.experimental.semanticIndexing.title": "Semantic Indexing", + "settings.experimental.semanticIndexing.description": + "Enable semantic codebase indexing and the semantic_search tool. Requires indexing configuration.", "settings.experimental.codebaseSearch.title": "程式碼庫搜尋", "settings.experimental.codebaseSearch.description": "啟用 AI 驅動的自然語言程式碼庫搜尋", "settings.experimental.continueOnDeny.title": "拒絕後繼續", @@ -1277,6 +1335,33 @@ export const dict = { "dialog.model.notSet": "未設定", "profile.personalAccount": "個人帳戶", "profile.switchingAccount": "正在切換帳戶…", + + "prompt.action.indexing": "索引設定", + + "settings.indexing.dimension.description": "留空以自動從模型偵測嵌入維度。", + "settings.indexing.dimension.placeholder": "自動", + "settings.indexing.dimension.title": "向量維度", + "settings.indexing.enable.description": "為此工作區開啟或關閉語意程式碼庫索引。", + "settings.indexing.enable.title": "啟用索引", + "settings.indexing.lancedbDirectory.description": "本機 LanceDB 儲存的可選目錄。", + "settings.indexing.lancedbDirectory.placeholder": "留空使用預設值", + "settings.indexing.lancedbDirectory.title": "LanceDB 目錄", + "settings.indexing.model.description": "覆寫所選供應商的預設嵌入模型。", + "settings.indexing.model.title": "嵌入模型", + "settings.indexing.provider.description": "選擇用於生成語意搜尋嵌入的供應商。", + "settings.indexing.provider.title": "嵌入供應商", + "settings.indexing.providerField.description": "供應商特定的連線設定。", + "settings.indexing.qdrantApiKey.description": "Qdrant 執行個體的可選 API 金鑰。", + "settings.indexing.qdrantApiKey.placeholder": "可選 API 金鑰", + "settings.indexing.qdrantApiKey.title": "Qdrant API 金鑰", + "settings.indexing.qdrantUrl.description": "Qdrant 執行個體的伺服器 URL。", + "settings.indexing.qdrantUrl.title": "Qdrant URL", + "settings.indexing.status.title": "狀態", + "settings.indexing.title": "索引", + "settings.indexing.tuning.description": "進階搜尋和批次處理參數。", + "settings.indexing.vectorStore.description": "選擇索引嵌入的儲存位置。", + "settings.indexing.vectorStore.title": "向量儲存", + // Agent Manager strings live in webview-ui/agent-manager/i18n/zht.ts "question.summary": "第 {{n}} / {{total}} 個問題", diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index b065fbfa902..df66ed6aded 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -6,10 +6,11 @@ * chain (which requires a real extension host / SSE connection), we provide mock * context values directly. Where a real provider is safe to instantiate without an * extension host (VSCodeProvider, ServerProvider, ProviderProvider), we use the real - * thing so components that call useVSCode()/useServer()/useProvider() don't throw. + * thing so components that call useVSCode()/useServer()/useProvider()/useIndexing() + * don't throw. */ -import { createSignal, type ParentComponent } from "solid-js" +import { createSignal, createMemo, type ParentComponent } from "solid-js" import { VSCodeProvider } from "../context/vscode" import { ServerProvider } from "../context/server" import { ProviderContext } from "../context/provider" @@ -28,10 +29,12 @@ import { File } from "@kilocode/kilo-ui/file" import { SessionContext } from "../context/session" import { NotificationsContext } from "../context/notifications" import { LanguageContext } from "../context/language" +import { IndexingProvider } from "../context/indexing" import { dict as uiEn } from "@kilocode/kilo-ui/i18n/en" import { dict as appEn } from "../i18n/en" import { dict as amEn } from "../../agent-manager/i18n/en" import { dict as kiloEn } from "@kilocode/kilo-i18n/en" +import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect" import { resolveTemplate } from "../context/language-utils" import type { Config, @@ -41,6 +44,8 @@ import type { SuggestionRequest, } from "../types/messages" +type PluginSpec = string | [string, Record] + // Merged English dictionary (same merge order as the real LanguageProvider) const dict: Record = { ...appEn, ...amEn, ...uiEn, ...kiloEn } @@ -123,6 +128,23 @@ function mockNotificationsValue(items: KilocodeNotification[] = []) { // Mock SessionContext value — only the subset used by components // --------------------------------------------------------------------------- +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function merge(target: Record, source: Record) { + const result: Record = { ...target } + for (const [key, value] of Object.entries(source)) { + const prev = result[key] + if (isRecord(value) && isRecord(prev)) { + result[key] = merge(prev, value) + continue + } + result[key] = value + } + return result +} + export function mockSessionValue(overrides?: { id?: string permissions?: PermissionRequest[] @@ -237,20 +259,39 @@ interface StoryProvidersProps { sessionID?: string /** When provided, injects a mock ConfigContext with this config instead of the real ConfigProvider. */ config?: Config + onConfigChange?: (config: Config) => void /** When true, renders children without the default 12px padding wrapper */ noPadding?: boolean } /** Wraps children with either a mock ConfigContext (when config prop is given) or the real ConfigProvider. */ -const ConfigWrapper: ParentComponent<{ config?: Config }> = (props) => { +const ConfigWrapper: ParentComponent<{ config?: Config; onConfigChange?: (config: Config) => void }> = (props) => { if (props.config) { + const [cfg, setCfg] = createSignal(props.config) + const features = createMemo(() => { + const config = cfg() as Config & { + plugin?: readonly PluginSpec[] | null + } + + return { + indexing: hasIndexingPlugin(config.plugin ?? []) && config.experimental?.semantic_indexing === true, + } + }) + const value = { - config: () => props.config!, + config: createMemo(() => cfg()), + features, loading: () => false, isDirty: () => false, saving: () => false, saveError: () => null, - updateConfig: noop, + updateConfig: (partial: Partial) => { + setCfg((prev) => { + const next = merge(prev as Record, partial as Record) as Config + props.onConfigChange?.(next) + return next + }) + }, saveConfig: noop, discardConfig: noop, } @@ -274,7 +315,7 @@ export const StoryProviders: ParentComponent = (props) => { return ( - + = (props) => { "en", t }}> - - - - - - {props.noPadding ? ( - props.children - ) : ( -
{props.children}
- )} -
-
-
-
-
+ + + + + + + {props.noPadding ? ( + props.children + ) : ( +
{props.children}
+ )} +
+
+
+
+
+
diff --git a/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx index 77db67c4e78..412f817e285 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/settings.stories.tsx @@ -3,7 +3,7 @@ * Stories for Settings and ProvidersTab components. */ -import { onMount } from "solid-js" +import { onMount, createSignal } from "solid-js" import type { Meta, StoryObj } from "storybook-solidjs-vite" import { StoryProviders, mockSessionValue } from "./StoryProviders" import { SessionContext } from "../context/session" @@ -12,7 +12,8 @@ import ProvidersTab from "../components/settings/ProvidersTab" import AgentBehaviourTab from "../components/settings/AgentBehaviourTab" import ModeEditView from "../components/settings/ModeEditView" import McpEditView from "../components/settings/McpEditView" -import type { AgentConfig, CommandConfig } from "../types/messages" +import type { AgentConfig, CommandConfig, Config } from "../types/messages" +import IndexingTab from "../components/settings/IndexingTab" const meta: Meta = { title: "Settings", @@ -320,3 +321,33 @@ export const ModeEditExport: Story = { ) }, } + +export const IndexingProviderBlurRace: Story = { + name: "IndexingTab", + render: () => { + const [saved, setSaved] = createSignal>({}) + const cfg: Config = { + experimental: { + semantic_indexing: true, + }, + indexing: { + provider: "openai", + openai: { apiKey: "" }, + gemini: { apiKey: "" }, + }, + } + return ( + <> + setSaved((next.indexing ?? {}) as Record)} + > +
+ +
+
+
{JSON.stringify(saved(), null, 2)}
+ + ) + }, +} diff --git a/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css b/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css index 7638e6adfc5..ec9ec1a427f 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css +++ b/packages/kilo-vscode/webview-ui/src/styles/prompt-input.css @@ -515,6 +515,30 @@ } } +.prompt-indexing-button { + position: relative; + + [data-slot="icon-svg"] { + color: currentColor; + } +} + +.prompt-indexing-button--muted { + color: var(--vscode-descriptionForeground); +} + +.prompt-indexing-button--warning { + color: var(--vscode-charts-yellow, #cca700); +} + +.prompt-indexing-button--success { + color: var(--vscode-testing-iconPassed, #73c991); +} + +.prompt-indexing-button--error { + color: var(--vscode-errorForeground, #f48771); +} + /* ============================================ Enhance Prompt Spinner ============================================ */ diff --git a/packages/kilo-vscode/webview-ui/src/styles/session-actions.css b/packages/kilo-vscode/webview-ui/src/styles/session-actions.css index 33c227d0ea2..e027aa6d406 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/session-actions.css +++ b/packages/kilo-vscode/webview-ui/src/styles/session-actions.css @@ -1,37 +1,280 @@ /* New Task Button */ .new-task-button-wrapper { - padding: 8px 12px 0; + padding: 6px 12px; container-type: inline-size; } +.new-task-button-wrapper--empty { + width: 100%; +} + +.chat-input > .new-task-button-wrapper + .prompt-input-container { + margin-top: 0; +} + +:root { + --session-action-radius: 6px; +} + .session-actions-row { display: flex; gap: 4px; align-items: center; + flex-wrap: nowrap; + min-height: 22px; + justify-content: flex-start; } -/* All direct children share extra space equally, but start from their content size */ .session-actions-row > * { - flex: 1 1 auto; + flex: 0 0 auto; min-width: 0; overflow: hidden; } -/* Tooltip wrappers fill their flex slot, buttons fill the tooltip wrapper */ .session-actions-row > [data-component="tooltip-trigger"] { display: flex; + flex: 0 0 auto; +} + +.session-actions-row > .session-move-changes-trigger { + margin-left: auto; } .session-actions-row > [data-component="tooltip-trigger"] > [data-component="button"] { - flex: 1; + flex: 0 0 auto; + border-radius: var(--session-action-radius); + min-height: 22px; + height: auto; + padding: 4px 6px !important; + background: transparent; + border: none; + box-shadow: none; + font-family: var(--font-family-sans); + font-size: 12px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: normal; + letter-spacing: var(--letter-spacing-normal); + color: var(--vscode-descriptionForeground); + transition: + background-color 150ms ease, + color 150ms ease; } -/* Diff wrapper also stretches evenly (same flex as buttons) */ -.session-diff-wrapper { - display: flex !important; +.session-actions-row > [data-component="tooltip-trigger"] > [data-component="button"]:hover:not(:disabled), +.session-actions-row > [data-component="tooltip-trigger"] > [data-component="button"]:focus-visible:not(:disabled), +.session-actions-row > [data-component="tooltip-trigger"] > [data-component="button"]:active:not(:disabled) { + background: var(--surface-interactive-hover, var(--vscode-list-hoverBackground)); + color: var(--vscode-foreground); } -/* Diff stats badge — fills its flex slot, content centered */ +.session-actions-row > [data-component="tooltip-trigger"] > [data-component="button"].session-new-button:disabled { + cursor: default; +} + +.session-worktree-split { + display: flex; + flex: 0 0 auto; + min-width: 0; + align-items: stretch; + gap: 2px; + border-radius: var(--session-action-radius); + box-shadow: none; + overflow: hidden; + color: var(--vscode-descriptionForeground); + transition: + background-color 150ms ease, + color 150ms ease; +} + +.session-worktree-split:hover, +.session-worktree-split:has(.session-worktree-main:focus-visible), +.session-worktree-split:has(.session-worktree-split-arrow:focus-visible), +.session-worktree-split:has(.session-worktree-split-arrow[data-expanded]) { + color: var(--vscode-foreground); +} + +.session-worktree-split:hover > [data-component="tooltip-trigger"] > .session-worktree-main, +.session-worktree-split:hover .session-worktree-split-arrow, +.session-worktree-split:has(.session-worktree-main:focus-visible) + > [data-component="tooltip-trigger"] + > .session-worktree-main, +.session-worktree-split:has(.session-worktree-main:focus-visible) .session-worktree-split-arrow, +.session-worktree-split:has(.session-worktree-split-arrow:focus-visible) + > [data-component="tooltip-trigger"] + > .session-worktree-main, +.session-worktree-split:has(.session-worktree-split-arrow:focus-visible) .session-worktree-split-arrow, +.session-worktree-split:has(.session-worktree-split-arrow[data-expanded]) + > [data-component="tooltip-trigger"] + > .session-worktree-main, +.session-worktree-split:has(.session-worktree-split-arrow[data-expanded]) .session-worktree-split-arrow { + background: var(--surface-interactive-hover, var(--vscode-list-hoverBackground)); +} + +.session-worktree-split > [data-component="tooltip-trigger"] { + display: flex; + flex: 0 0 auto; +} + +.session-worktree-split > [data-component="tooltip-trigger"] > [data-component="button"] { + flex: 0 0 auto; + border-radius: var(--session-action-radius) 0 0 var(--session-action-radius); + background: transparent; + box-shadow: none; + color: inherit; + padding: 4px 6px !important; +} + +.session-worktree-split > [data-component="tooltip-trigger"] > [data-component="button"]:hover:not(:disabled), +.session-worktree-split > [data-component="tooltip-trigger"] > [data-component="button"]:focus-visible:not(:disabled), +.session-worktree-split > [data-component="tooltip-trigger"] > [data-component="button"]:active:not(:disabled) { + background: var(--surface-base-hover); + box-shadow: none; +} + +.session-worktree-split-arrow { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 12px; + padding: 0 3px; + border: none; + border-radius: 0 var(--session-action-radius) var(--session-action-radius) 0; + background: transparent; + color: inherit; + cursor: pointer; +} + +.session-worktree-split-arrow:focus-visible { + outline: none; + background: inherit; +} + +.session-worktree-split > [data-component="tooltip-trigger"] > [data-component="button"], +.session-worktree-split-arrow { + height: 24px; +} + +.session-worktree-main { + padding-right: 6px !important; +} + +.session-worktree-split-menu { + min-width: 250px; +} + +.session-worktree-split-menu [data-slot="dropdown-menu-item"] { + padding: 6px 10px; +} + +.session-worktree-menu-gap { + display: inline-flex; + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.session-worktree-menu-label { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + +.session-worktree-menu-branch { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; +} + +.session-worktree-menu-branch strong { + font-weight: 600; +} + +.session-move-action { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + gap: 5px; + border-radius: var(--session-action-radius); + color: var(--vscode-descriptionForeground); + padding: 4px 6px !important; + background: transparent; + box-shadow: none; + transition: + background-color 150ms ease, + color 150ms ease; +} + +.session-move-action:hover:not(:disabled), +.session-move-action:focus-visible:not(:disabled), +.session-move-action:active:not(:disabled) { + color: var(--vscode-foreground); + background: var(--surface-interactive-hover, var(--vscode-list-hoverBackground)); + box-shadow: none; +} + +.session-move-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-move-action [data-component="icon"], +.session-move-changes [data-component="icon"] { + opacity: 0.7; +} + +.session-move-changes { + display: inline-flex; + align-items: center; + gap: 3px; + flex: 0 0 auto; + overflow: hidden; + position: relative; + border-radius: var(--session-action-radius); + color: var(--vscode-descriptionForeground); + padding: 4px 6px !important; + background: transparent; + box-shadow: none; + transition: + background-color 150ms ease, + color 150ms ease; +} + +.session-move-changes:hover:not(:disabled), +.session-move-changes:focus-visible:not(:disabled), +.session-move-changes:active:not(:disabled) { + color: var(--vscode-foreground); + background: var(--surface-interactive-hover, var(--vscode-list-hoverBackground)); + box-shadow: none; +} + +.session-move-changes { + font-family: var(--font-family-sans); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.session-move-changes--empty { + opacity: 0.7; +} + +.session-move-dot { + position: absolute; + top: 4px; + right: 5px; + width: 5px; + height: 5px; + border-radius: 999px; + background: #34d399; + opacity: 0; +} + +/* Diff stats badge styles retained for the inline move button stats */ .session-diff-badge { display: flex; flex: 1; @@ -73,27 +316,56 @@ color: #f87171; } -/* Collapse diff stats to icon-only when truly narrow */ -@container (max-width: 300px) { - .session-diff-add, - .session-diff-del { - display: none; +.session-changes-tooltip { + display: inline-flex; + align-items: baseline; + flex-wrap: wrap; + column-gap: 4px; + row-gap: 2px; +} + +.session-changes-tooltip-separator { + color: var(--vscode-descriptionForeground); +} + +/* Collapse diff stats to icon-only before the row wraps. */ +@container (max-width: 420px) { + .session-actions-row { + gap: 4px; + flex-wrap: wrap; } - .session-diff-badge { - padding: 4px 6px; + .session-actions-row > *, + .session-worktree-split { + flex-basis: auto; + } + + .session-move-action { + padding-right: 5px !important; + } + + .session-move-changes { + gap: 0; + width: 26px; + min-width: 26px; + padding-left: 5px !important; + padding-right: 5px !important; + } + + .session-diff-add, + .session-diff-del { + width: 0; + opacity: 0; overflow: hidden; } - /* Dot indicator on the icon when collapsed and there are changes */ - .session-diff-badge--has-changes [data-component="icon"]::after { - content: ""; - position: absolute; - top: -2px; - right: -2px; - width: 6px; - height: 6px; - border-radius: 50%; - background: #34d399; + .session-move-dot { + opacity: 1; + } +} + +@container (max-width: 420px) { + .session-actions-row > .session-move-changes-trigger { + margin-left: 0; } } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index 7fb3b1fec17..3b77f6769b0 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -2,6 +2,8 @@ import type { PermissionConfig } from "./permissions" import type { AgentConfig } from "./agents" import type { ProviderConfig } from "./providers" +type SdkIndexingStatus = import("@kilocode/sdk/v2/client").IndexingStatus + export interface McpConfig { type?: "local" | "remote" command?: string[] | string @@ -37,6 +39,7 @@ export interface WatcherConfig { export interface ExperimentalConfig { disable_paste_summary?: boolean batch_tool?: boolean + semantic_indexing?: boolean codebase_search?: boolean primary_tools?: string[] continue_loop_on_deny?: boolean @@ -47,6 +50,42 @@ export interface CommitMessageConfig { prompt?: string } +export type IndexingProvider = + | "openai" + | "ollama" + | "openai-compatible" + | "gemini" + | "mistral" + | "vercel-ai-gateway" + | "bedrock" + | "openrouter" + | "voyage" + +export interface IndexingConfig { + enabled?: boolean + provider?: IndexingProvider + model?: string + dimension?: number + vectorStore?: "lancedb" | "qdrant" + openai?: { apiKey?: string } + ollama?: { baseUrl?: string } + "openai-compatible"?: { baseUrl?: string; apiKey?: string } + gemini?: { apiKey?: string } + mistral?: { apiKey?: string } + "vercel-ai-gateway"?: { apiKey?: string } + bedrock?: { region?: string; profile?: string } + openrouter?: { apiKey?: string; specificProvider?: string } + voyage?: { apiKey?: string } + qdrant?: { url?: string; apiKey?: string } + lancedb?: { directory?: string } + searchMinScore?: number + searchMaxResults?: number + embeddingBatchSize?: number + scannerMaxBatchRetries?: number +} + +export type IndexingStatus = SdkIndexingStatus + export interface BrowserSettings { enabled: boolean useSystemChrome: boolean @@ -78,4 +117,9 @@ export interface Config { tools?: Record layout?: "auto" | "stretch" experimental?: ExperimentalConfig + indexing?: IndexingConfig +} + +export interface FeatureFlags { + indexing: boolean } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts index 01999e7e554..affd2d8b55c 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/extension-messages.ts @@ -9,7 +9,7 @@ import type { PermissionRequest } from "./permissions" import type { QuestionRequest, SuggestionRequest, TodoItem } from "./questions" import type { ModelSelection, Provider, ProviderAuthState } from "./providers" import type { AgentInfo, SkillInfo, SlashCommandInfo } from "./agents" -import type { BrowserSettings, Config } from "./config" +import type { BrowserSettings, Config, FeatureFlags, IndexingStatus } from "./config" import type { KilocodeNotification, ProfileData } from "./profile" import type { AgentManagerApplyWorktreeDiffConflict, @@ -270,6 +270,11 @@ export interface NavigateMessage { tab?: string } +export interface IndexingStatusLoadedMessage { + type: "indexingStatusLoaded" + status: IndexingStatus +} + export interface ProvidersLoadedMessage { type: "providersLoaded" providers: Record @@ -303,6 +308,7 @@ export interface AutocompleteSettingsLoadedMessage { enableAutoTrigger: boolean enableSmartInlineTaskKeybinding: boolean enableChatAutocomplete: boolean + model: string } } @@ -394,11 +400,13 @@ export interface ClaudeCompatSettingLoadedMessage { export interface ConfigLoadedMessage { type: "configLoaded" config: Config + features: FeatureFlags } export interface ConfigUpdatedMessage { type: "configUpdated" config: Config + features: FeatureFlags } export interface ConfigUpdateFailedMessage { @@ -827,6 +835,7 @@ export type ExtensionMessage = | DeviceAuthFailedMessage | DeviceAuthCancelledMessage | NavigateMessage + | IndexingStatusLoadedMessage | ProvidersLoadedMessage | AgentsLoadedMessage | SkillsLoadedMessage diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index 6d67b01ab8d..de4a705b9e0 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -163,10 +163,45 @@ export interface OpenVSCodeSettingsRequest { query: string } +export interface OpenConfigFileRequest { + type: "openConfigFile" + scope: "local" | "global" + labels: { + scope: string + statusLoaded: string + statusLoadedLegacy: string + statusNotLoaded: string + statusCreate: string + title: string + placeholder: string + noWorkspace: string + openFailed: string + sourceXdg: string + sourceHomeKilo: string + sourceHomeKilocode: string + sourceHomeOpencode: string + sourceEnvFile: string + sourceEnvDir: string + sourceEnvContent: string + sourceProjectKilo: string + sourceProjectRoot: string + sourceProjectKilocode: string + sourceProjectOpencode: string + } +} + export interface OpenMarketplacePanelRequest { type: "openMarketplacePanel" } +export interface OpenAgentManagerRequest { + type: "openAgentManager" +} + +export interface OpenAdvancedWorktreeRequest { + type: "openAdvancedWorktree" +} + export interface RequestAgentsMessage { type: "requestAgents" } @@ -270,8 +305,8 @@ export interface RequestAutocompleteSettingsMessage { export interface UpdateAutocompleteSettingMessage { type: "updateAutocompleteSetting" - key: "enableAutoTrigger" | "enableSmartInlineTaskKeybinding" | "enableChatAutocomplete" - value: boolean + key: "enableAutoTrigger" | "enableSmartInlineTaskKeybinding" | "enableChatAutocomplete" | "model" + value: boolean | string } export interface RequestChatCompletionMessage { @@ -330,6 +365,15 @@ export interface RequestGlobalConfigMessage { type: "requestGlobalConfig" } +export interface RequestIndexingStatusMessage { + type: "requestIndexingStatus" +} + +export interface OpenSettingsTabRequest { + type: "openSettingsTab" + tab: string +} + export interface UpdateConfigMessage { type: "updateConfig" config: Partial @@ -687,6 +731,7 @@ export interface OpenChangesRequest { export interface OpenDiffVirtualRequest { type: "openDiffVirtual" diff: PermissionFileDiff + initialDiffStyle: "unified" | "split" } export interface RetryConnectionRequest { @@ -901,7 +946,10 @@ export type WebviewMessage = | OpenExternalRequest | OpenSettingsPanelRequest | OpenVSCodeSettingsRequest + | OpenConfigFileRequest | OpenMarketplacePanelRequest + | OpenAgentManagerRequest + | OpenAdvancedWorktreeRequest | OpenFileRequest | CancelLoginRequest | SetOrganizationRequest @@ -938,7 +986,9 @@ export type WebviewMessage = | RequestClaudeCompatSettingMessage | RequestConfigMessage | RequestGlobalConfigMessage + | RequestIndexingStatusMessage | UpdateConfigMessage + | OpenSettingsTabRequest | RequestNotificationSettingsMessage | ResetAllSettingsRequest | SettingsTabChangedMessage diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 0142a44b2c3..459edc7ed5e 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,25 @@ # @kilocode/cli +## 7.2.26 + +### Patch Changes + +- [#9549](https://github.com/Kilo-Org/kilocode/pull/9549) [`a5bca01`](https://github.com/Kilo-Org/kilocode/commit/a5bca011a16077d4394f9b5650a387f235cc77b2) - Prefer ChatGPT OAuth credentials over inherited OpenAI environment variables and make ChatGPT sign-in easier to find. + +- [#9448](https://github.com/Kilo-Org/kilocode/pull/9448) [`73ab363`](https://github.com/Kilo-Org/kilocode/commit/73ab363f9a1592721d4ce4b92d1a083b7bc8176b) - Fix session cost display missing subagent costs. The TUI footer, sidebar, web context panel, and ACP usage reports now include the cost of every subagent the session spawned, including nested ones. + +- [#9484](https://github.com/Kilo-Org/kilocode/pull/9484) [`dbf1135`](https://github.com/Kilo-Org/kilocode/commit/dbf113524ed27e2aaac9afc5441e70339edaa164) - Prompt before agents access files outside the active directory when a workspace boundary resolves to a filesystem root. + +## 7.2.25 + +### Patch Changes + +- [#9526](https://github.com/Kilo-Org/kilocode/pull/9526) [`c8113f2`](https://github.com/Kilo-Org/kilocode/commit/c8113f27b190f5c08ce642da57d68646132e1828) - Fix multi-turn DeepSeek reasoning round-tripping on OpenRouter by bumping `@openrouter/ai-sdk-provider` to 2.8.1 in both the CLI and Kilo Gateway packages and letting the SDK handle reasoning details, plus pulling in upstream DeepSeek variant, reasoning-effort, and assistant-reasoning fixes. New DeepSeek conversations are fixed; existing sessions that already stored empty reasoning metadata may still need to be restarted. + +- Updated dependencies [[`c8113f2`](https://github.com/Kilo-Org/kilocode/commit/c8113f27b190f5c08ce642da57d68646132e1828)]: + - @kilocode/kilo-gateway@7.2.25 + - @kilocode/kilo-telemetry@7.2.25 + ## 7.2.23 ### Minor Changes diff --git a/packages/opencode/migration/20260423070820_add_icon_url_override/migration.sql b/packages/opencode/migration/20260423070820_add_icon_url_override/migration.sql new file mode 100644 index 00000000000..e28a1d4e989 --- /dev/null +++ b/packages/opencode/migration/20260423070820_add_icon_url_override/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE `project` ADD `icon_url_override` text; +UPDATE `project` SET `icon_url_override` = `icon_url` WHERE `icon_url` IS NOT NULL; diff --git a/packages/opencode/migration/20260423070820_add_icon_url_override/snapshot.json b/packages/opencode/migration/20260423070820_add_icon_url_override/snapshot.json new file mode 100644 index 00000000000..06dae8e44b7 --- /dev/null +++ b/packages/opencode/migration/20260423070820_add_icon_url_override/snapshot.json @@ -0,0 +1,1409 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "66cbe0d7-def0-451b-b88a-7608513a9b44", + "prevIds": ["30b928c5-deef-472c-856d-b5b5064bf6d4"], + "ddl": [ + { + "name": "account_state", + "entityType": "tables" + }, + { + "name": "account", + "entityType": "tables" + }, + { + "name": "control_account", + "entityType": "tables" + }, + { + "name": "workspace", + "entityType": "tables" + }, + { + "name": "project", + "entityType": "tables" + }, + { + "name": "message", + "entityType": "tables" + }, + { + "name": "part", + "entityType": "tables" + }, + { + "name": "permission", + "entityType": "tables" + }, + { + "name": "session_entry", + "entityType": "tables" + }, + { + "name": "session", + "entityType": "tables" + }, + { + "name": "todo", + "entityType": "tables" + }, + { + "name": "session_share", + "entityType": "tables" + }, + { + "name": "event_sequence", + "entityType": "tables" + }, + { + "name": "event", + "entityType": "tables" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_account_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active_org_id", + "entityType": "columns", + "table": "account_state" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "access_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "refresh_token", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "token_expiry", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "active", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "control_account" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "''", + "generated": null, + "name": "name", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "branch", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "extra", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "workspace" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "worktree", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "vcs", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_url_override", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "icon_color", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "project" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_initialized", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sandboxes", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "commands", + "entityType": "columns", + "table": "project" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "message" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "message" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "message_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "part" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "part" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "permission" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "permission" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "session_entry" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "project_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "workspace_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "parent_id", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "slug", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "directory", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "title", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "version", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "share_url", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_additions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_deletions", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_files", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "summary_diffs", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "revert", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "permission", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_compacting", + "entityType": "columns", + "table": "session" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_archived", + "entityType": "columns", + "table": "session" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "content", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "priority", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "position", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "todo" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "todo" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "session_id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "secret", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "url", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_created", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "time_updated", + "entityType": "columns", + "table": "session_share" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event_sequence" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "aggregate_id", + "entityType": "columns", + "table": "event" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "seq", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "type", + "entityType": "columns", + "table": "event" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "data", + "entityType": "columns", + "table": "event" + }, + { + "columns": ["active_account_id"], + "tableTo": "account", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "nameExplicit": false, + "name": "fk_account_state_active_account_id_account_id_fk", + "entityType": "fks", + "table": "account_state" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_workspace_project_id_project_id_fk", + "entityType": "fks", + "table": "workspace" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_message_session_id_session_id_fk", + "entityType": "fks", + "table": "message" + }, + { + "columns": ["message_id"], + "tableTo": "message", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_part_message_id_message_id_fk", + "entityType": "fks", + "table": "part" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_permission_project_id_project_id_fk", + "entityType": "fks", + "table": "permission" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_entry_session_id_session_id_fk", + "entityType": "fks", + "table": "session_entry" + }, + { + "columns": ["project_id"], + "tableTo": "project", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_project_id_project_id_fk", + "entityType": "fks", + "table": "session" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_todo_session_id_session_id_fk", + "entityType": "fks", + "table": "todo" + }, + { + "columns": ["session_id"], + "tableTo": "session", + "columnsTo": ["id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_session_share_session_id_session_id_fk", + "entityType": "fks", + "table": "session_share" + }, + { + "columns": ["aggregate_id"], + "tableTo": "event_sequence", + "columnsTo": ["aggregate_id"], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_event_aggregate_id_event_sequence_aggregate_id_fk", + "entityType": "fks", + "table": "event" + }, + { + "columns": ["email", "url"], + "nameExplicit": false, + "name": "control_account_pk", + "entityType": "pks", + "table": "control_account" + }, + { + "columns": ["session_id", "position"], + "nameExplicit": false, + "name": "todo_pk", + "entityType": "pks", + "table": "todo" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_state_pk", + "table": "account_state", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "account_pk", + "table": "account", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "workspace_pk", + "table": "workspace", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "project_pk", + "table": "project", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "message_pk", + "table": "message", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "part_pk", + "table": "part", + "entityType": "pks" + }, + { + "columns": ["project_id"], + "nameExplicit": false, + "name": "permission_pk", + "table": "permission", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_entry_pk", + "table": "session_entry", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "session_pk", + "table": "session", + "entityType": "pks" + }, + { + "columns": ["session_id"], + "nameExplicit": false, + "name": "session_share_pk", + "table": "session_share", + "entityType": "pks" + }, + { + "columns": ["aggregate_id"], + "nameExplicit": false, + "name": "event_sequence_pk", + "table": "event_sequence", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "event_pk", + "table": "event", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "time_created", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "message_session_time_created_id_idx", + "entityType": "indexes", + "table": "message" + }, + { + "columns": [ + { + "value": "message_id", + "isExpression": false + }, + { + "value": "id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_message_id_id_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "part_session_idx", + "entityType": "indexes", + "table": "part" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + }, + { + "value": "type", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_session_type_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "time_created", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_entry_time_created_idx", + "entityType": "indexes", + "table": "session_entry" + }, + { + "columns": [ + { + "value": "project_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_project_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "workspace_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_workspace_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "parent_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "session_parent_idx", + "entityType": "indexes", + "table": "session" + }, + { + "columns": [ + { + "value": "session_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "todo_session_idx", + "entityType": "indexes", + "table": "todo" + } + ], + "renames": [] +} diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 5ff8e85338d..f1dc1ab1aa3 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.24", + "version": "7.2.26", "name": "@kilocode/cli", "type": "module", "license": "MIT", @@ -110,6 +110,7 @@ "@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:*", @@ -117,6 +118,7 @@ "@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:", @@ -127,8 +129,8 @@ "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", - "@opentui/core": "catalog:", - "@opentui/solid": "catalog:", + "@opentui/core": "0.1.99", + "@opentui/solid": "0.1.99", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", "@solid-primitives/event-bus": "1.1.2", @@ -177,6 +179,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", diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index 3d70edff9fd..3569d2c44da 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -5,10 +5,12 @@ import fs from "fs" import path from "path" import { fileURLToPath } from "url" import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin" +import { createRequire } from "module" // kilocode_change const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const dir = path.resolve(__dirname, "..") +const require = createRequire(import.meta.url) // kilocode_change process.chdir(dir) @@ -16,6 +18,7 @@ await import("./generate.ts") import { Script } from "@opencode-ai/script" import pkg from "../package.json" +import { LanceDBRuntime } from "../src/kilocode/lancedb" // kilocode_change // Load migrations from migration directories const migrationDirs = ( @@ -78,6 +81,26 @@ const createEmbeddedWebUIBundle = async () => { const embeddedFileMap = skipEmbedWebUi ? null : await createEmbeddedWebUIBundle() +// kilocode_change start - codebase indexing +async function copyTreeSitterWasms(outputDir: string) { + const runtimeWasmPath = require.resolve("web-tree-sitter/tree-sitter.wasm") + const languagePackagePath = require.resolve("tree-sitter-wasms/package.json") + const languageWasmDir = path.join(path.dirname(languagePackagePath), "out") + const targetDir = path.join(outputDir, "tree-sitter") + + await fs.promises.mkdir(targetDir, { recursive: true }) + await fs.promises.copyFile(runtimeWasmPath, path.join(targetDir, "tree-sitter.wasm")) + + const languageWasmFiles = (await fs.promises.readdir(languageWasmDir)).filter((file) => file.endsWith(".wasm")) + + await Promise.all( + languageWasmFiles.map((file) => fs.promises.copyFile(path.join(languageWasmDir, file), path.join(targetDir, file))), + ) + + console.log(`copied ${languageWasmFiles.length + 1} tree-sitter wasm files to ${targetDir}`) +} +// kilocode_change end + const allTargets: { os: string arch: "arm64" | "x64" @@ -126,12 +149,10 @@ const allTargets: { arch: "x64", avx2: false, }, - // kilocode_change start - Windows ARM64 target { os: "win32", arch: "arm64", }, - // kilocode_change end { os: "win32", arch: "x64", @@ -141,12 +162,6 @@ const allTargets: { arch: "x64", avx2: false, }, - // kilocode_change start - added Windows ARM64 target - { - os: "win32", - arch: "arm64", - }, - // kilocode_change end ] const targets = singleFlag @@ -195,7 +210,6 @@ for (const item of targets) { const rootPath = path.resolve(dir, "../../node_modules/@opentui/core/parser.worker.js") const parserWorker = fs.realpathSync(fs.existsSync(localPath) ? localPath : rootPath) const workerPath = "./src/cli/cmd/tui/worker.ts" - const rgPath = "./src/file/ripgrep.worker.ts" // Use platform-specific bunfs root path based on target OS const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/" @@ -205,7 +219,8 @@ for (const item of targets) { conditions: ["browser"], tsconfig: "./tsconfig.json", plugins: [plugin], - external: ["node-gyp"], + sourcemap: "external", // kilocode_change + external: ["node-gyp", ...LanceDBRuntime.external], // kilocode_change format: "esm", minify: true, splitting: true, @@ -220,24 +235,19 @@ for (const item of targets) { windows: {}, }, files: embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}, - entrypoints: [ - "./src/index.ts", - parserWorker, - workerPath, - rgPath, - ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : []), - ], + entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])], define: { KILO_VERSION: `'${Script.version}'`, KILO_MIGRATIONS: JSON.stringify(migrations), OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath, KILO_WORKER_PATH: workerPath, - KILO_RIPGREP_WORKER_PATH: rgPath, KILO_CHANNEL: `'${Script.channel}'`, KILO_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "", }, }) + await copyTreeSitterWasms(path.resolve(dir, `dist/${name}/bin`)) // kilocode_change + // kilocode_change start - fix Nix-specific ELF interpreter paths for Linux binaries if (item.os === "linux") { const interpreters: Record = { diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 98fbd082e54..ced313749bf 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -77,10 +77,10 @@ const image = "ghcr.io/kilo-org/kilo" // kilocode_change const platforms = "linux/amd64,linux/arm64" const tags = [`${image}:${version}`, `${image}:${Script.channel}`] const tagFlags = tags.flatMap((t) => ["-t", t]) -await $`docker buildx build --platform ${platforms} ${tagFlags} --push .` // registries if (!Script.preview) { + await $`docker buildx build --platform ${platforms} ${tagFlags} --push .` // Calculate SHA values // kilocode_change start const arm64Sha = await $`sha256sum ./dist/kilo-linux-arm64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim()) diff --git a/packages/opencode/script/run-workspace-server b/packages/opencode/script/run-workspace-server new file mode 100755 index 00000000000..4371a157976 --- /dev/null +++ b/packages/opencode/script/run-workspace-server @@ -0,0 +1,106 @@ +#!/usr/bin/env bun + +// This script runs a separate OpenCode server to be used as a remote +// workspace, simulating a remote environment but all local to make +// debugger easier +// +// *Important*: make sure you add the debug workspace plugin first. +// In `.opencode/opencode.jsonc` in the root of this project add: +// +// "plugin": ["../packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts"] +// +// Afterwards, run `./packages/opencode/script/run-workspace-server` + +import { stat } from "node:fs/promises" +import { setTimeout as sleep } from "node:timers/promises" + +const DEV_DATA_FILE = "/tmp/opencode-workspace-dev-data.json" +const RESTART_POLL_INTERVAL = 250 + +async function readData() { + return await Bun.file(DEV_DATA_FILE).json() +} + +async function readDataMtime() { + return await stat(DEV_DATA_FILE) + .then((info) => info.mtimeMs) + .catch((error) => { + if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") { + return undefined + } + + throw error + }) +} + +async function readSnapshot() { + while (true) { + try { + const before = await readDataMtime() + if (before === undefined) { + await sleep(RESTART_POLL_INTERVAL) + continue + } + + const data = await readData() + const after = await readDataMtime() + + if (before === after) { + return { data, mtime: after } + } + } catch (error) { + if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") { + await sleep(RESTART_POLL_INTERVAL) + continue + } + + throw error + } + } +} + +function startDevServer(data: any) { + const env = Object.fromEntries(Object.entries(data.env ?? {}).filter(([, value]) => value !== undefined)) + + return Bun.spawn(["bun", "run", "dev", "serve", "--port", String(data.port), "--print-logs"], { + env: { + ...process.env, + ...env, + XDG_DATA_HOME: "/tmp/data", + }, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }) +} + +async function waitForRestartSignal(mtime: number, signal: AbortSignal) { + while (!signal.aborted) { + await sleep(RESTART_POLL_INTERVAL) + if (signal.aborted) return false + if ((await readDataMtime()) !== mtime) return true + } + + return false +} + +while (true) { + const { data, mtime } = await readSnapshot() + const proc = startDevServer(data) + const restartAbort = new AbortController() + + const result = await Promise.race([ + proc.exited.then((code) => ({ type: "exit" as const, code })), + waitForRestartSignal(mtime, restartAbort.signal).then((restart) => ({ type: "restart" as const, restart })), + ]) + + restartAbort.abort() + + if (result.type === "restart" && result.restart) { + proc.kill() + await proc.exited + continue + } + + process.exit(result.code) +} diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index c0f302f21ae..448760ae1aa 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -55,7 +55,7 @@ const configFile = process.argv[2] const tuiFile = process.argv[3] console.log(configFile) -await Bun.write(configFile, JSON.stringify(generate(Config.Info), null, 2)) +await Bun.write(configFile, JSON.stringify(generate(Config.Info.zod), null, 2)) if (tuiFile) { console.log(tuiFile) diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 67f8abfbc4d..438f90796c9 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -224,7 +224,7 @@ When to use each: Promoting a previously-anonymous schema to Schema.Class is acceptable when it is top-level or endpoint-facing, but call it out in the PR — it is an additive SDK change (`export type Foo = ...` newly appears) even if it preserves the JSON shape. -Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those, add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior: +Schemas that are **not** pure objects (enums, unions, records, tuples) cannot use Schema.Class. For those — and for pure-object schemas where handlers populate plain objects rather than class instances — add `.annotate({ identifier: "FooName" })` to get the same named-ref behavior without the `instanceof` requirement: ```ts export const Action = Schema.Literals(["ask", "allow", "deny"]).annotate({ identifier: "PermissionActionConfig" }) @@ -373,9 +373,9 @@ The first slice is successful if: - `Schema.Class` works well for route DTOs such as `Question.Request`, `Question.Info`, and `Question.Reply`. - scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes. -- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects. +- if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects. `Schema.Class`'s Declaration AST enforces `input instanceof self || input.[ClassTypeId]` during encode (see effect-smol `Schema.ts:10479-10484`). Plain objects from zod parse fail with `Expected Foo, got {...}`. This surfaced on `GET /config` where the service returns zod-parsed plain objects and `Config.InfoSchema` referenced `ConfigProvider.Info` (class). The fix was to convert pure-object classes to `Schema.Struct(...).annotate({ identifier: "..." })` — same named SDK `$ref`, no instance requirement. Verified byte-identical `types.gen.ts` vs `dev`. - internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes. -- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes. +- `Schema.Class` emits named `$ref` in OpenAPI — only use it for types that already had `.meta({ ref })` in the old Zod schema **and** when the handler/service returns real instances. For schemas that need a named `$ref` but are populated from plain objects, use `Schema.Struct(...).annotate({ identifier: "..." })` instead. Inner/nested types should stay as `Schema.Struct` to avoid SDK shape changes. ### Integration @@ -404,8 +404,7 @@ Current instance route inventory: - `provider` - `bridged` endpoints: `GET /provider`, `GET /provider/auth`, `POST /provider/:providerID/oauth/authorize`, `POST /provider/:providerID/oauth/callback` - `config` - `bridged` (partial) - bridged endpoint: `GET /config/providers` - later endpoint: `GET /config` + bridged endpoints: `GET /config`, `GET /config/providers` defer `PATCH /config` for now - `project` - `bridged` (partial) bridged endpoints: `GET /project`, `GET /project/current` @@ -431,9 +430,8 @@ Current instance route inventory: Recommended near-term sequence: 1. `workspace` read endpoints (`GET /experimental/workspace/adaptor`, `GET /experimental/workspace`, `GET /experimental/workspace/status`) -2. `config` full read endpoint (`GET /config`) -3. `file` JSON read endpoints -4. `mcp` JSON read endpoints +2. `file` JSON read endpoints +3. `mcp` JSON read endpoints ## Checklist @@ -449,8 +447,8 @@ Recommended near-term sequence: - [x] port remaining provider endpoints (`GET /provider`, OAuth mutations) - [x] port `config` providers read endpoint - [x] port `project` read endpoints (`GET /project`, `GET /project/current`) +- [x] port `GET /config` full read endpoint - [ ] port `workspace` read endpoints -- [ ] port `GET /config` full read endpoint - [ ] port `file` JSON read endpoints - [ ] decide when to remove the flag and make Effect routes the default diff --git a/packages/opencode/specs/effect/instance-context.md b/packages/opencode/specs/effect/instance-context.md index 7d0d7eb13c8..6d63715030f 100644 --- a/packages/opencode/specs/effect/instance-context.md +++ b/packages/opencode/specs/effect/instance-context.md @@ -224,7 +224,6 @@ These tools mostly use direct getters for path resolution and repo-relative disp - `src/tool/bash.ts` - `src/tool/edit.ts` - `src/tool/lsp.ts` -- `src/tool/multiedit.ts` - `src/tool/plan.ts` - `src/tool/read.ts` - `src/tool/write.ts` diff --git a/packages/opencode/specs/effect/schema.md b/packages/opencode/specs/effect/schema.md index 72ee10350dd..9ff6859cee1 100644 --- a/packages/opencode/specs/effect/schema.md +++ b/packages/opencode/specs/effect/schema.md @@ -97,7 +97,7 @@ creating a parallel schema source of truth. ## Escape hatches -The walker in `@/util/effect-zod` exposes three explicit escape hatches for +The walker in `@/util/effect-zod` exposes two explicit escape hatches for cases the pure-Schema path cannot express. Each one stays in the codebase only as long as its upstream or local dependency requires it — inline comments document when each can be deleted. @@ -109,19 +109,7 @@ Replaces the entire derivation with a hand-crafted zod schema. Used when: - the target carries external `$ref` metadata (e.g. `config/model-id.ts` points at `https://models.dev/...`) - the target is a zod-only schema that cannot yet be expressed as Schema - (e.g. `ConfigAgent.Info`, `ConfigPermission.Info`, `Log.Level`) - -### `ZodPreprocess` annotation - -Wraps the derived zod schema with `z.preprocess(fn, inner)`. Used by -`config/permission.ts` to inject `__originalKeys` before parsing, because -`Schema.StructWithRest` canonicalises output (known fields first, catchall -after) and destroys the user's original property order — which permission -rule precedence depends on. - -Tracked upstream as `effect:core/wlh553`: "Schema: add preserveInputOrder -(or pre-parse hook) for open structs." Once that lands, `ZodPreprocess` and -the `__originalKeys` hack can both be deleted. + (e.g. `ConfigAgent.Info`, `Log.Level`) ### Local `DeepMutable` in `config/config.ts` @@ -171,21 +159,95 @@ Schema at source. These are the highest-priority next targets. Each is a small, self-contained schema module with a clear domain. -- [ ] `src/control-plane/schema.ts` -- [ ] `src/permission/schema.ts` -- [ ] `src/project/schema.ts` -- [ ] `src/provider/schema.ts` -- [ ] `src/pty/schema.ts` -- [ ] `src/question/schema.ts` -- [ ] `src/session/schema.ts` -- [ ] `src/sync/schema.ts` -- [ ] `src/tool/schema.ts` +- [x] `src/control-plane/schema.ts` +- [x] `src/permission/schema.ts` +- [x] `src/project/schema.ts` +- [x] `src/provider/schema.ts` +- [x] `src/pty/schema.ts` +- [x] `src/question/schema.ts` +- [x] `src/session/schema.ts` +- [x] `src/sync/schema.ts` +- [x] `src/tool/schema.ts` ### Session domain Major cluster. Message + event types flow through the SSE API and every SDK output, so byte-identical SDK surface is critical. +Suggested order for this cluster, starting from the leaves that `session.ts` +and the SSE/event surface depend on: + +1. `src/session/schema.ts` ✅ already migrated +2. `src/provider/schema.ts` if `message-v2.ts` still relies on zod-first IDs +3. `src/lsp/*` schema leaves needed by `LSP.Range` +4. `src/snapshot/*` leaves used by `Snapshot.FileDiff` +5. `src/session/message-v2.ts` +6. `src/session/message.ts` +7. `src/session/prompt.ts` +8. `src/session/revert.ts` +9. `src/session/summary.ts` +10. `src/session/status.ts` +11. `src/session/todo.ts` +12. `src/session/session.ts` +13. `src/session/compaction.ts` + +Dependency sketch: + +```text +session.ts +|- project/schema.ts +|- control-plane/schema.ts +|- permission/schema.ts +|- snapshot/* +|- message-v2.ts +| |- provider/schema.ts +| |- lsp/* +| |- snapshot/* +| |- sync/index.ts +| `- bus/bus-event.ts +|- sync/index.ts +|- bus/bus-event.ts +`- util/update-schema.ts +``` + +Working rule for this cluster: + +- migrate reusable leaf schemas and nested payload objects first +- migrate aggregate DTOs like `Session.Info` after their nested pieces exist as + named Schema values +- leave zod-only event/update helpers in place temporarily when converting + them would force unrelated churn across sync/bus boundaries + +`message-v2.ts` first-pass outline: + +1. Schema-backed imports already available + - `SessionID`, `MessageID`, `PartID` + - `ProviderID`, `ModelID` +2. Local leaf objects to extract and migrate first + - output format payloads + - common part bases like `PartBase` + - timestamp/range helper objects like `time.start/end` + - file/source helper objects + - token/cost/model helper objects +3. Part variants built from those leaves + - `SnapshotPart`, `PatchPart`, `TextPart`, `ReasoningPart` + - `FilePart`, `AgentPart`, `CompactionPart`, `SubtaskPart` + - retry/step/tool related parts +4. Higher-level unions and DTOs + - `FilePartSource` + - part unions + - message unions and assistant/user payloads +5. Errors and event payloads last + - `NamedError.create(...)` shapes can stay temporarily if converting them to + `Schema.TaggedErrorClass` would force unrelated churn + - `SyncEvent.define(...)` and `BusEvent.define(...)` payloads can keep using + derived `.zod` until the sync/bus layers are migrated + +Possible later tightening after the Schema-first migration is stable: + +- promote repeated opaque strings and timestamp numbers into branded/newtype + leaf schemas where that adds domain value without changing the wire format + - [ ] `src/session/compaction.ts` - [ ] `src/session/message-v2.ts` - [ ] `src/session/message.ts` @@ -216,7 +278,6 @@ emitted JSON Schema must stay byte-identical. - [ ] `src/tool/grep.ts` - [ ] `src/tool/invalid.ts` - [ ] `src/tool/lsp.ts` -- [ ] `src/tool/multiedit.ts` - [ ] `src/tool/plan.ts` - [ ] `src/tool/question.ts` - [ ] `src/tool/read.ts` diff --git a/packages/opencode/specs/effect/tools.md b/packages/opencode/specs/effect/tools.md index 7b47831709a..3cc277357be 100644 --- a/packages/opencode/specs/effect/tools.md +++ b/packages/opencode/specs/effect/tools.md @@ -46,7 +46,6 @@ These exported tool definitions currently use `Tool.define(...)` in `src/tool`: - [x] `grep.ts` - [x] `invalid.ts` - [x] `lsp.ts` -- [x] `multiedit.ts` - [x] `plan.ts` - [x] `question.ts` - [x] `read.ts` @@ -82,7 +81,6 @@ Notable items that are already effectively on the target path and do not need se - `write.ts` - `codesearch.ts` - `websearch.ts` -- `multiedit.ts` - `edit.ts` ## Filesystem notes diff --git a/packages/opencode/src/agent/prompt/compaction.txt b/packages/opencode/src/agent/prompt/compaction.txt index 11deccb3afc..c7cb838bbaa 100644 --- a/packages/opencode/src/agent/prompt/compaction.txt +++ b/packages/opencode/src/agent/prompt/compaction.txt @@ -1,15 +1,9 @@ -You are a helpful AI assistant tasked with summarizing conversations. +You are an anchored context summarization assistant for coding sessions. -When asked to summarize, provide a detailed but concise summary of the conversation. -Focus on information that would be helpful for continuing the conversation, including: -- What was done -- What is currently being worked on -- Which files are being modified -- What needs to be done next -- Key user requests, constraints, or preferences that should persist -- Important technical decisions and why they were made +Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work. -Your summary should be comprehensive enough to provide context but concise enough to be quickly understood. +If the prompt includes a block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts. -Do not respond to any questions in the conversation, only output the summary. -Respond in the same language the user used in the conversation. +Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs. + +Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation. diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts index 185cab9c758..47db6358b6e 100644 --- a/packages/opencode/src/cli/cmd/debug/lsp.ts +++ b/packages/opencode/src/cli/cmd/debug/lsp.ts @@ -23,8 +23,7 @@ const DiagnosticsCommand = cmd({ const out = await AppRuntime.runPromise( LSP.Service.use((lsp) => Effect.gen(function* () { - yield* lsp.touchFile(args.file, true) - yield* Effect.sleep(1000) + yield* lsp.touchFile(args.file, "full") return yield* lsp.diagnostics() }), ), diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index c64f3edafe7..b62ca11c2fb 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -992,7 +992,8 @@ export const GithubRunCommand = cmd({ const err = result.info.error console.error("Agent error:", err) if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files)) - throw new Error(`${err.name}: ${err.data?.message || ""}`) + const message = "message" in err.data ? err.data.message : "" + throw new Error(`${err.name}: ${message}`) } const text = extractResponseText(result.parts) @@ -1021,7 +1022,8 @@ export const GithubRunCommand = cmd({ const err = summary.info.error console.error("Summary agent error:", err) if (err.name === "ContextOverflowError") throw new Error(formatPromptTooLargeError(files)) - throw new Error(`${err.name}: ${err.data?.message || ""}`) + const message = "message" in err.data ? err.data.message : "" + throw new Error(`${err.name}: ${message}`) } const summaryText = extractResponseText(summary.parts) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 72cd5e71cc2..e9a435096d6 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -197,7 +197,7 @@ export const ImportCommand = cmd({ ) for (const msg of exportData.messages) { - const msgInfo = MessageV2.Info.parse(msg.info) + const msgInfo = MessageV2.Info.zod.parse(msg.info) const { id, sessionID: _, ...msgData } = msgInfo Database.use((db) => db @@ -213,7 +213,7 @@ export const ImportCommand = cmd({ ) for (const part of msg.parts) { - const partInfo = MessageV2.Part.parse(part) + const partInfo = MessageV2.Part.zod.parse(part) const { id: partId, sessionID: _s, messageID, ...partData } = partInfo Database.use((db) => db diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index 936cff7cc1e..c61d9510271 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -4,9 +4,30 @@ import { AppRuntime } from "@/effect/app-runtime" import { Git } from "@/git" import { Instance } from "@/project/instance" import { Process } from "@/util" +import { existsSync } from "node:fs" // kilocode_change + +const subcommand = "pr" // kilocode_change + +// kilocode_change start - resolve the currently running CLI instead of hardcoding opencode +export function cliCommand( + input = { + execPath: process.execPath, + argv: process.argv, + exists: existsSync, + }, +) { + const script = input.argv[1] + if (!script) return [input.execPath] + if (script === subcommand) return [input.execPath] // kilocode_change + if (script.startsWith("/$bunfs/root/")) return [input.execPath] + if (script.startsWith("B:/~BUN/root/")) return [input.execPath] + if (input.exists(script)) return [input.execPath, script] + return [input.execPath] +} +// kilocode_change end export const PrCommand = cmd({ - command: "pr ", + command: `${subcommand} `, // kilocode_change describe: "fetch and checkout a GitHub PR branch, then run kilo", // kilocode_change builder: (yargs) => yargs.positional("number", { @@ -26,6 +47,7 @@ export const PrCommand = cmd({ const prNumber = args.number const localBranchName = `pr/${prNumber}` + const cli = cliCommand() // kilocode_change UI.println(`Fetching and checking out PR #${prNumber}...`) // Use gh pr checkout with custom branch name @@ -102,9 +124,7 @@ export const PrCommand = cmd({ UI.println(`Found session: ${sessionUrl}`) UI.println(`Importing session...`) - const importResult = await Process.text(["kilo", "import", sessionUrl], { - nothrow: true, - }) + const importResult = await Process.text([...cli, "import", sessionUrl], { nothrow: true }) // kilocode_change end if (importResult.code === 0) { const importOutput = importResult.text.trim() @@ -122,13 +142,12 @@ export const PrCommand = cmd({ UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`) UI.println() - const bin = "kilo" // kilocode_change - UI.println(`Starting ${bin}...`) // kilocode_change + UI.println("Starting kilo...") // kilocode_change UI.println() - const opencodeArgs = sessionId ? ["-s", sessionId] : [] + const run = sessionId ? [...cli, "-s", sessionId] : cli // kilocode_change // kilocode_change start - const opencodeProcess = Process.spawn([bin, ...opencodeArgs], { + const opencodeProcess = Process.spawn(run, { // kilocode_change end stdin: "inherit", stdout: "inherit", @@ -136,7 +155,7 @@ export const PrCommand = cmd({ cwd: process.cwd(), }) const code = await opencodeProcess.exited - if (code !== 0) throw new Error(`${bin} exited with code ${code}`) // kilocode_change + if (code !== 0) throw new Error(`kilo exited with code ${code}`) // kilocode_change }, }) }, diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 4e0bffcc8df..c2920e28866 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -391,7 +391,7 @@ export const ProvidersLoginCommand = cmd({ hint: { kilo: "recommended", // kilocode_change opencode: "recommended", - openai: "ChatGPT Plus/Pro or API key", + openai: "ChatGPT login or API key", // kilocode_change }[x.id], })), ), @@ -407,7 +407,10 @@ export const ProvidersLoginCommand = cmd({ const input = args.provider const byID = options.find((x) => x.value === input) const byName = options.find((x) => x.label.toLowerCase() === input.toLowerCase()) - const match = byID ?? byName + // kilocode_change start - accept codex as an alias for OpenAI ChatGPT auth + const alias = input.toLowerCase() === "codex" ? options.find((x) => x.value === "openai") : undefined + const match = byID ?? byName ?? alias + // kilocode_change end if (!match) { prompts.log.error(`Unknown provider "${input}"`) process.exit(1) diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts index 34af56ad7a9..0242f72f35b 100644 --- a/packages/opencode/src/cli/cmd/stats.ts +++ b/packages/opencode/src/cli/cmd/stats.ts @@ -193,7 +193,11 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin for (const message of messages) { if (message.info.role === "assistant") { - sessionCost += message.info.cost || 0 + // kilocode_change start - count propagated subagent cost once but keep child model stats (#6321) + const parts = message.parts.filter((part) => part.type === "step-finish") + const cost = parts.length ? parts.reduce((sum, part) => sum + part.cost, 0) : message.info.cost || 0 + if (!session.parentID) sessionCost += message.info.cost || 0 + // kilocode_change end const modelKey = `${message.info.providerID}/${message.info.modelID}` if (!sessionModelUsage[modelKey]) { @@ -204,7 +208,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin } } sessionModelUsage[modelKey].messages++ - sessionModelUsage[modelKey].cost += message.info.cost || 0 + sessionModelUsage[modelKey].cost += cost // kilocode_change if (message.info.tokens) { sessionTokens.input += message.info.tokens.input || 0 diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8dca70bdcb5..b43f24588ee 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -126,8 +126,8 @@ export function tui(input: { const mode = await Terminal.getTerminalBackgroundColor() - // Re-clear after getTerminalBackgroundColor() — setRawMode(false) restores - // the original console mode which re-enables ENABLE_PROCESSED_INPUT. + // Re-clear after getTerminalBackgroundColor() because setRawMode(false) + // restores the original console mode, including processed input on Windows. win32DisableProcessedInput() const onExit = async () => { diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 2b0273c3c6d..a1d745753bb 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -5,6 +5,8 @@ import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/cli/cmd/tui/config/tui" import { createKiloClient } from "@kilocode/sdk/v2" // kilocode_change import { importCloudSession, validateCloudFork } from "@/kilocode/cloud-session" // kilocode_change +import { errorMessage } from "@/util/error" +import { validateSession } from "./validate-session" export const AttachCommand = cmd({ command: "attach ", @@ -98,6 +100,20 @@ export const AttachCommand = cmd({ } // kilocode_change end const config = await TuiConfig.get() + + try { + await validateSession({ + url: args.url, + sessionID: args.session, + directory, + headers, + }) + } catch (error) { + UI.error(errorMessage(error)) + process.exitCode = 1 + return + } + await tui({ url: args.url, config, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 34a106cf35b..677dfd67adc 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -33,7 +33,7 @@ export function createDialogProviderOptions() { const connected = sync.data.provider_next.connected.includes(provider.id) return { - title: provider.name, + title: KiloProvider.PROVIDER_TITLES[provider.id] ?? provider.name, // kilocode_change value: provider.id, description: KiloProvider.PROVIDER_DESCRIPTIONS[provider.id], // kilocode_change footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, diff --git a/packages/opencode/src/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 2d883d69f8f..91e5906119d 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -19,6 +19,7 @@ import { makeRuntime } from "@/effect/runtime" import { Filesystem, Log } from "@/util" import { ConfigVariable } from "@/config/variable" import { Npm } from "@/npm" +import { KilocodeDefaultPlugins } from "@/kilocode/config/default-plugins" // kilocode_change const log = Log.create({ service: "tui.config" }) @@ -146,6 +147,10 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: } acc.result.keybinds = ConfigKeybinds.Keybinds.parse(keybinds) + // kilocode_change start — inject Kilo default plugins to keep TUI aligned with server config + KilocodeDefaultPlugins.apply(acc.result, { disabled: Flag.KILO_DISABLE_DEFAULT_PLUGINS, log }) + // kilocode_change end + return { config: acc.result, dirs: acc.result.plugin?.length ? dirs : [], diff --git a/packages/opencode/src/cli/cmd/tui/context/kv.tsx b/packages/opencode/src/cli/cmd/tui/context/kv.tsx index 803752e7664..43266315bf0 100644 --- a/packages/opencode/src/cli/cmd/tui/context/kv.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/kv.tsx @@ -1,7 +1,9 @@ import { Global } from "@/global" import { Filesystem } from "@/util" +import { Flock } from "@opencode-ai/shared/util/flock" +import { rename, rm } from "fs/promises" import { createSignal, type Setter } from "solid-js" -import { createStore } from "solid-js/store" +import { createStore, unwrap } from "solid-js/store" import { createSimpleContext } from "./helper" import path from "path" @@ -11,12 +13,29 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ const [ready, setReady] = createSignal(false) const [store, setStore] = createStore>() const filePath = path.join(Global.Path.state, "kv.json") + const lock = `tui-kv:${filePath}` + // Queue same-process writes so rapid updates persist in order. + let write = Promise.resolve() - Filesystem.readJson>(filePath) + // Write to a temp file first so kv.json is only replaced once the JSON is complete, avoiding partial writes if shutdown interrupts persistence. + function writeSnapshot(snapshot: Record) { + const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp` + return Filesystem.writeJson(tempPath, snapshot) + .then(() => rename(tempPath, filePath)) + .catch(async (error) => { + await rm(tempPath, { force: true }).catch(() => undefined) + throw error + }) + } + + // Read under the same lock used for writes because kv.json is shared across processes. + Flock.withLock(lock, () => Filesystem.readJson>(filePath)) .then((x) => { setStore(x) }) - .catch(() => {}) + .catch((error) => { + console.error("Failed to read KV state", { filePath, error }) + }) .finally(() => { setReady(true) }) @@ -44,7 +63,12 @@ export const { use: useKV, provider: KVProvider } = createSimpleContext({ }, set(key: string, value: any) { setStore(key, value) - void Filesystem.writeJson(filePath, store) + const snapshot = structuredClone(unwrap(store)) + write = write + .then(() => Flock.withLock(lock, () => writeSnapshot(snapshot))) + .catch((error) => { + console.error("Failed to write KV state", { filePath, error }) + }) }, } return result diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 7864bdd29fc..92e09d36f99 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -34,6 +34,8 @@ import { handleSuggestionEvent } from "@/kilocode/suggestion/tui/sync" // kiloco import { useToast } from "@tui/ui/toast" // kilocode_change import { Log } from "@/util" import { emptyConsoleState, type ConsoleState } from "@/config/console-state" +import type { IndexingStatus } from "@kilocode/kilo-indexing/status" // kilocode_change +import { KiloIndexing } from "@/kilocode/indexing" // kilocode_change export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", @@ -87,6 +89,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } formatter: FormatterStatus[] vcs: VcsInfo | undefined + indexing: IndexingStatus // kilocode_change }>({ provider_next: { all: [], @@ -118,6 +121,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ mcp_resource: {}, formatter: [], vcs: undefined, + indexing: { state: "Disabled", message: "Indexing disabled.", processedFiles: 0, totalFiles: 0, percent: 0 }, // kilocode_change }) const event = useEvent() @@ -469,6 +473,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) break } + case "indexing.status": { + setStore("indexing", reconcile(event.properties.status)) + break + } // kilocode_change end } }) @@ -587,6 +595,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) }) .catch(() => {}), + KiloIndexing.current().then((x) => setStore("indexing", reconcile(x))), // kilocode_change end ]).then(() => { setStore("status", "complete") diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 2e47afeab2a..055015a501e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -327,8 +327,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ setStore( produce((draft) => { const lock = pick(kv.get("theme_mode_lock")) - const mode = pick(kv.get("theme_mode", props.mode)) - draft.mode = lock ?? mode ?? props.mode + const mode = lock ?? props.mode + if (!lock && pick(kv.get("theme_mode")) !== undefined) { + kv.set("theme_mode", undefined) + } + draft.mode = mode draft.lock = lock const active = config.theme ?? kv.get("theme", "kilo") // kilocode_change draft.active = typeof active === "string" ? active : "kilo" // kilocode_change @@ -386,7 +389,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ } function apply(mode: "dark" | "light") { - kv.set("theme_mode", mode) + if (store.lock !== undefined) kv.set("theme_mode", mode) if (store.mode === mode) return setStore("mode", mode) renderer.clearPaletteCache() @@ -402,6 +405,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ function free() { setStore("lock", undefined) kv.set("theme_mode_lock", undefined) + kv.set("theme_mode", undefined) const mode = renderer.themeMode if (mode) apply(mode) } @@ -410,7 +414,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ if (store.lock) return apply(mode) } - // renderer.on(CliRenderEvents.THEME_MODE, handle) + renderer.on(CliRenderEvents.THEME_MODE, handle) const refresh = () => { renderer.clearPaletteCache() diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 801281bd148..b8fe76ff1d5 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -7,6 +7,23 @@ import { useSDK } from "../../context/sdk" // kilocode_change import { createStore } from "solid-js/store" import { useRoute } from "../../context/route" import { RemoteIndicator } from "@/kilocode/remote-tui" // kilocode_change +import { formatIndexingLabel } from "@/kilocode/indexing-label" // kilocode_change +import type { IndexingStatusState } from "@kilocode/kilo-indexing/status" // kilocode_change +import { indexingEnabled } from "@/kilocode/indexing-feature" // kilocode_change + +// kilocode_change start +function indexingTone(state: IndexingStatusState, theme: ReturnType["theme"]) { + if (state === "Complete") return theme.success + if (state === "Error") return theme.error + if (state === "In Progress") return theme.warning + if (state === "Standby") return theme.textMuted + return theme.textMuted +} + +function indexingText(indexing: ReturnType["data"]["indexing"]) { + return formatIndexingLabel(indexing) +} +// kilocode_change end export function Footer() { const { theme } = useTheme() @@ -22,6 +39,7 @@ export function Footer() { const directory = useDirectory() const connected = useConnected() const sdk = useSDK() // kilocode_change + const indexing = createMemo(() => sync.data.indexing) // kilocode_change const [store, setStore] = createStore({ welcome: false, @@ -73,6 +91,7 @@ export function Footer() { 0 ? theme.success : theme.textMuted }}>• {lsp().length} LSP + {/* kilocode_change start */} @@ -86,6 +105,10 @@ export function Footer() { {mcp()} MCP + + {indexingText(indexing()).slice(0, 48)} + + {/* kilocode_change end */} /status diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 02b64629034..736c1bafae6 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -43,6 +43,7 @@ import type { WebSearchTool } from "@/tool/websearch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" import type { SkillTool } from "@/tool/skill" +import type { SemanticSearchTool } from "@/kilocode/tool/semantic-search" // kilocode_change import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useCommandDialog } from "@tui/component/dialog-command" @@ -63,6 +64,7 @@ import { Flag } from "@/flag/flag" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import parsers from "../../../../../../parsers-config.ts" import * as Clipboard from "../../util/clipboard" +import { errorMessage } from "@/util/error" import { Toast, useToast } from "../../ui/toast" import { useKV } from "../../context/kv.tsx" import * as Editor from "../../util/editor" @@ -83,6 +85,7 @@ import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" import { formatMarkdownTables } from "../../util/markdown" // kilocode_change import { bell } from "@/kilocode/bell" // kilocode_change +import { SessionIndexing } from "@/kilocode/components/session-indexing" // kilocode_change import { getScrollAcceleration } from "../../util/scroll" import { TuiPluginRuntime } from "../../plugin" import { DialogGoUpsell } from "../../component/dialog-go-upsell" @@ -261,31 +264,43 @@ export function Session() { const toast = useToast() const sdk = useSDK() - createEffect(async () => { - const previousWorkspace = project.workspace.current() - const result = await sdk.client.session.get({ sessionID: route.sessionID }, { throwOnError: true }) - if (!result.data) { + createEffect(() => { + const sessionID = route.sessionID + void (async () => { + const previousWorkspace = project.workspace.current() + const result = await sdk.client.session.get({ sessionID }, { throwOnError: true }) + if (!result.data) { + toast.show({ + message: `Session not found: ${sessionID}`, + variant: "error", + duration: 5000, + }) + navigate({ type: "home" }) + return + } + + if (result.data.workspaceID !== previousWorkspace) { + project.workspace.set(result.data.workspaceID) + + // Sync all the data for this workspace. Note that this + // workspace may not exist anymore which is why this is not + // fatal. If it doesn't we still want to show the session + // (which will be non-interactive) + try { + await sync.bootstrap({ fatal: false }) + } catch {} + } + await sync.session.sync(sessionID) + if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000) + })().catch((error) => { + if (route.sessionID !== sessionID) return toast.show({ - message: `Session not found: ${route.sessionID}`, + message: errorMessage(error), variant: "error", + duration: 5000, }) navigate({ type: "home" }) - return - } - - if (result.data.workspaceID !== previousWorkspace) { - project.workspace.set(result.data.workspaceID) - - // Sync all the data for this workspace. Note that this - // workspace may not exist anymore which is why this is not - // fatal. If it doesn't we still want to show the session - // (which will be non-interactive) - try { - await sync.bootstrap({ fatal: false }) - } catch (e) {} - } - await sync.session.sync(route.sessionID) - if (scroll) scroll.scrollBy(100_000) + }) }) let lastSwitch: string | undefined = undefined @@ -1304,6 +1319,9 @@ export function Session() { {/* kilocode_change end */} + {/* kilocode_change start */} + + {/* kilocode_change end */} @@ -1662,6 +1680,11 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess + {/* kilocode_change start */} + + + + {/* kilocode_change end */} @@ -2082,6 +2105,23 @@ function WebSearch(props: ToolProps) { ) } +// kilocode_change start +function SemanticSearch(props: ToolProps) { + const meta = createMemo(() => props.metadata as { results?: { length: number }[] }) + const args = createMemo(() => props.input as { query?: string; path?: string }) + const count = createMemo(() => meta().results?.length ?? 0) + + return ( + + Codebase Search "{args().query}" in {normalizePath(args().path!)} + 0}> + ({count()} {count() === 1 ? "result" : "results"}) + + + ) +} +// kilocode_change end + function Task(props: ToolProps) { const { navigate } = useRoute() const sync = useSync() diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 3e0e037bd4b..155e9d52efc 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -9,6 +9,7 @@ import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" import { useSync } from "../../context/sync" import { useTextareaKeybindings } from "../../component/textarea-keybindings" +import { useProject } from "../../context/project" import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util" @@ -132,6 +133,7 @@ function TextBody(props: { title: string; description?: string; icon?: string }) export function PermissionPrompt(props: { request: PermissionRequest }) { const sdk = useSDK() + const project = useProject() const sync = useSync() const [store, setStore] = createStore({ stage: "permission" as PermissionStage, @@ -191,6 +193,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { void sdk.client.permission.reply({ reply: "always", requestID: props.request.id, + workspace: project.workspace.current(), }) }} /> @@ -202,6 +205,7 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { reply: "reject", requestID: props.request.id, message: message || undefined, + workspace: project.workspace.current(), }) }} onCancel={() => { @@ -467,12 +471,14 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { void sdk.client.permission.reply({ reply: "reject", requestID: props.request.id, + workspace: project.workspace.current(), }) return } void sdk.client.permission.reply({ reply: "once", requestID: props.request.id, + workspace: project.workspace.current(), }) }} /> diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 45b6195676d..123631addfe 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -19,6 +19,7 @@ import { createKiloClient } from "@kilocode/sdk/v2" // kilocode_change import { writeHeapSnapshot } from "v8" import { TuiConfig } from "./config/tui" import { KILO_PROCESS_ROLE, KILO_RUN_ID, ensureRunID, sanitizedProcessEnv } from "@/util/opencode-process" +import { validateSession } from "./validate-session" declare global { const KILO_WORKER_PATH: string @@ -285,6 +286,19 @@ export const TuiThreadCommand = cmd({ events: createEventSource(client), } + try { + await validateSession({ + url: transport.url, + sessionID: args.session, + directory: cwd, + fetch: transport.fetch, + }) + } catch (error) { + UI.error(errorMessage(error)) + process.exitCode = 1 + return + } + setTimeout(() => { client.call("checkUpgrade", { directory: cwd }).catch(() => {}) }, 1000).unref?.() diff --git a/packages/opencode/src/cli/cmd/tui/util/terminal.ts b/packages/opencode/src/cli/cmd/tui/util/terminal.ts index d0fe4b12da3..f57eb0b289c 100644 --- a/packages/opencode/src/cli/cmd/tui/util/terminal.ts +++ b/packages/opencode/src/cli/cmd/tui/util/terminal.ts @@ -41,9 +41,9 @@ function parse(color: string): RGBA | null { return null } -function mode(bg: RGBA | null): "dark" | "light" { - if (!bg) return "dark" - const luminance = (0.299 * bg.r + 0.587 * bg.g + 0.114 * bg.b) / 255 +function mode(background: RGBA | null): "dark" | "light" { + if (!background) return "dark" + const luminance = (0.299 * background.r + 0.587 * background.g + 0.114 * background.b) / 255 return luminance > 0.5 ? "light" : "dark" } diff --git a/packages/opencode/src/cli/cmd/tui/validate-session.ts b/packages/opencode/src/cli/cmd/tui/validate-session.ts new file mode 100644 index 00000000000..da94eb9ca24 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/validate-session.ts @@ -0,0 +1,24 @@ +import { createKiloClient } from "@kilocode/sdk/v2" +import { SessionID } from "@/session/schema" + +export async function validateSession(input: { + url: string + sessionID?: string + directory?: string + fetch?: typeof fetch + headers?: RequestInit["headers"] +}) { + if (!input.sessionID) return + + const result = SessionID.zod.safeParse(input.sessionID) + if (!result.success) { + throw new Error(`Invalid session ID: ${result.error.issues.at(0)?.message ?? "unknown error"}`) + } + + await createKiloClient({ + baseUrl: input.url, + directory: input.directory, + fetch: input.fetch, + headers: input.headers, + }).session.get({ sessionID: result.data }, { throwOnError: true }) +} diff --git a/packages/opencode/src/cli/upgrade.ts b/packages/opencode/src/cli/upgrade.ts index e56f57ebb84..ec623fcf81e 100644 --- a/packages/opencode/src/cli/upgrade.ts +++ b/packages/opencode/src/cli/upgrade.ts @@ -7,6 +7,7 @@ import { InstallationVersion } from "@/installation/version" export async function upgrade() { const config = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.getGlobal())) + if (config.autoupdate === false || Flag.KILO_DISABLE_AUTOUPDATE) return const method = await AppRuntime.runPromise(Installation.Service.use((svc) => svc.method())) // kilocode_change start - only auto-upgrade for npm/pnpm/bun (we only publish @kilocode/cli via npm registry) if (method !== "npm" && method !== "pnpm" && method !== "bun") return @@ -20,7 +21,6 @@ export async function upgrade() { } if (InstallationVersion === latest) return - if (config.autoupdate === false || Flag.KILO_DISABLE_AUTOUPDATE) return const kind = Installation.getReleaseType(InstallationVersion, latest) diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 3918507ee0a..8ae4d3f818f 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -3,7 +3,7 @@ export * as ConfigAgent from "./agent" import { Schema } from "effect" import z from "zod" import { Bus } from "@/bus" -import { zod, ZodOverride } from "@/util/effect-zod" +import { zod } from "@/util/effect-zod" import { Log } from "../util" import { NamedError } from "@opencode-ai/shared/util/error" import { Glob } from "@opencode-ai/shared/util/glob" @@ -25,12 +25,6 @@ const Color = Schema.Union([ Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), ]) -// ConfigPermission.Info is a zod schema (its `.preprocess(...).transform(...)` -// shape lives outside the Effect Schema type system), so the walker reaches it -// via ZodOverride rather than a pure Schema reference. This preserves the -// `$ref: PermissionConfig` emitted in openapi.json. -const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info }) - const AgentSchema = Schema.StructWithRest( Schema.Struct({ model: Schema.optional(Schema.NullOr(ConfigModelID)), // kilocode_change - nullable for delete sentinel @@ -57,7 +51,7 @@ const AgentSchema = Schema.StructWithRest( description: "Maximum number of agentic iterations before forcing text-only response", }), maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }), - permission: Schema.optional(PermissionRef), + permission: Schema.optional(ConfigPermission.Info), }), [Schema.Record(Schema.String, Schema.Any)], ) @@ -96,7 +90,7 @@ const normalize = (agent: z.infer) => { const permission: ConfigPermission.Info = {} for (const [tool, enabled] of Object.entries(agent.tools ?? {})) { const action = enabled ? "allow" : "deny" - if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { + if (tool === "write" || tool === "edit" || tool === "patch") { permission.edit = action continue } @@ -104,7 +98,8 @@ const normalize = (agent: z.infer) => { } globalThis.Object.assign(permission, agent.permission) - return { ...agent, options, permission, steps: agent.steps ?? agent.maxSteps } + const steps = agent.steps ?? agent.maxSteps + return { ...agent, options, permission, ...(steps !== undefined ? { steps } : {}) } } export const Info = zod(AgentSchema).transform(normalize).meta({ ref: "AgentConfig" }) as unknown as z.ZodType< diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1c59befd81d..d73930a9d2b 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -25,6 +25,7 @@ import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "e import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" import { InstanceRef } from "@/effect/instance-ref" import { zod, ZodOverride } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" import { ConfigAgent } from "./agent" import { ConfigCommand } from "./command" import { ConfigFormatter } from "./formatter" @@ -44,6 +45,8 @@ import { ConfigVariable } from "./variable" import { Npm } from "@/npm" // kilocode_change start import { KilocodeConfig } from "../kilocode/config/config" +import { KilocodeDefaultPlugins } from "@/kilocode/config/default-plugins" // kilocode_change +import { IndexingConfig as KiloIndexingConfig } from "@kilocode/kilo-indexing/config" // kilocode_change import { makeRuntime } from "@/effect/run-service" import { unique } from "remeda" // kilocode_change end @@ -96,18 +99,31 @@ export const Server = ConfigServer.Server.zod export const Layout = ConfigLayout.Layout.zod export type Layout = ConfigLayout.Layout +// kilocode_change start - indexing configuration +export const Indexing = KiloIndexingConfig +export type Indexing = z.infer +// kilocode_change end + // Schemas that still live at the zod layer (have .transform / .preprocess / // .meta not expressible in current Effect Schema) get referenced via a // ZodOverride-annotated Schema.Any. Walker sees the annotation and emits the // exact zod directly, preserving component $refs. const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info }) -const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info }) const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level }) +const IndexingRef = Schema.Any.annotate({ [ZodOverride]: KiloIndexingConfig }) // kilocode_change const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)) const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)) -const InfoSchema = Schema.Struct({ +// The Effect Schema is the canonical source of truth. The `.zod` compatibility +// surface is derived so existing Hono validators keep working without a parallel +// Zod definition. +// +// The walker emits `z.object({...})` which is non-strict by default. Config +// historically uses `.strict()` (additionalProperties: false in openapi.json), +// so layer that on after derivation. Re-apply the Config ref afterward +// since `.strict()` strips the walker's meta annotation. +export const Info = Schema.Struct({ $schema: Schema.optional(Schema.String).annotate({ description: "JSON schema reference for configuration validation", }), @@ -154,6 +170,7 @@ const InfoSchema = Schema.Struct({ remote_control: Schema.optional(Schema.Boolean).annotate({ description: "Enable remote control of sessions via Kilo Cloud. Equivalent to running /remote on startup.", }), + indexing: Schema.optional(IndexingRef).annotate({ description: "Codebase indexing configuration" }), // kilocode_change // kilocode_change end // kilocode_change start - nullable for delete sentinel model: Schema.optional(Schema.NullOr(ConfigModelID)).annotate({ @@ -220,7 +237,7 @@ const InfoSchema = Schema.Struct({ description: "Additional instruction files or patterns to include", }), layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), - permission: Schema.optional(PermissionRef), + permission: Schema.optional(ConfigPermission.Info), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), enterprise: Schema.optional( Schema.Struct({ @@ -236,6 +253,13 @@ const InfoSchema = Schema.Struct({ prune: Schema.optional(Schema.Boolean).annotate({ description: "Enable pruning of old tool outputs (default: true)", }), + tail_turns: Schema.optional(NonNegativeInt).annotate({ + description: + "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)", + }), + preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({ + description: "Maximum number of tokens from recent turns to preserve verbatim after compaction", + }), reserved: Schema.optional(NonNegativeInt).annotate({ description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", }), @@ -246,6 +270,11 @@ const InfoSchema = Schema.Struct({ disable_paste_summary: Schema.optional(Schema.Boolean), batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }), codebase_search: Schema.optional(Schema.Boolean).annotate({ description: "Enable AI-powered codebase search" }), // kilocode_change + // kilocode_change start + semantic_indexing: Schema.optional(Schema.Boolean).annotate({ + description: "Enable semantic codebase indexing and the semantic_search tool", + }), + // kilocode_change end // kilocode_change start - enable telemetry by default openTelemetry: Schema.Boolean.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(true))).annotate({ description: "Enable telemetry. Set to false to opt-out.", @@ -263,6 +292,14 @@ const InfoSchema = Schema.Struct({ }), ), }) + .annotate({ identifier: "Config" }) + .pipe( + withStatics((s) => ({ + zod: (zod(s) as unknown as z.ZodObject).strict().meta({ ref: "Config" }) as unknown as z.ZodType< + DeepMutable> + >, + })), + ) // Schema.Struct produces readonly types by default, but the service code // below mutates Info objects directly (e.g. `config.mode = ...`). Strip the @@ -284,15 +321,7 @@ type DeepMutable = T extends readonly [unknown, ...unknown[]] ? { -readonly [K in keyof T]: DeepMutable } : T -// The walker emits `z.object({...})` which is non-strict by default. Config -// historically uses `.strict()` (additionalProperties: false in openapi.json), -// so layer that on after derivation. Re-apply the Config ref afterward -// since `.strict()` strips the walker's meta annotation. -export const Info = (zod(InfoSchema) as unknown as z.ZodObject) - .strict() - .meta({ ref: "Config" }) as unknown as z.ZodType>> - -export type Info = z.output & { +export type Info = DeepMutable> & { // plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together // with the file and scope it came from so later runtime code can make location-sensitive decisions. plugin_origins?: ConfigPlugin.Origin[] @@ -414,7 +443,7 @@ export const layer = Layer.effect( ), ) const parsed = ConfigParse.jsonc(expanded, source) - const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source) + const data = ConfigParse.schema(Info.zod, normalizeLoadedConfig(parsed, source), source) if (!("path" in options)) return data yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) @@ -666,9 +695,8 @@ export const layer = Layer.effect( const deps: Fiber.Fiber[] = [] + // kilocode_change start for (const dir of unique(directories)) { - // kilocode_change - // kilocode_change start if (KilocodeConfig.isConfigDir(dir, Flag.KILO_CONFIG_DIR)) { for (const file of KilocodeConfig.ALL_CONFIG_FILES) { const source = path.join(dir, file) @@ -826,7 +854,7 @@ export const layer = Layer.effect( const perms: Record = {} for (const [tool, enabled] of Object.entries(result.tools)) { const action: ConfigPermission.Action = enabled ? "allow" : "deny" - if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") { + if (tool === "write" || tool === "edit" || tool === "patch") { perms.edit = action continue } @@ -847,6 +875,9 @@ export const layer = Layer.effect( if (Flag.KILO_DISABLE_PRUNE) { result.compaction = { ...result.compaction, prune: false } } + // kilocode_change start — inject Kilo default plugins into both plugin list and origins + KilocodeDefaultPlugins.apply(result, { disabled: Flag.KILO_DISABLE_DEFAULT_PLUGINS, log }) + // kilocode_change end return { config: result, @@ -932,13 +963,13 @@ export const layer = Layer.effect( let next: Info if (!file.endsWith(".jsonc")) { - const existing = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file) + const existing = ConfigParse.schema(Info.zod, ConfigParse.jsonc(before, file), file) const merged = KilocodeConfig.mergeConfig(writable(existing), writable(config)) // kilocode_change yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) next = merged } else { const updated = patchJsonc(before, writable(config)) - next = ConfigParse.schema(Info, ConfigParse.jsonc(updated, file), file) + next = ConfigParse.schema(Info.zod, ConfigParse.jsonc(updated, file), file) yield* fs.writeFileString(file, updated).pipe(Effect.orDie) } diff --git a/packages/opencode/src/config/mcp.ts b/packages/opencode/src/config/mcp.ts index 8b77bc4c286..0887fa984ab 100644 --- a/packages/opencode/src/config/mcp.ts +++ b/packages/opencode/src/config/mcp.ts @@ -2,7 +2,7 @@ import { Schema } from "effect" import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" -export class Local extends Schema.Class("McpLocalConfig")({ +export const Local = Schema.Struct({ type: Schema.Literal("local").annotate({ description: "Type of MCP server connection" }), command: Schema.mutable(Schema.Array(Schema.String)).annotate({ description: "Command and arguments to run the MCP server", @@ -16,11 +16,12 @@ export class Local extends Schema.Class("McpLocalConfig")({ timeout: Schema.optional(Schema.Number).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), -}) { - static readonly zod = zod(this) -} +}) + .annotate({ identifier: "McpLocalConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Local = Schema.Schema.Type -export class OAuth extends Schema.Class("McpOAuthConfig")({ +export const OAuth = Schema.Struct({ clientId: Schema.optional(Schema.String).annotate({ description: "OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted.", }), @@ -31,11 +32,12 @@ export class OAuth extends Schema.Class("McpOAuthConfig")({ redirectUri: Schema.optional(Schema.String).annotate({ description: "OAuth redirect URI (default: http://127.0.0.1:19876/mcp/oauth/callback).", }), -}) { - static readonly zod = zod(this) -} +}) + .annotate({ identifier: "McpOAuthConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type OAuth = Schema.Schema.Type -export class Remote extends Schema.Class("McpRemoteConfig")({ +export const Remote = Schema.Struct({ type: Schema.Literal("remote").annotate({ description: "Type of MCP server connection" }), url: Schema.String.annotate({ description: "URL of the remote MCP server" }), enabled: Schema.optional(Schema.Boolean).annotate({ @@ -50,9 +52,10 @@ export class Remote extends Schema.Class("McpRemoteConfig")({ timeout: Schema.optional(Schema.Number).annotate({ description: "Timeout in ms for MCP server requests. Defaults to 5000 (5 seconds) if not specified.", }), -}) { - static readonly zod = zod(this) -} +}) + .annotate({ identifier: "McpRemoteConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Remote = Schema.Schema.Type export const Info = Schema.Union([Local, Remote]) .annotate({ discriminator: "type" }) diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index 632ea571cdf..f2ced3112e2 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -1,6 +1,6 @@ export * as ConfigPermission from "./permission" -import { Schema } from "effect" -import { zod, ZodPreprocess } from "@/util/effect-zod" +import { Schema, SchemaGetter } from "effect" +import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" export const Action = Schema.NullOr(Schema.Literals(["ask", "allow", "deny"])) // kilocode_change - nullable allows null as a delete sentinel @@ -18,21 +18,19 @@ export const Rule = Schema.Union([Action, Object]) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type Rule = Schema.Schema.Type -// Captures the user's original property insertion order before Schema.Struct -// canonicalises the object. See the `ZodPreprocess` comment in -// `util/effect-zod.ts` for the full rationale — in short: rule precedence is -// encoded in JSON key order (`evaluate.ts` uses `findLast`, so later keys win) -// and `Schema.StructWithRest` would otherwise drop that order. -const permissionPreprocess = (val: unknown) => { - if (typeof val === "object" && val !== null && !Array.isArray(val)) { - return { __originalKeys: globalThis.Object.keys(val), ...val } - } - return val -} - -const ObjectShape = Schema.StructWithRest( +// Known permission keys get explicit types — most are full Rule (either a +// single Action or a per-pattern object), but a handful of tools take no +// sub-target patterns and are Action-only. Unknown keys fall through the +// Record rest signature as Rule. +// +// StructWithRest canonicalises key order on decode (known first, then rest), +// which used to require the `__originalKeys` preprocess hack because +// `Permission.fromConfig` depended on the user's insertion order. That +// dependency is gone — `fromConfig` now sorts top-level keys so wildcard +// permissions come before specifics, making the final precedence +// order-independent. +const InputObject = Schema.StructWithRest( Schema.Struct({ - __originalKeys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), read: Schema.optional(Rule), edit: Schema.optional(Rule), glob: Schema.optional(Rule), @@ -53,24 +51,29 @@ const ObjectShape = Schema.StructWithRest( [Schema.Record(Schema.String, Rule)], ) -const InnerSchema = Schema.Union([ObjectShape, Action]).annotate({ - [ZodPreprocess]: permissionPreprocess, -}) +// Input the user writes in config: either a single Action (shorthand for "*") +// or an object of per-target rules. +const InputSchema = Schema.Union([Action, InputObject]) -// Post-parse: drop the __originalKeys metadata and rebuild the rule map in the -// user's original insertion order. A plain string input (the Action branch of -// the union) becomes `{ "*": action }`. -const transform = (x: unknown): Record => { - if (typeof x === "string") return { "*": x as Action } - const obj = x as { __originalKeys?: string[] } & Record - const { __originalKeys, ...rest } = obj - if (!__originalKeys) return rest as Record - const result: Record = {} - for (const key of __originalKeys) { - if (key in rest) result[key] = rest[key] as Rule - } - return result -} +// Normalise the Action shorthand into `{ "*": action }`. Object inputs pass +// through untouched. +const normalizeInput = (input: Schema.Schema.Type): Schema.Schema.Type => + input === null || typeof input === "string" ? { "*": input } : input // kilocode_change -export const Info = zod(InnerSchema).transform(transform).meta({ ref: "PermissionConfig" }) -export type Info = Record +export const Info = InputSchema.pipe( + Schema.decodeTo(InputObject, { + decode: SchemaGetter.transform(normalizeInput), + // Not perfectly invertible (we lose whether the user originally typed an + // Action shorthand), but the object form is always a valid representation + // of the same rules. + encode: SchemaGetter.passthrough({ strict: false }), + }), +) + .annotate({ identifier: "PermissionConfig" }) + .pipe( + // Walker already emits the decodeTo transform into the derived zod (see + // `encoded()` in effect-zod.ts), so just expose that directly. + withStatics((s) => ({ zod: zod(s) })), + ) +type _Info = Schema.Schema.Type +export type Info = { -readonly [K in keyof _Info]: _Info[K] } diff --git a/packages/opencode/src/config/provider.ts b/packages/opencode/src/config/provider.ts index d17606fe60f..e6305449787 100644 --- a/packages/opencode/src/config/provider.ts +++ b/packages/opencode/src/config/provider.ts @@ -60,7 +60,8 @@ export const Model = Schema.Struct({ variants: Schema.optional( Schema.Record( Schema.String, - Schema.NullOr( // kilocode_change - allow null values so removed variants can be deleted via stripNulls on save + Schema.NullOr( + // kilocode_change - allow null values so removed variants can be deleted via stripNulls on save Schema.StructWithRest( Schema.Struct({ disabled: Schema.optional(Schema.Boolean).annotate({ description: "Disable this variant for the model" }), @@ -72,7 +73,7 @@ export const Model = Schema.Struct({ ), }).pipe(withStatics((s) => ({ zod: zod(s) }))) -export class Info extends Schema.Class("ProviderConfig")({ +export const Info = Schema.Struct({ api: Schema.optional(Schema.String), name: Schema.optional(Schema.String), env: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), @@ -109,8 +110,9 @@ export class Info extends Schema.Class("ProviderConfig")({ ), ), models: Schema.optional(Schema.Record(Schema.String, Schema.NullOr(Model))), // kilocode_change - allow null values so removed models can be deleted via stripNulls on save -}) { - static readonly zod = zod(this) -} +}) + .annotate({ identifier: "ProviderConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type export * as ConfigProvider from "./provider" diff --git a/packages/opencode/src/config/server.ts b/packages/opencode/src/config/server.ts index 5d1171f2456..8ba60c3e585 100644 --- a/packages/opencode/src/config/server.ts +++ b/packages/opencode/src/config/server.ts @@ -1,7 +1,8 @@ import { Schema } from "effect" import { zod } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -export class Server extends Schema.Class("ServerConfig")({ +export const Server = Schema.Struct({ port: Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))).annotate({ description: "Port to listen on", }), @@ -13,8 +14,9 @@ export class Server extends Schema.Class("ServerConfig")({ cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Additional domains to allow for CORS", }), -}) { - static readonly zod = zod(this) -} +}) + .annotate({ identifier: "ServerConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Server = Schema.Schema.Type export * as ConfigServer from "./server" diff --git a/packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts b/packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts new file mode 100644 index 00000000000..a8265c5da9b --- /dev/null +++ b/packages/opencode/src/control-plane/dev/debug-workspace-plugin.ts @@ -0,0 +1,73 @@ +import type { Plugin } from "@kilocode/plugin" +import { rename, writeFile } from "node:fs/promises" +import { randomInt } from "node:crypto" +import { setTimeout as sleep } from "node:timers/promises" + +const DEV_DATA_FILE = "/tmp/opencode-workspace-dev-data.json" +const DEV_DATA_TEMP_FILE = `${DEV_DATA_FILE}.tmp` + +async function waitForHealth(port: number) { + const url = `http://127.0.0.1:${port}/global/health` + const started = Date.now() + + while (Date.now() - started < 30_000) { + try { + const response = await fetch(url) + if (response.ok) { + return + } + } catch {} + + await sleep(250) + } + + throw new Error(`Timed out waiting for debug server health check at ${url}`) +} + +let PORT: number | undefined + +async function writeDebugData(port: number, id: string, env: Record) { + await writeFile( + DEV_DATA_TEMP_FILE, + JSON.stringify( + { + port, + id, + env, + }, + null, + 2, + ), + ) + + await rename(DEV_DATA_TEMP_FILE, DEV_DATA_FILE) +} + +export const DebugWorkspacePlugin: Plugin = async ({ experimental_workspace }) => { + experimental_workspace.register("debug", { + name: "Debug", + description: "Create a debugging server", + configure(config) { + return config + }, + async create(config, env) { + const port = randomInt(5000, 9001) + PORT = port + + await writeDebugData(port, config.id, env) + + await waitForHealth(port) + }, + async remove(_config) {}, + target(_config) { + return { + type: "remote", + url: `http://localhost:${PORT!}/`, + } + }, + }) + + return {} +} + +export default DebugWorkspacePlugin diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index 4c7ced010d6..5a0850a2495 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -1,8 +1,7 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" -import { ZodOverride } from "@/util/effect-zod" +import { zod, ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe( @@ -14,6 +13,6 @@ export type WorkspaceID = typeof workspaceIdSchema.Type export const WorkspaceID = workspaceIdSchema.pipe( withStatics((schema: typeof workspaceIdSchema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)), - zod: Identifier.schema("workspace").pipe(z.custom()), + zod: zod(schema), })), ) diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 5f909bfd1a8..0200401113b 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -1,15 +1,29 @@ -import fs from "fs/promises" import path from "path" -import { fileURLToPath } from "url" import z from "zod" -import { Cause, Context, Effect, Layer, Queue, Stream } from "effect" -import { ripgrep } from "ripgrep" -import { Filesystem } from "@/util" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { Cause, Context, Effect, Fiber, Layer, Queue, Stream } from "effect" +import type { PlatformError } from "effect/PlatformError" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { ChildProcess } from "effect/unstable/process" +import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner" + +import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner" +import { Global } from "@/global" import { Log } from "@/util" import { sanitizedProcessEnv } from "@/util/opencode-process" -import { KiloRipgrepStream } from "../kilocode/kilo-ripgrep-stream" // kilocode_change - UTF-8 safe stream decoder shared with worker +import { which } from "@/util/which" const log = Log.create({ service: "ripgrep" }) +const VERSION = "15.1.0" +const PLATFORM = { + "arm64-darwin": { platform: "aarch64-apple-darwin", extension: "tar.gz" }, + "arm64-linux": { platform: "aarch64-unknown-linux-gnu", extension: "tar.gz" }, + "x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" }, + "x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" }, + "arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" }, + "ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" }, + "x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" }, +} as const const Stats = z.object({ elapsed: z.object({ @@ -121,62 +135,20 @@ export interface TreeInput { } export interface Interface { - readonly files: (input: FilesInput) => Stream.Stream - readonly tree: (input: TreeInput) => Effect.Effect - readonly search: (input: SearchInput) => Effect.Effect + readonly files: (input: FilesInput) => Stream.Stream + readonly tree: (input: TreeInput) => Effect.Effect + readonly search: (input: SearchInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/Ripgrep") {} -type Run = { kind: "files" | "search"; cwd: string; args: string[] } - -type WorkerResult = { - type: "result" - code: number - stdout: string - stderr: string -} - -type WorkerLine = { - type: "line" - line: string -} - -type WorkerDone = { - type: "done" - code: number - stderr: string -} - -type WorkerError = { - type: "error" - error: { - message: string - name?: string - stack?: string - } -} - function env() { const env = sanitizedProcessEnv() delete env.RIPGREP_CONFIG_PATH return env } -function text(input: unknown) { - if (typeof input === "string") return input - if (input instanceof ArrayBuffer) return Buffer.from(input).toString() - if (ArrayBuffer.isView(input)) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString() - return String(input) -} - -function toError(input: unknown) { - if (input instanceof Error) return input - if (typeof input === "string") return new Error(input) - return new Error(String(input)) -} - -function abort(signal?: AbortSignal) { +function aborted(signal?: AbortSignal) { const err = signal?.reason if (err instanceof Error) return err const out = new Error("Aborted") @@ -184,6 +156,16 @@ function abort(signal?: AbortSignal) { return out } +function waitForAbort(signal?: AbortSignal) { + if (!signal) return Effect.never + if (signal.aborted) return Effect.fail(aborted(signal)) + return Effect.callback((resume) => { + const onabort = () => resume(Effect.fail(aborted(signal))) + signal.addEventListener("abort", onabort, { once: true }) + return Effect.sync(() => signal.removeEventListener("abort", onabort)) + }) +} + function error(stderr: string, code: number) { const err = new Error(stderr.trim() || `ripgrep failed with code ${code}`) err.name = "RipgrepError" @@ -204,376 +186,300 @@ function row(data: Row): Row { } } -function opts(cwd: string) { - return { - env: env(), - preopens: { ".": cwd }, - } +function parse(line: string) { + return Effect.try({ + try: () => Result.parse(JSON.parse(line)), + catch: (cause) => new Error("invalid ripgrep output", { cause }), + }) } -function check(cwd: string) { - return Effect.tryPromise({ - try: () => fs.stat(cwd).catch(() => undefined), - catch: toError, - }).pipe( - Effect.flatMap((stat) => - stat?.isDirectory() - ? Effect.void - : Effect.fail( - Object.assign(new Error(`No such file or directory: '${cwd}'`), { - code: "ENOENT", - errno: -2, - path: cwd, - }), - ), - ), - ) +function fail(queue: Queue.Queue, err: PlatformError | Error) { + Queue.failCauseUnsafe(queue, Cause.fail(err)) } function filesArgs(input: FilesInput) { - const args = ["--files", "--glob=!.git/*"] + const args = ["--no-config", "--files", "--glob=!.git/*"] if (input.follow) args.push("--follow") if (input.hidden !== false) args.push("--hidden") + if (input.hidden === false) args.push("--glob=!.*") if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`) if (input.glob) { - for (const glob of input.glob) { - args.push(`--glob=${glob}`) - } + for (const glob of input.glob) args.push(`--glob=${glob}`) } args.push(".") return args } function searchArgs(input: SearchInput) { - const args = ["--json", "--hidden", "--glob=!.git/*", "--no-messages"] + const args = ["--no-config", "--json", "--hidden", "--glob=!.git/*", "--no-messages"] if (input.follow) args.push("--follow") if (input.glob) { - for (const glob of input.glob) { - args.push(`--glob=${glob}`) - } + for (const glob of input.glob) args.push(`--glob=${glob}`) } if (input.limit) args.push(`--max-count=${input.limit}`) args.push("--", input.pattern, ...(input.file ?? ["."])) return args } -function parse(stdout: string) { - return stdout - .trim() - .split(/\r?\n/) - .filter(Boolean) - .map((line) => Result.parse(JSON.parse(line))) - .flatMap((item) => (item.type === "match" ? [row(item.data)] : [])) +function raceAbort(effect: Effect.Effect, signal?: AbortSignal) { + return signal ? effect.pipe(Effect.raceFirst(waitForAbort(signal))) : effect } -declare const KILO_RIPGREP_WORKER_PATH: string +export const layer: Layer.Layer = + Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient) + const spawner = yield* ChildProcessSpawner -function target(): Effect.Effect { - if (typeof KILO_RIPGREP_WORKER_PATH !== "undefined") { - return Effect.succeed(KILO_RIPGREP_WORKER_PATH) - } - const js = new URL("./ripgrep.worker.js", import.meta.url) - return Effect.tryPromise({ - try: () => Filesystem.exists(fileURLToPath(js)), - catch: toError, - }).pipe(Effect.map((exists) => (exists ? js : new URL("./ripgrep.worker.ts", import.meta.url)))) -} + const run = Effect.fnUntraced(function* (command: string, args: string[], opts?: { cwd?: string }) { + const handle = yield* spawner.spawn( + ChildProcess.make(command, args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }), + ) + const [stdout, stderr, code] = yield* Effect.all( + [ + Stream.mkString(Stream.decodeText(handle.stdout)), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ) + return { stdout, stderr, code } + }, Effect.scoped) -function worker() { - return target().pipe(Effect.flatMap((file) => Effect.sync(() => new Worker(file, { env: env() })))) -} + const extract = Effect.fnUntraced(function* ( + archive: string, + config: (typeof PLATFORM)[keyof typeof PLATFORM], + target: string, + ) { + const dir = yield* fs.makeTempDirectoryScoped({ directory: Global.Path.bin, prefix: "ripgrep-" }) -// kilocode_change start - delegate to KiloRipgrepStream for UTF-8 safe decoding -function drain( - dec: ReturnType, - buf: string, - chunk: unknown, - push: (line: string) => void, -) { - return KiloRipgrepStream.drain(dec, buf, chunk, push) -} -// kilocode_change end - -function fail(queue: Queue.Queue, err: Error) { - Queue.failCauseUnsafe(queue, Cause.fail(err)) -} - -function searchDirect(input: SearchInput) { - return Effect.tryPromise({ - try: () => - ripgrep(searchArgs(input), { - buffer: true, - ...opts(input.cwd), - }), - catch: toError, - }).pipe( - Effect.flatMap((ret) => { - const out = ret.stdout ?? "" - if (ret.code !== 0 && ret.code !== 1 && ret.code !== 2) { - return Effect.fail(error(ret.stderr ?? "", ret.code ?? 1)) - } - return Effect.sync(() => ({ - items: ret.code === 1 ? [] : parse(out), - partial: ret.code === 2, - })) - }), - ) -} - -function searchWorker(input: SearchInput) { - if (input.signal?.aborted) return Effect.fail(abort(input.signal)) - - return Effect.acquireUseRelease( - worker(), - (w) => - Effect.callback((resume, signal) => { - let open = true - const done = (effect: Effect.Effect) => { - if (!open) return - open = false - resume(effect) + if (config.extension === "zip") { + const shell = (yield* Effect.sync(() => which("powershell.exe") ?? which("pwsh.exe"))) ?? "powershell.exe" + const result = yield* run(shell, [ + "-NoProfile", + "-NonInteractive", + "-Command", + `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -LiteralPath '${archive.replaceAll("'", "''")}' -DestinationPath '${dir.replaceAll("'", "''")}' -Force`, + ]) + if (result.code !== 0) { + return yield* Effect.fail(error(result.stderr || result.stdout, result.code)) + } } - const onabort = () => done(Effect.fail(abort(input.signal))) - w.onerror = (evt) => { - done(Effect.fail(toError(evt.error ?? evt.message))) + if (config.extension === "tar.gz") { + const result = yield* run("tar", ["-xzf", archive, "-C", dir]) + if (result.code !== 0) { + return yield* Effect.fail(error(result.stderr || result.stdout, result.code)) + } } - w.onmessage = (evt: MessageEvent) => { - const msg = evt.data - if (msg.type === "error") { - done(Effect.fail(Object.assign(new Error(msg.error.message), msg.error))) - return + + const extracted = path.join( + dir, + `ripgrep-${VERSION}-${config.platform}`, + process.platform === "win32" ? "rg.exe" : "rg", + ) + if (!(yield* fs.isFile(extracted))) { + return yield* Effect.fail(new Error(`ripgrep archive did not contain executable: ${extracted}`)) + } + + yield* fs.copyFile(extracted, target) + if (process.platform === "win32") return + yield* fs.chmod(target, 0o755) + }, Effect.scoped) + + const filepath = yield* Effect.cached( + Effect.gen(function* () { + const system = yield* Effect.sync(() => which(process.platform === "win32" ? "rg.exe" : "rg")) + if (system && (yield* fs.isFile(system).pipe(Effect.orDie))) return system + + const target = path.join(Global.Path.bin, `rg${process.platform === "win32" ? ".exe" : ""}`) + if (yield* fs.isFile(target).pipe(Effect.orDie)) return target + + const platformKey = `${process.arch}-${process.platform}` as keyof typeof PLATFORM + const config = PLATFORM[platformKey] + if (!config) { + return yield* Effect.fail(new Error(`unsupported platform for ripgrep: ${platformKey}`)) } - if (msg.code === 1) { - done(Effect.succeed({ items: [], partial: false })) - return + + const filename = `ripgrep-${VERSION}-${config.platform}.${config.extension}` + const url = `https://github.com/BurntSushi/ripgrep/releases/download/${VERSION}/${filename}` + const archive = path.join(Global.Path.bin, filename) + + log.info("downloading ripgrep", { url }) + yield* fs.ensureDir(Global.Path.bin).pipe(Effect.orDie) + + const bytes = yield* HttpClientRequest.get(url).pipe( + http.execute, + Effect.flatMap((response) => response.arrayBuffer), + Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause)))), + ) + if (bytes.byteLength === 0) { + return yield* Effect.fail(new Error(`failed to download ripgrep from ${url}`)) } - if (msg.code !== 0 && msg.code !== 1 && msg.code !== 2) { - done(Effect.fail(error(msg.stderr, msg.code))) - return + + yield* fs.writeWithDirs(archive, new Uint8Array(bytes)) + yield* extract(archive, config, target) + yield* fs.remove(archive, { force: true }).pipe(Effect.ignore) + return target + }), + ) + + const check = Effect.fnUntraced(function* (cwd: string) { + if (yield* fs.isDir(cwd).pipe(Effect.orDie)) return + return yield* Effect.fail( + Object.assign(new Error(`No such file or directory: '${cwd}'`), { + code: "ENOENT", + errno: -2, + path: cwd, + }), + ) + }) + + const command = Effect.fnUntraced(function* (cwd: string, args: string[]) { + const binary = yield* filepath + return ChildProcess.make(binary, args, { + cwd, + env: env(), + extendEnv: true, + stdin: "ignore", + }) + }) + + const files: Interface["files"] = (input) => + Stream.callback((queue) => + Effect.gen(function* () { + yield* Effect.forkScoped( + Effect.gen(function* () { + yield* check(input.cwd) + const handle = yield* spawner.spawn(yield* command(input.cwd, filesArgs(input))) + const stderr = yield* Stream.mkString(Stream.decodeText(handle.stderr)).pipe(Effect.forkScoped) + const stdout = yield* Stream.decodeText(handle.stdout).pipe( + Stream.splitLines, + Stream.filter((line) => line.length > 0), + Stream.runForEach((line) => Effect.sync(() => Queue.offerUnsafe(queue, clean(line)))), + Effect.forkScoped, + ) + const code = yield* raceAbort(handle.exitCode, input.signal) + yield* Fiber.join(stdout) + if (code === 0 || code === 1) { + Queue.endUnsafe(queue) + return + } + fail(queue, error(yield* Fiber.join(stderr), code)) + }).pipe( + Effect.catch((err) => + Effect.sync(() => { + fail(queue, err) + }), + ), + ), + ) + }), + ) + + const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) { + yield* check(input.cwd) + + const program = Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn(yield* command(input.cwd, searchArgs(input))) + + const [items, stderr, code] = yield* Effect.all( + [ + Stream.decodeText(handle.stdout).pipe( + Stream.splitLines, + Stream.filter((line) => line.length > 0), + Stream.mapEffect(parse), + Stream.filter((item): item is Match => item.type === "match"), + Stream.map((item) => row(item.data)), + Stream.runCollect, + Effect.map((chunk) => [...chunk]), + ), + Stream.mkString(Stream.decodeText(handle.stderr)), + handle.exitCode, + ], + { concurrency: "unbounded" }, + ) + + if (code !== 0 && code !== 1 && code !== 2) { + return yield* Effect.fail(error(stderr, code)) + } + + return { + items: code === 1 ? [] : items, + partial: code === 2, + } + }), + ) + + return yield* raceAbort(program, input.signal) + }) + + const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) { + log.info("tree", input) + const list = Array.from(yield* files({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect)) + + interface Node { + name: string + children: Map + } + + function child(node: Node, name: string) { + const item = node.children.get(name) + if (item) return item + const next = { name, children: new Map() } + node.children.set(name, next) + return next + } + + function count(node: Node): number { + return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0) + } + + const root: Node = { name: "", children: new Map() } + for (const file of list) { + if (file.includes(".kilo") || file.includes(".opencode")) continue // kilocode_change + const parts = file.split(path.sep) + if (parts.length < 2) continue + let node = root + for (const part of parts.slice(0, -1)) { + node = child(node, part) } - done( - Effect.sync(() => ({ - items: parse(msg.stdout), - partial: msg.code === 2, - })), + } + + const total = count(root) + const limit = input.limit ?? total + const lines: string[] = [] + const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((node) => ({ node, path: node.name })) + + let used = 0 + for (let i = 0; i < queue.length && used < limit; i++) { + const item = queue[i] + lines.push(item.path) + used++ + queue.push( + ...Array.from(item.node.children.values()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((node) => ({ node, path: `${item.path}/${node.name}` })), ) } - input.signal?.addEventListener("abort", onabort, { once: true }) - signal.addEventListener("abort", onabort, { once: true }) - w.postMessage({ - kind: "search", - cwd: input.cwd, - args: searchArgs(input), - } satisfies Run) + if (total > used) lines.push(`[${total - used} truncated]`) + return lines.join("\n") + }) - return Effect.sync(() => { - input.signal?.removeEventListener("abort", onabort) - signal.removeEventListener("abort", onabort) - w.onerror = null - w.onmessage = null - }) - }), - (w) => Effect.sync(() => w.terminate()), - ) -} - -function filesDirect(input: FilesInput) { - return Stream.callback( - Effect.fnUntraced(function* (queue: Queue.Queue) { - let buf = "" - let err = "" - // kilocode_change start - const decoder = KiloRipgrepStream.decoder() - const out = { - write(chunk: unknown) { - buf = drain(decoder, buf, chunk, (line) => { - Queue.offerUnsafe(queue, clean(line)) - }) - }, - } - // kilocode_change end - - const stderr = { - write(chunk: unknown) { - err += text(chunk) - }, - } - - yield* Effect.forkScoped( - Effect.gen(function* () { - yield* check(input.cwd) - const ret = yield* Effect.tryPromise({ - try: () => - ripgrep(filesArgs(input), { - stdout: out, - stderr, - ...opts(input.cwd), - }), - catch: toError, - }) - buf += decoder.end() // kilocode_change - if (buf) Queue.offerUnsafe(queue, clean(buf)) - if (ret.code === 0 || ret.code === 1) { - Queue.endUnsafe(queue) - return - } - fail(queue, error(err, ret.code ?? 1)) - }).pipe( - Effect.catch((err) => - Effect.sync(() => { - fail(queue, err) - }), - ), - ), - ) + return Service.of({ files, tree, search }) }), ) -} -function filesWorker(input: FilesInput) { - return Stream.callback( - Effect.fnUntraced(function* (queue: Queue.Queue) { - if (input.signal?.aborted) { - fail(queue, abort(input.signal)) - return - } - - const w = yield* Effect.acquireRelease(worker(), (w) => Effect.sync(() => w.terminate())) - let open = true - const close = () => { - if (!open) return false - open = false - return true - } - const onabort = () => { - if (!close()) return - fail(queue, abort(input.signal)) - } - - w.onerror = (evt) => { - if (!close()) return - fail(queue, toError(evt.error ?? evt.message)) - } - w.onmessage = (evt: MessageEvent) => { - const msg = evt.data - if (msg.type === "line") { - if (open) Queue.offerUnsafe(queue, msg.line) - return - } - if (!close()) return - if (msg.type === "error") { - fail(queue, Object.assign(new Error(msg.error.message), msg.error)) - return - } - if (msg.code === 0 || msg.code === 1) { - Queue.endUnsafe(queue) - return - } - fail(queue, error(msg.stderr, msg.code)) - } - - yield* Effect.acquireRelease( - Effect.sync(() => { - input.signal?.addEventListener("abort", onabort, { once: true }) - w.postMessage({ - kind: "files", - cwd: input.cwd, - args: filesArgs(input), - } satisfies Run) - }), - () => - Effect.sync(() => { - input.signal?.removeEventListener("abort", onabort) - w.onerror = null - w.onmessage = null - }), - ) - }), - ) -} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const source = (input: FilesInput) => { - const useWorker = !!input.signal && typeof Worker !== "undefined" - if (!useWorker && input.signal) { - log.warn("worker unavailable, ripgrep abort disabled") - } - return useWorker ? filesWorker(input) : filesDirect(input) - } - - const files: Interface["files"] = (input) => source(input) - - const tree: Interface["tree"] = Effect.fn("Ripgrep.tree")(function* (input: TreeInput) { - log.info("tree", input) - const list = Array.from(yield* source({ cwd: input.cwd, signal: input.signal }).pipe(Stream.runCollect)) - - interface Node { - name: string - children: Map - } - - function child(node: Node, name: string) { - const item = node.children.get(name) - if (item) return item - const next = { name, children: new Map() } - node.children.set(name, next) - return next - } - - function count(node: Node): number { - return Array.from(node.children.values()).reduce((sum, child) => sum + 1 + count(child), 0) - } - - const root: Node = { name: "", children: new Map() } - for (const file of list) { - if (file.includes(".kilo") || file.includes(".opencode")) continue // kilocode_change - const parts = file.split(path.sep) - if (parts.length < 2) continue - let node = root - for (const part of parts.slice(0, -1)) { - node = child(node, part) - } - } - - const total = count(root) - const limit = input.limit ?? total - const lines: string[] = [] - const queue: Array<{ node: Node; path: string }> = Array.from(root.children.values()) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((node) => ({ node, path: node.name })) - - let used = 0 - for (let i = 0; i < queue.length && used < limit; i++) { - const item = queue[i] - lines.push(item.path) - used++ - queue.push( - ...Array.from(item.node.children.values()) - .sort((a, b) => a.name.localeCompare(b.name)) - .map((node) => ({ node, path: `${item.path}/${node.name}` })), - ) - } - - if (total > used) lines.push(`[${total - used} truncated]`) - return lines.join("\n") - }) - - const search: Interface["search"] = Effect.fn("Ripgrep.search")(function* (input: SearchInput) { - const useWorker = !!input.signal && typeof Worker !== "undefined" - if (!useWorker && input.signal) { - log.warn("worker unavailable, ripgrep abort disabled") - } - return yield* useWorker ? searchWorker(input) : searchDirect(input) - }) - - return Service.of({ files, tree, search }) - }), +export const defaultLayer = layer.pipe( + Layer.provide(FetchHttpClient.layer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(CrossSpawnSpawner.defaultLayer), ) -export const defaultLayer = layer - export * as Ripgrep from "./ripgrep" diff --git a/packages/opencode/src/file/ripgrep.worker.ts b/packages/opencode/src/file/ripgrep.worker.ts deleted file mode 100644 index 10566a8225a..00000000000 --- a/packages/opencode/src/file/ripgrep.worker.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { ripgrep } from "ripgrep" -import { sanitizedProcessEnv } from "@/util/opencode-process" -import { KiloRipgrepStream } from "../kilocode/kilo-ripgrep-stream" // kilocode_change - share UTF-8 stream decoding - -function env() { - const env = sanitizedProcessEnv() - delete env.RIPGREP_CONFIG_PATH - return env -} - -function opts(cwd: string) { - return { - env: env(), - preopens: { ".": cwd }, - } -} - -type Run = { - kind: "files" | "search" - cwd: string - args: string[] -} - -function text(input: unknown) { - if (typeof input === "string") return input - if (input instanceof ArrayBuffer) return Buffer.from(input).toString() - if (ArrayBuffer.isView(input)) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString() - return String(input) -} - -function error(input: unknown) { - if (input instanceof Error) { - return { - message: input.message, - name: input.name, - stack: input.stack, - } - } - - return { - message: String(input), - } -} - -function clean(file: string) { - return file.replace(/^\.[\\/]/, "") -} - -onmessage = async (evt: MessageEvent) => { - const msg = evt.data - - try { - if (msg.kind === "search") { - const ret = await ripgrep(msg.args, { - buffer: true, - ...opts(msg.cwd), - }) - postMessage({ - type: "result", - code: ret.code ?? 0, - stdout: ret.stdout ?? "", - stderr: ret.stderr ?? "", - }) - return - } - - let buf = "" - let err = "" - // kilocode_change start - keep decoder state across stdout chunks - const decoder = KiloRipgrepStream.decoder() - const out = { - write(chunk: unknown) { - buf = KiloRipgrepStream.drain(decoder, buf, chunk, (line) => postMessage({ type: "line", line: clean(line) })) - }, - } - // kilocode_change end - const stderr = { - write(chunk: unknown) { - err += text(chunk) - }, - } - - const ret = await ripgrep(msg.args, { - stdout: out, - stderr, - ...opts(msg.cwd), - }) - - buf += decoder.end() // kilocode_change - flush any trailing buffered bytes - if (buf) postMessage({ type: "line", line: clean(buf) }) - postMessage({ - type: "done", - code: ret.code ?? 0, - stderr: err, - }) - } catch (err) { - postMessage({ - type: "error", - error: error(err), - }) - } -} diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 85934ce9c9a..53a2c10119b 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -25,7 +25,7 @@ export type Status = z.infer export interface Interface { readonly init: () => Effect.Effect readonly status: () => Effect.Effect - readonly file: (filepath: string) => Effect.Effect + readonly file: (filepath: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/Format") {} @@ -70,16 +70,19 @@ export const layer = Layer.effect( } }), ) - return checks.filter((x) => x.cmd).map((x) => ({ item: x.item, cmd: x.cmd! })) + return checks + .filter((x): x is { item: Formatter.Info; cmd: string[] } => x.cmd !== false) + .map((x) => ({ item: x.item, cmd: x.cmd })) } function formatFile(filepath: string) { return Effect.gen(function* () { log.info("formatting", { file: filepath }) - const ext = path.extname(filepath) + const formatters = yield* Effect.promise(() => getFormatter(path.extname(filepath))) - for (const { item, cmd } of yield* Effect.promise(() => getFormatter(ext))) { - if (cmd === false) continue + if (!formatters.length) return false + + for (const { item, cmd } of formatters) { log.info("running", { command: cmd }) const replaced = cmd.map((x) => x.replace("$FILE", filepath)) const dir = yield* InstanceState.directory @@ -113,6 +116,8 @@ export const layer = Layer.effect( }) } } + + return true }) } @@ -188,7 +193,7 @@ export const layer = Layer.effect( const file = Effect.fn("Format.file")(function* (filepath: string) { const { formatFile } = yield* InstanceState.get(state) - yield* formatFile(filepath) + return yield* formatFile(filepath) }) return Service.of({ init, status, file }) diff --git a/packages/opencode/src/kilo-sessions/remote-sender.ts b/packages/opencode/src/kilo-sessions/remote-sender.ts index ee3141a6f88..40feff1afe5 100644 --- a/packages/opencode/src/kilo-sessions/remote-sender.ts +++ b/packages/opencode/src/kilo-sessions/remote-sender.ts @@ -277,7 +277,7 @@ export namespace RemoteSender { return } dispatchLongRunning(msg, directoryFor(input.data.sessionID), async () => { - await SessionPrompt.prompt(input.data) + await SessionPrompt.prompt(input.data as SessionPrompt.PromptInput) }) return } diff --git a/packages/opencode/src/kilocode/agent/index.ts b/packages/opencode/src/kilocode/agent/index.ts index c23b4b2bbef..1d19ec1d937 100644 --- a/packages/opencode/src/kilocode/agent/index.ts +++ b/packages/opencode/src/kilocode/agent/index.ts @@ -146,6 +146,7 @@ function askGuard(mcp: Record = {}) { websearch: "allow", codesearch: "allow", codebase_search: "allow", + semantic_search: "allow", external_directory: { [Truncate.GLOB]: "allow", }, @@ -173,6 +174,7 @@ function planGuard(mcp: Record = {}) { websearch: "allow", codesearch: "allow", codebase_search: "allow", + semantic_search: "allow", external_directory: { [Truncate.GLOB]: "allow", [path.join(Global.Path.data, "plans", "*")]: "allow", @@ -251,6 +253,7 @@ export function telemetryOptions(cfg: Config.Info) { // - Rename build → code // - Patch plan with readOnlyBash, mcpRules, .kilo paths // - Patch explore with codebase_search and conditional prompt +// - Patch appropriate agents with semantic_search // - Add debug, orchestrator, ask agents export function patchAgents( agents: Record< @@ -281,7 +284,11 @@ export function patchAgents( ) { // Rename "build" → "code" for backward compatibility if (agents.build) { - agents.code = { ...agents.build, name: "code" } + agents.code = { + ...agents.build, + name: "code", + permission: Permission.merge(defaults, Permission.fromConfig({ semantic_search: "allow" }), user), + } delete agents.build } @@ -315,6 +322,7 @@ export function patchAgents( websearch: "allow", codesearch: "allow", codebase_search: "allow", + semantic_search: "allow", read: "allow", external_directory: { "*": "ask", @@ -341,6 +349,7 @@ export function patchAgents( question: "allow", suggest: "allow", // kilocode_change plan_enter: "allow", + semantic_search: "allow", }), user, ), diff --git a/packages/opencode/src/kilocode/bootstrap.ts b/packages/opencode/src/kilocode/bootstrap.ts new file mode 100644 index 00000000000..c5d50378d38 --- /dev/null +++ b/packages/opencode/src/kilocode/bootstrap.ts @@ -0,0 +1,8 @@ +import { KiloSessions } from "@/kilo-sessions/kilo-sessions" +import { KiloIndexing } from "@/kilocode/indexing" + +export namespace KilocodeBootstrap { + export async function init() { + await Promise.all([KiloSessions.init(), KiloIndexing.init()]) + } +} diff --git a/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx index 7f26418a121..53c837c7bca 100644 --- a/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/kilocode/cli/cmd/tui/component/dialog-provider.tsx @@ -30,7 +30,11 @@ export const PROVIDER_PRIORITY: Record = { export const PROVIDER_DESCRIPTIONS: Record = { kilo: "(Recommended)", anthropic: "(Claude Max or API key)", - openai: "(ChatGPT Plus/Pro or API key)", + openai: "(ChatGPT login or API key)", +} + +export const PROVIDER_TITLES: Record = { + openai: "OpenAI / Codex", } // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/kilocode/components/dialog-indexing.tsx b/packages/opencode/src/kilocode/components/dialog-indexing.tsx new file mode 100644 index 00000000000..67e393a9c83 --- /dev/null +++ b/packages/opencode/src/kilocode/components/dialog-indexing.tsx @@ -0,0 +1,479 @@ +/** + * Indexing Configuration Dialog + * + * Menu-driven dialog for configuring codebase indexing settings. + * Allows toggling indexing, selecting embedding providers, configuring + * vector stores, and adjusting tuning parameters. + */ + +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { DialogPrompt } from "@tui/ui/dialog-prompt" +import { useSync } from "@tui/context/sync" +import { useToast } from "@tui/ui/toast" +import { reconcile } from "solid-js/store" +import type { IndexingConfig, Config } from "@kilocode/sdk/v2" + +// These types are OpenCode-internal and imported at runtime +type UseSDK = any +type SDK = any + +type EmbeddingProvider = NonNullable + +const PROVIDER_LABELS: Record = { + openai: "OpenAI", + ollama: "Ollama (local)", + "openai-compatible": "OpenAI-Compatible", + gemini: "Gemini", + mistral: "Mistral", + "vercel-ai-gateway": "Vercel AI Gateway", + bedrock: "AWS Bedrock", + openrouter: "OpenRouter", + voyage: "Voyage", +} + +type ProviderFieldDef = { key: string; label: string; placeholder: string; sensitive?: boolean } + +const PROVIDER_FIELDS: Record = { + openai: [{ key: "apiKey", label: "API Key", placeholder: "sk-...", sensitive: true }], + ollama: [{ key: "baseUrl", label: "Base URL", placeholder: "http://localhost:11434" }], + "openai-compatible": [ + { key: "baseUrl", label: "Base URL", placeholder: "https://api.example.com/v1" }, + { key: "apiKey", label: "API Key", placeholder: "sk-...", sensitive: true }, + ], + gemini: [{ key: "apiKey", label: "API Key", placeholder: "AI...", sensitive: true }], + mistral: [{ key: "apiKey", label: "API Key", placeholder: "...", sensitive: true }], + "vercel-ai-gateway": [{ key: "apiKey", label: "API Key", placeholder: "...", sensitive: true }], + bedrock: [ + { key: "region", label: "AWS Region", placeholder: "us-east-1" }, + { key: "profile", label: "AWS Profile", placeholder: "default" }, + ], + openrouter: [ + { key: "apiKey", label: "API Key", placeholder: "sk-or-...", sensitive: true }, + { key: "specificProvider", label: "Specific Provider", placeholder: "optional" }, + ], + voyage: [{ key: "apiKey", label: "API Key", placeholder: "pa-...", sensitive: true }], +} + +const VECTOR_STORE_LABELS: Record = { + qdrant: "Qdrant (default)", + lancedb: "LanceDB", +} + +function maskSecret(value: string | undefined): string { + if (!value) return "not set" + if (value.length <= 6) return "***" + return value.slice(0, 3) + "..." + value.slice(-3) +} + +function getIndexing(sync: ReturnType): IndexingConfig { + return (sync.data.config as Config & { indexing?: IndexingConfig }).indexing ?? {} +} + +async function saveIndexing( + sdk: SDK, + sync: ReturnType, + indexing: IndexingConfig, + toast: ReturnType, +): Promise { + const response = await sdk.client.global.config.update({ config: { indexing } }) + if (response.error) { + toast.show({ message: "Failed to save indexing config", variant: "error" }) + return false + } + // Refresh config in sync store so the dialog shows updated values immediately. + // The server's async bootstrap (via global.disposed) would eventually do this, + // but it races with dialog re-render. + const configResponse = await sdk.client.config.get({}) + if (configResponse.data) { + sync.set("config", reconcile(configResponse.data)) + } + toast.show({ message: "Indexing config saved", variant: "success" }) + return true +} + +function providerSettingsDescription(indexing: IndexingConfig, provider: EmbeddingProvider): string { + const fields = PROVIDER_FIELDS[provider] + const settings = indexing[provider] as Record | undefined + if (!settings) return "not configured" + const parts = fields.map((f) => { + const val = settings[f.key] + if (!val) return `${f.label}: not set` + return `${f.label}: ${f.sensitive ? maskSecret(val) : val}` + }) + return parts.join(", ") +} + +// --- Sub-dialogs --- + +interface SubDialogProps { + useSDK: () => UseSDK +} + +function ProviderSelect(props: SubDialogProps) { + const dialog = useDialog() + const sync = useSync() + const sdk = props.useSDK() + const toast = useToast() + const indexing = getIndexing(sync) + + const options: DialogSelectOption[] = ( + Object.entries(PROVIDER_LABELS) as [EmbeddingProvider, string][] + ).map(([value, title]) => ({ + value, + title, + description: value === indexing.provider ? "(current)" : undefined, + })) + + return ( + { + const provider = option.value + const updated = { ...getIndexing(sync), provider } + const saved = await saveIndexing(sdk, sync, updated, toast) + if (!saved) { + dialog.clear() + return + } + showProviderSettings(dialog, sync, sdk, toast, provider, props.useSDK) + }} + /> + ) +} + +async function showProviderSettings( + dialog: ReturnType, + sync: ReturnType, + sdk: SDK, + toast: ReturnType, + provider: EmbeddingProvider, + useSDK: () => UseSDK, +) { + const fields = PROVIDER_FIELDS[provider] + const indexing = getIndexing(sync) + const currentSettings = (indexing[provider] as Record) ?? {} + const newSettings: Record = { ...currentSettings } + + for (const field of fields) { + const currentValue = currentSettings[field.key] ?? "" + const result = await DialogPrompt.show(dialog, `${PROVIDER_LABELS[provider]} — ${field.label}`, { + value: currentValue, + placeholder: field.placeholder, + }) + // null means user pressed Esc — abort the flow + if (result === null) { + dialog.replace(() => ) + return + } + newSettings[field.key] = result.trim() || undefined + } + + const updated = { ...getIndexing(sync), [provider]: newSettings } + await saveIndexing(sdk, sync, updated, toast) + dialog.replace(() => ) +} + +function VectorStoreSelect(props: SubDialogProps) { + const dialog = useDialog() + const sync = useSync() + const sdk = props.useSDK() + const toast = useToast() + const indexing = getIndexing(sync) + + const options: DialogSelectOption[] = Object.entries(VECTOR_STORE_LABELS).map(([value, title]) => ({ + value, + title, + description: value === (indexing.vectorStore ?? "qdrant") ? "(current)" : undefined, + })) + + return ( + { + const store = option.value as "lancedb" | "qdrant" + if (store === "lancedb") { + await showLancedbSettings(dialog, sync, sdk, toast, props.useSDK) + } else { + await showQdrantSettings(dialog, sync, sdk, toast, props.useSDK) + } + }} + /> + ) +} + +async function showLancedbSettings( + dialog: ReturnType, + sync: ReturnType, + sdk: SDK, + toast: ReturnType, + useSDK: () => UseSDK, +) { + const indexing = getIndexing(sync) + const result = await DialogPrompt.show(dialog, "LanceDB — Directory", { + value: indexing.lancedb?.directory ?? "", + placeholder: "Leave empty for default", + }) + if (result === null) { + dialog.replace(() => ) + return + } + const updated: IndexingConfig = { + ...getIndexing(sync), + vectorStore: "lancedb", + lancedb: { directory: result.trim() || undefined }, + } + await saveIndexing(sdk, sync, updated, toast) + dialog.replace(() => ) +} + +async function showQdrantSettings( + dialog: ReturnType, + sync: ReturnType, + sdk: SDK, + toast: ReturnType, + useSDK: () => UseSDK, +) { + const indexing = getIndexing(sync) + const currentSettings = indexing.qdrant ?? {} + + const url = await DialogPrompt.show(dialog, "Qdrant — URL", { + value: currentSettings.url ?? "", + placeholder: "http://localhost:6333", + }) + if (url === null) { + dialog.replace(() => ) + return + } + + const apiKey = await DialogPrompt.show(dialog, "Qdrant — API Key", { + value: currentSettings.apiKey ?? "", + placeholder: "Optional API key", + }) + if (apiKey === null) { + dialog.replace(() => ) + return + } + + const updated: IndexingConfig = { + ...getIndexing(sync), + vectorStore: "qdrant", + qdrant: { + url: url.trim() || undefined, + apiKey: apiKey.trim() || undefined, + }, + } + await saveIndexing(sdk, sync, updated, toast) + dialog.replace(() => ) +} + +interface TuningParam { + key: keyof Pick< + IndexingConfig, + "searchMinScore" | "searchMaxResults" | "embeddingBatchSize" | "scannerMaxBatchRetries" + > + label: string + defaultValue: number +} + +const TUNING_PARAMS: TuningParam[] = [ + { key: "searchMinScore", label: "Search Min Score", defaultValue: 0.4 }, + { key: "searchMaxResults", label: "Search Max Results", defaultValue: 50 }, + { key: "embeddingBatchSize", label: "Embedding Batch Size", defaultValue: 60 }, + { key: "scannerMaxBatchRetries", label: "Scanner Max Batch Retries", defaultValue: 3 }, +] + +function TuningMenu(props: SubDialogProps) { + const dialog = useDialog() + const sync = useSync() + const sdk = props.useSDK() + const toast = useToast() + const indexing = getIndexing(sync) + + const options: DialogSelectOption[] = TUNING_PARAMS.map((param) => { + const value = indexing[param.key] + return { + value: param.key, + title: param.label, + description: value !== undefined ? String(value) : `default (${param.defaultValue})`, + } + }) + + return ( + { + const param = TUNING_PARAMS.find((p) => p.key === option.value)! + const currentIndexing = getIndexing(sync) + const currentValue = currentIndexing[param.key] + const result = await DialogPrompt.show(dialog, param.label, { + value: currentValue !== undefined ? String(currentValue) : "", + placeholder: `Default: ${param.defaultValue}`, + }) + if (result === null) { + dialog.replace(() => ) + return + } + const trimmed = result.trim() + let numValue: number | undefined + if (trimmed) { + numValue = Number(trimmed) + if (isNaN(numValue)) { + toast.show({ message: `Invalid number: "${trimmed}"`, variant: "error" }) + dialog.replace(() => ) + return + } + } + const updated = { ...getIndexing(sync), [param.key]: numValue } + await saveIndexing(sdk, sync, updated, toast) + dialog.replace(() => ) + }} + /> + ) +} + +// --- Main Dialog --- + +interface DialogIndexingProps { + useSDK: () => UseSDK +} + +export function DialogIndexing(props: DialogIndexingProps) { + const dialog = useDialog() + const sync = useSync() + const sdk = props.useSDK() + const toast = useToast() + const indexing = getIndexing(sync) + + const providerLabel = indexing.provider ? PROVIDER_LABELS[indexing.provider] : "not set" + const storeLabel = indexing.vectorStore + ? (VECTOR_STORE_LABELS[indexing.vectorStore] ?? indexing.vectorStore) + : "Qdrant (default)" + + const tuningCount = TUNING_PARAMS.filter((p) => indexing[p.key] !== undefined).length + const tuningDesc = tuningCount > 0 ? `${tuningCount} customized` : "defaults" + + const options: DialogSelectOption[] = [ + { + value: "toggle", + title: "Indexing", + category: "General", + description: indexing.enabled ? "enabled" : "disabled", + }, + { + value: "provider", + title: "Embedding Provider", + category: "Embedding", + description: providerLabel, + }, + { + value: "model", + title: "Embedding Model", + category: "Embedding", + description: indexing.model ?? "default", + }, + { + value: "dimension", + title: "Vector Dimension", + category: "Embedding", + description: indexing.dimension ? String(indexing.dimension) : "auto", + }, + { + value: "vectorStore", + title: "Vector Store", + category: "Storage", + description: storeLabel, + }, + { + value: "tuning", + title: "Tuning Parameters", + category: "Advanced", + description: tuningDesc, + }, + ] + + // If a provider is selected, add a provider-settings entry below it + if (indexing.provider) { + const settingsDesc = providerSettingsDescription(indexing, indexing.provider) + options.splice(2, 0, { + value: "providerSettings", + title: `${PROVIDER_LABELS[indexing.provider]} Settings`, + category: "Embedding", + description: settingsDesc, + }) + } + + return ( + { + switch (option.value) { + case "toggle": { + const updated = { ...getIndexing(sync), enabled: !indexing.enabled } + await saveIndexing(sdk, sync, updated, toast) + dialog.replace(() => ) + break + } + case "provider": + dialog.replace(() => ) + break + case "providerSettings": + if (indexing.provider) { + await showProviderSettings(dialog, sync, sdk, toast, indexing.provider, props.useSDK) + } + break + case "model": { + const result = await DialogPrompt.show(dialog, "Embedding Model", { + value: indexing.model ?? "", + placeholder: "e.g. text-embedding-3-small", + }) + if (result !== null) { + const updated = { + ...getIndexing(sync), + model: result.trim() || undefined, + } + await saveIndexing(sdk, sync, updated, toast) + } + dialog.replace(() => ) + break + } + case "dimension": { + const result = await DialogPrompt.show(dialog, "Vector Dimension", { + value: indexing.dimension ? String(indexing.dimension) : "", + placeholder: "Leave empty for auto-detection", + }) + if (result !== null) { + const trimmed = result.trim() + let dim: number | undefined + if (trimmed) { + dim = Number(trimmed) + if (isNaN(dim) || dim <= 0 || !Number.isInteger(dim)) { + toast.show({ message: `Invalid dimension: "${trimmed}"`, variant: "error" }) + dialog.replace(() => ) + break + } + } + const updated = { ...getIndexing(sync), dimension: dim } + await saveIndexing(sdk, sync, updated, toast) + } + dialog.replace(() => ) + break + } + case "vectorStore": + dialog.replace(() => ) + break + case "tuning": + dialog.replace(() => ) + break + } + }} + /> + ) +} diff --git a/packages/opencode/src/kilocode/components/session-indexing.tsx b/packages/opencode/src/kilocode/components/session-indexing.tsx new file mode 100644 index 00000000000..fb31f610fe8 --- /dev/null +++ b/packages/opencode/src/kilocode/components/session-indexing.tsx @@ -0,0 +1,31 @@ +// kilocode_change - new file +import { createMemo, Show } from "solid-js" +import { useSync } from "@tui/context/sync" +import { useTheme } from "@tui/context/theme" +import { indexingEnabled } from "../indexing-feature" +import { formatIndexingLabel } from "../indexing-label" +import type { IndexingStatusState } from "@kilocode/kilo-indexing/status" + +function tone(state: IndexingStatusState, theme: ReturnType["theme"]) { + if (state === "Complete") return theme.success + if (state === "Error") return theme.error + if (state === "In Progress") return theme.warning + if (state === "Standby") return theme.textMuted + return theme.textMuted +} + +export function SessionIndexing() { + const { theme } = useTheme() + const sync = useSync() + const enabled = createMemo(() => indexingEnabled(sync.data.config)) + const indexing = createMemo(() => sync.data.indexing) + const label = createMemo(() => formatIndexingLabel(indexing())) + + return ( + + + {label().slice(0, 48)} + + + ) +} diff --git a/packages/opencode/src/kilocode/config-injector.ts b/packages/opencode/src/kilocode/config-injector.ts index fda2660397a..e53d8e93564 100644 --- a/packages/opencode/src/kilocode/config-injector.ts +++ b/packages/opencode/src/kilocode/config-injector.ts @@ -84,7 +84,10 @@ export namespace KilocodeConfigInjector { * Merge permission configs, preserving order and handling duplicates. * Incoming rules take precedence (kilocode patterns override). */ - function mergePermissions(existing: ConfigPermission.Info | undefined, incoming: ConfigPermission.Info): ConfigPermission.Info { + function mergePermissions( + existing: ConfigPermission.Info | undefined, + incoming: ConfigPermission.Info, + ): ConfigPermission.Info { if (!existing) return incoming const result: ConfigPermission.Info = { ...existing } diff --git a/packages/opencode/src/kilocode/config-validation.ts b/packages/opencode/src/kilocode/config-validation.ts index a14f946edbb..24c9abb5b28 100644 --- a/packages/opencode/src/kilocode/config-validation.ts +++ b/packages/opencode/src/kilocode/config-validation.ts @@ -43,7 +43,7 @@ export namespace ConfigValidation { return `\n\n\nERROR: Config file at ${label(filepath)} is not valid JSON(C)\n ${detail}\n` } - const result = Config.Info.safeParse(data) + const result = Config.Info.zod.safeParse(data) if (!result.success) { const issues = result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n") return `\n\n\nWARNING: Configuration is invalid at ${label(filepath)}\n${issues}\n` diff --git a/packages/opencode/src/kilocode/config/default-plugins.ts b/packages/opencode/src/kilocode/config/default-plugins.ts new file mode 100644 index 00000000000..393226f93e0 --- /dev/null +++ b/packages/opencode/src/kilocode/config/default-plugins.ts @@ -0,0 +1,29 @@ +import { createRequire } from "module" +import type { ConfigPlugin } from "@/config/plugin" +import { ensureIndexingPlugin, resolveIndexingPlugin } from "@/kilocode/indexing-feature" + +type Log = { + debug: (msg: string, data?: Record) => void +} + +const req = createRequire(import.meta.url) + +export namespace KilocodeDefaultPlugins { + export function apply( + cfg: T, + opts: { disabled: boolean; log?: Log }, + ): T { + const before = cfg.plugin ?? [] + const plugin = opts.disabled ? undefined : resolveIndexingPlugin(req, opts.log) + const after = ensureIndexingPlugin(before, plugin) + if (after.length > before.length) { + const added = after[after.length - 1] + cfg.plugin_origins = [ + ...(cfg.plugin_origins ?? []), + { spec: added, source: "builtin", scope: "global" as ConfigPlugin.Scope }, + ] + } + cfg.plugin = after + return cfg + } +} diff --git a/packages/opencode/src/kilocode/ignore-migrator.ts b/packages/opencode/src/kilocode/ignore-migrator.ts index c0e8fb5171a..219185c2c92 100644 --- a/packages/opencode/src/kilocode/ignore-migrator.ts +++ b/packages/opencode/src/kilocode/ignore-migrator.ts @@ -200,7 +200,10 @@ export namespace IgnoreMigrator { * Load .kilocodeignore and return permission config. * Handles all logging internally. */ - export async function loadIgnoreConfig(projectDir: string, skipGlobalPaths?: boolean): Promise { + export async function loadIgnoreConfig( + projectDir: string, + skipGlobalPaths?: boolean, + ): Promise { try { const result = await migrate({ projectDir, skipGlobalPaths }) diff --git a/packages/opencode/src/kilocode/indexing-feature.ts b/packages/opencode/src/kilocode/indexing-feature.ts new file mode 100644 index 00000000000..a19b1b316b9 --- /dev/null +++ b/packages/opencode/src/kilocode/indexing-feature.ts @@ -0,0 +1,43 @@ +import { pathToFileURL } from "url" +import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect" + +export const INDEXING_PLUGIN = "@kilocode/kilo-indexing" + +// RATIONALE: Upstream PluginSpec changed from string to string | [string, Record]. +// Use a broad input type to accept both forms but return the concrete PluginSpec shape. +type PluginSpec = string | [string, Record] + +type ConfigLike = { + plugin?: readonly PluginSpec[] | null + experimental?: { semantic_indexing?: boolean } | null +} + +type Req = { + resolve: (id: string) => string +} + +type LogLike = { + debug: (msg: string, data?: Record) => void +} + +export function indexingEnabled(config?: ConfigLike | null): boolean { + return hasIndexingPlugin(config?.plugin ?? []) && config?.experimental?.semantic_indexing === true +} + +export function resolveIndexingPlugin(req: Req, log?: LogLike): string { + try { + const file = req.resolve(INDEXING_PLUGIN) + return pathToFileURL(file).href + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + log?.debug("failed to resolve indexing plugin package, using package marker", { error }) + return INDEXING_PLUGIN + } +} + +export function ensureIndexingPlugin(items: readonly PluginSpec[], plugin?: string): PluginSpec[] { + const plugins = [...items] + if (!plugin) return plugins + if (hasIndexingPlugin(plugins)) return plugins + return [...plugins, plugin] +} diff --git a/packages/opencode/src/kilocode/indexing-label.ts b/packages/opencode/src/kilocode/indexing-label.ts new file mode 100644 index 00000000000..96f77ea21c2 --- /dev/null +++ b/packages/opencode/src/kilocode/indexing-label.ts @@ -0,0 +1,18 @@ +import type { IndexingStatus } from "@kilocode/kilo-indexing/status" + +export function formatIndexingLabel(status: IndexingStatus): string { + if (status.state === "In Progress") { + if (status.totalFiles <= 0) return "IDX In Progress" + return `IDX ${status.percent}% ${status.processedFiles}/${status.totalFiles}` + } + + if (status.state === "Error") { + return `IDX ${status.message}` + } + + if (status.state === "Standby") { + return "IDX Standby" + } + + return `IDX ${status.state}` +} diff --git a/packages/opencode/src/kilocode/indexing.ts b/packages/opencode/src/kilocode/indexing.ts new file mode 100644 index 00000000000..d1d0c1c0020 --- /dev/null +++ b/packages/opencode/src/kilocode/indexing.ts @@ -0,0 +1,302 @@ +import z from "zod" +import path from "path" +import { + CodeIndexManager, + type IndexingTelemetryEvent, + type VectorStoreSearchResult, +} from "@kilocode/kilo-indexing/engine" +import { toIndexingConfigInput } from "@kilocode/kilo-indexing/config" +import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect" +import { IndexingStatus, disabledIndexingStatus, normalizeIndexingStatus } from "@kilocode/kilo-indexing/status" +import { Telemetry } from "@kilocode/kilo-telemetry" +import { Instance } from "@/project/instance" +import { Bus } from "@/bus" +import { BusEvent } from "@/bus/bus-event" +import { Config } from "@/config" +import { registerDisposer } from "@/effect/instance-registry" +import { Global } from "@/global" +import { Log } from "@/util" +import { LanceDBRuntime } from "./lancedb" // kilocode_change + +const log = Log.create({ service: "kilocode-indexing" }) +const missing = () => disabledIndexingStatus("Indexing plugin is not enabled for this workspace.") + +function worktreeDisabled(): z.infer { + return { + state: "Disabled", + message: "Indexing is disabled in worktree sessions. Use the main workspace for indexing.", + processedFiles: 0, + totalFiles: 0, + percent: 0, + } +} + +function isWorktreePath(dir: string): boolean { + return /(?:\/|\\)\.kilo(?:code)?(?:\/|\\)worktrees(?:\/|\\)/.test(dir) +} + +function failed(err: unknown): z.infer { + const msg = err instanceof Error ? err.message : String(err) + const text = msg.startsWith("Failed to initialize:") ? msg : `Failed to initialize: ${msg}` + + return { + state: "Error", + message: text, + processedFiles: 0, + totalFiles: 0, + percent: 0, + } +} + +function trackTelemetry(event: IndexingTelemetryEvent): void { + if (event.type === "started") { + Telemetry.trackIndexingStarted({ + trigger: event.trigger, + source: event.source, + mode: event.mode, + provider: event.provider, + vectorStore: event.vectorStore, + modelId: event.modelId, + }) + return + } + + if (event.type === "completed") { + Telemetry.trackIndexingCompleted({ + trigger: event.trigger, + source: event.source, + mode: event.mode, + provider: event.provider, + vectorStore: event.vectorStore, + modelId: event.modelId, + filesIndexed: event.filesIndexed, + filesDiscovered: event.filesDiscovered, + totalBlocks: event.totalBlocks, + batchErrors: event.batchErrors, + }) + return + } + + if (event.type === "file_count") { + Telemetry.trackIndexingFileCount({ + source: event.source, + mode: event.mode, + provider: event.provider, + vectorStore: event.vectorStore, + modelId: event.modelId, + discovered: event.discovered, + candidate: event.candidate, + }) + return + } + + if (event.type === "batch_retry") { + Telemetry.trackIndexingBatchRetry({ + source: event.source, + mode: event.mode, + provider: event.provider, + vectorStore: event.vectorStore, + modelId: event.modelId, + attempt: event.attempt, + maxRetries: event.maxRetries, + batchSize: event.batchSize, + error: event.error, + }) + return + } + + Telemetry.trackIndexingError({ + source: event.source, + trigger: event.trigger, + mode: event.mode, + provider: event.provider, + vectorStore: event.vectorStore, + modelId: event.modelId, + location: event.location, + error: event.error, + retryCount: event.retryCount, + maxRetries: event.maxRetries, + }) +} + +export namespace KiloIndexing { + export const Status = IndexingStatus + export type Status = z.infer + + type Entry = { + manager?: CodeIndexManager + current(): Status + publish(): Promise + dispose(): void + } + + type Cache = { + promise: Promise + entry?: Entry + disposed?: boolean + } + + export const Event = BusEvent.define( + "indexing.status", + z.object({ + status: Status, + }), + ) + + const cache = new Map() + + const inert = async (current: () => Status): Promise => { + const publish = async () => { + await Bus.publish(Event, { status: current() }) + } + + await publish() + return { + current, + publish, + dispose() {}, + } + } + + const boot = async (): Promise => { + const dir = Instance.directory + const cfg = await Config.get() + if (!hasIndexingPlugin(cfg.plugin)) { + return inert(() => missing()) + } + + if (cfg.experimental?.semantic_indexing !== true) { + return inert(() => disabledIndexingStatus("Semantic indexing is disabled. Enable it in the Experimental settings.")) + } + + if (isWorktreePath(dir)) { + return inert(() => worktreeDisabled()) + } + + log.info("initializing project indexing", { workspacePath: dir }) + const root = path.join(Global.Path.state, "indexing") + const manager = new CodeIndexManager(dir, root) + const input = toIndexingConfigInput(cfg.indexing) + const box = { status: undefined as Status | undefined } + const current = () => box.status ?? normalizeIndexingStatus(manager) + + const publish = async () => { + await Bus.publish(Event, { status: current() }) + } + const report = async () => { + try { + return await publish() + } catch (err) { + log.error("failed to publish indexing status", { err }) + } + } + + const unsub = manager.onProgressUpdate.on(() => { + void report() + }) + const telemetrySub = manager.onTelemetry.on((event) => { + trackTelemetry(event) + }) + + const base: Entry = { + current, + publish, + dispose() { + unsub.dispose() + telemetrySub.dispose() + manager.dispose() + }, + } + + // kilocode_change start + const err = await LanceDBRuntime.ensure(input.vectorStoreProvider) + .then(() => manager.initialize(input)) + .then( + () => undefined, + (err) => err, + ) + // kilocode_change end + if (err) { + box.status = failed(err) + log.error("project indexing initialization failed", { + err, + workspacePath: dir, + }) + await report() + return base + } + + log.info("project indexing initialized", { + workspacePath: dir, + featureEnabled: manager.isFeatureEnabled, + featureConfigured: manager.isFeatureConfigured, + state: manager.getCurrentStatus().systemStatus, + }) + await report() + + return { + ...base, + manager, + } + } + + const state = async () => { + const dir = Instance.directory + const existing = cache.get(dir) + if (existing) return existing.promise + + const hit: Cache = { + promise: boot() + .then((entry) => { + if (hit.disposed) { + entry.dispose() + return entry + } + hit.entry = entry + return entry + }) + .catch((err) => { + if (cache.get(dir) === hit) cache.delete(dir) + throw err + }), + } + cache.set(dir, hit) + return hit.promise + } + + registerDisposer(async (dir) => { + const hit = cache.get(dir) + cache.delete(dir) + if (hit?.entry) { + hit.entry.dispose() + return + } + if (hit) hit.disposed = true + }) + + export async function init() { + await state() + } + + export async function current(): Promise { + return (await state()).current() + } + + export function ready(): boolean { + const entry = cache.get(Instance.directory)?.entry + if (!entry?.manager) return false + return entry.current().state !== "Disabled" + } + + export async function available(): Promise { + const entry = await state() + if (!entry.manager) return false + return entry.current().state !== "Disabled" + } + + export async function search(query: string, directoryPrefix?: string): Promise { + const entry = await state() + if (!entry.manager) return [] + return entry.manager.searchIndex(query, directoryPrefix) + } +} diff --git a/packages/opencode/src/kilocode/kilo-commands.tsx b/packages/opencode/src/kilocode/kilo-commands.tsx index e622b35ee22..e0d90c2e0f4 100644 --- a/packages/opencode/src/kilocode/kilo-commands.tsx +++ b/packages/opencode/src/kilocode/kilo-commands.tsx @@ -17,6 +17,8 @@ import { DialogKiloTeamSelect } from "./components/dialog-kilo-team-select.js" import { DialogKiloProfile } from "./components/dialog-kilo-profile.js" import { DialogClawSetup } from "./components/dialog-claw-setup.js" import { DialogClawUpgrade } from "./components/dialog-claw-upgrade.js" +import { DialogIndexing } from "./components/dialog-indexing.js" +import { indexingEnabled } from "./indexing-feature" // These types are OpenCode-internal and imported at runtime type UseSDK = any @@ -40,6 +42,7 @@ export function registerKiloCommands(useSDK: () => UseSDK) { const isKiloConnected = createMemo(() => { return sync.data.provider_next.connected.includes("kilo") }) + const indexing = createMemo(() => indexingEnabled(sync.data.config)) command.register(() => [ // /kiloclaw command @@ -154,6 +157,21 @@ export function registerKiloCommands(useSDK: () => UseSDK) { }, }, + ...(indexing() + ? [ + { + value: "kilo.indexing", + title: "Indexing", + description: "Configure codebase indexing", + category: "Kilo", + slash: { name: "indexing", aliases: ["index", "embedding"] }, + onSelect: () => { + dialog.replace(() => ) + }, + }, + ] + : []), + // /teams command { value: "kilo.teams", diff --git a/packages/opencode/src/kilocode/kilo-ripgrep-stream.ts b/packages/opencode/src/kilocode/kilo-ripgrep-stream.ts deleted file mode 100644 index eacedee8a55..00000000000 --- a/packages/opencode/src/kilocode/kilo-ripgrep-stream.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { StringDecoder } from "string_decoder" - -export namespace KiloRipgrepStream { - export function decoder() { - return new StringDecoder("utf8") - } - - function decode(dec: StringDecoder, input: unknown) { - if (typeof input === "string") return input - if (input instanceof ArrayBuffer) return dec.write(Buffer.from(input)) - if (ArrayBuffer.isView(input)) return dec.write(Buffer.from(input.buffer, input.byteOffset, input.byteLength)) - return String(input) - } - - export function drain(dec: StringDecoder, buf: string, chunk: unknown, push: (line: string) => void) { - const lines = (buf + decode(dec, chunk)).split(/\r?\n/) - const rest = lines.pop() || "" - for (const line of lines) { - if (line) push(line) - } - return rest - } -} diff --git a/packages/opencode/src/kilocode/lancedb.ts b/packages/opencode/src/kilocode/lancedb.ts new file mode 100644 index 00000000000..fde37b5a66e --- /dev/null +++ b/packages/opencode/src/kilocode/lancedb.ts @@ -0,0 +1,40 @@ +import { Npm } from "@/npm" + +export namespace LanceDBRuntime { + export const env = "KILO_LANCEDB_PATH" + export const pkg = "@lancedb/lancedb" + export const version = "0.26.2" + export const external = [ + pkg, + "@lancedb/lancedb-darwin-arm64", + "@lancedb/lancedb-linux-arm64-gnu", + "@lancedb/lancedb-linux-arm64-musl", + "@lancedb/lancedb-linux-x64-gnu", + "@lancedb/lancedb-linux-x64-musl", + "@lancedb/lancedb-win32-arm64-msvc", + "@lancedb/lancedb-win32-x64-msvc", + ] as const + + const box = { ready: undefined as Promise | undefined } + + export function clear() { + delete process.env[env] + box.ready = undefined + } + + export async function ensure(store?: string) { + if (store !== "lancedb") return + if (process.env[env]) return + if (box.ready) return box.ready + + box.ready = (async () => { + const result = await Npm.add(`${pkg}@${version}`) + if (result.entrypoint) process.env[env] = result.entrypoint + })().catch((err) => { + box.ready = undefined + throw err + }) + + return box.ready + } +} diff --git a/packages/opencode/src/kilocode/plugins/home-footer.tsx b/packages/opencode/src/kilocode/plugins/home-footer.tsx index c1c1d7a3eee..244fabc789b 100644 --- a/packages/opencode/src/kilocode/plugins/home-footer.tsx +++ b/packages/opencode/src/kilocode/plugins/home-footer.tsx @@ -9,6 +9,9 @@ import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@kilocode/plugin/tui" import { createMemo, createSignal, Match, onCleanup, onMount, Show, Switch } from "solid-js" import { Global } from "@/global" +import { indexingEnabled } from "../indexing-feature" +import { formatIndexingLabel } from "../indexing-label" +import { useSync } from "@/cli/cmd/tui/context/sync" const id = "internal:kilo-home-footer" @@ -102,6 +105,18 @@ function Version(props: { api: TuiPluginApi }) { function View(props: { api: TuiPluginApi }) { const kilo = createMemo(() => props.api.state.provider.some((p) => p.id === "kilo")) + const theme = () => props.api.theme.current + const sync = useSync() + const indexingOn = createMemo(() => indexingEnabled(sync.data.config)) + const indexing = createMemo(() => sync.data.indexing) + const indexingLabel = createMemo(() => formatIndexingLabel(indexing())) + const indexingColor = createMemo(() => { + if (indexing().state === "Complete") return theme().success + if (indexing().state === "Error") return theme().error + if (indexing().state === "In Progress") return theme().warning + if (indexing().state === "Standby") return theme().textMuted + return theme().textMuted + }) return ( + + {indexingLabel().slice(0, 48)} + @@ -131,7 +149,7 @@ function View(props: { api: TuiPluginApi }) { const tui: TuiPlugin = async (api) => { api.slots.register({ - order: 101, + order: 99, slots: { home_footer() { return diff --git a/packages/opencode/src/kilocode/review/worktree-diff.ts b/packages/opencode/src/kilocode/review/worktree-diff.ts index bf416d0a24c..c516235ad80 100644 --- a/packages/opencode/src/kilocode/review/worktree-diff.ts +++ b/packages/opencode/src/kilocode/review/worktree-diff.ts @@ -3,23 +3,26 @@ import { $ } from "bun" import { createTwoFilesPatch } from "diff" import fs from "node:fs/promises" import path from "node:path" -import z from "zod" +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" import { FileIgnore } from "@/file/ignore" import { Snapshot } from "@/snapshot" import { Log } from "@/util" +import { withStatics } from "@/util/schema" export namespace WorktreeDiff { - export const Item = Snapshot.FileDiff.extend({ - before: z.string(), - after: z.string(), - tracked: z.boolean(), - generatedLike: z.boolean(), - summarized: z.boolean(), - stamp: z.string(), - }).meta({ - ref: "WorktreeDiffItem", + export const Item = Schema.Struct({ + ...Snapshot.FileDiff.fields, + before: Schema.String, + after: Schema.String, + tracked: Schema.Boolean, + generatedLike: Schema.Boolean, + summarized: Schema.Boolean, + stamp: Schema.String, }) - export type Item = z.infer + .annotate({ identifier: "WorktreeDiffItem" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type Item = typeof Item.Type type Status = NonNullable diff --git a/packages/opencode/src/kilocode/server/instance.ts b/packages/opencode/src/kilocode/server/instance.ts index 7a5e5db23e1..79282b77c73 100644 --- a/packages/opencode/src/kilocode/server/instance.ts +++ b/packages/opencode/src/kilocode/server/instance.ts @@ -13,6 +13,7 @@ import { PermissionKilocodeRoutes } from "../permission/routes" import { RemoteRoutes } from "../../server/routes/instance/remote" import { NetworkRoutes } from "../../server/routes/instance/network" import { SuggestionRoutes } from "../suggestion/routes" +import { IndexingRoutes } from "./routes/indexing" import { createKiloRoutes } from "@kilocode/kilo-gateway" import { Auth } from "../../auth" import { errors } from "../../server/error" @@ -28,6 +29,7 @@ export function register(app: Hono): Hono { return app .route("/permission", PermissionKilocodeRoutes()) .route("/network", NetworkRoutes()) + .route("/indexing", IndexingRoutes()) // kilocode_change .route("/suggestion", SuggestionRoutes()) .route("/telemetry", TelemetryRoutes()) .route("/remote", RemoteRoutes()) diff --git a/packages/opencode/src/kilocode/server/routes/indexing.ts b/packages/opencode/src/kilocode/server/routes/indexing.ts new file mode 100644 index 00000000000..8f610e305bc --- /dev/null +++ b/packages/opencode/src/kilocode/server/routes/indexing.ts @@ -0,0 +1,9 @@ +import { lazy } from "@/util/lazy" +import { KiloIndexing } from "@/kilocode/indexing" +import { createIndexingRoutes } from "@kilocode/kilo-indexing/server" + +export const IndexingRoutes = lazy(() => + createIndexingRoutes({ + current: () => KiloIndexing.current(), + }), +) diff --git a/packages/opencode/src/kilocode/session/cost-propagation.ts b/packages/opencode/src/kilocode/session/cost-propagation.ts new file mode 100644 index 00000000000..107b56d56a7 --- /dev/null +++ b/packages/opencode/src/kilocode/session/cost-propagation.ts @@ -0,0 +1,69 @@ +// kilocode_change - new file +import { Effect } from "effect" +import { Session } from "@/session" +import { MessageV2 } from "@/session/message-v2" +import { SessionID, MessageID } from "@/session/schema" + +export namespace KiloCostPropagation { + /** + * Per-key promise chain that serializes concurrent `propagate` calls against + * the same parent message. Prevents lost updates when the LLM launches + * several `task` tool calls in parallel (each release stage races to + * read-modify-write the same parent cost field). + */ + const locks = new Map>() + + function acquire(key: string): Promise<() => void> { + const prev = locks.get(key) ?? Promise.resolve() + let release!: () => void + const current = new Promise((r) => (release = r)) + const chain = prev.catch(() => {}).then(() => current) + locks.set(key, chain) + return prev + .catch(() => {}) + .then(() => () => { + release() + if (locks.get(key) === chain) locks.delete(key) + }) + } + + /** + * Total assistant-message cost in a session. Because each subagent propagates + * its own total into the parent assistant message when it finishes, this sum + * already reflects descendant sessions recursively — no tree walk needed. + */ + export const childCost = Effect.fn("KiloCostPropagation.childCost")(function* ( + sessions: Session.Interface, + id: SessionID, + ) { + const msgs = yield* sessions.messages({ sessionID: id }) + return msgs.reduce((sum, m) => sum + (m.info.role === "assistant" ? m.info.cost : 0), 0) + }) + + /** + * Add `amount` to the given parent assistant message's cost. No-op when + * `amount` is non-positive or the target is not an assistant message. + * + * Concurrent calls against the same parent are serialized internally so the + * read-modify-write cannot lose updates when subagents complete in parallel. + */ + export const propagate = Effect.fn("KiloCostPropagation.propagate")(function* ( + sessions: Session.Interface, + sid: SessionID, + mid: MessageID, + amount: number, + ) { + if (!(amount > 0)) return + yield* Effect.acquireUseRelease( + Effect.promise(() => acquire(`${sid}:${mid}`)), + () => + Effect.gen(function* () { + const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: sid, messageID: mid })) + if (parent.info.role !== "assistant") return + parent.info.cost += amount + yield* sessions.updateMessage(parent.info) + }), + (release) => Effect.sync(() => release()), + ) + }) +} diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index 301a9487500..5c108c7cf6d 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -90,7 +90,7 @@ Scalar form applies to all patterns. Object form maps glob patterns to actions. Actions: `"allow"`, `"ask"`, `"deny"`. Set `null` to delete an inherited key. -Tool permissions: `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`, `webfetch`, `websearch`, `codesearch`, `lsp`, `skill`, `external_directory`, `todowrite`, `todoread`, `question`, `doom_loop`. +Tool permissions: `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`, `webfetch`, `websearch`, `codesearch`, `semantic_search`, `lsp`, `skill`, `external_directory`, `todowrite`, `todoread`, `question`, `doom_loop`. ## MCP Servers diff --git a/packages/opencode/src/kilocode/tool/registry.ts b/packages/opencode/src/kilocode/tool/registry.ts index a59b642c31d..db995da290d 100644 --- a/packages/opencode/src/kilocode/tool/registry.ts +++ b/packages/opencode/src/kilocode/tool/registry.ts @@ -1,10 +1,11 @@ // kilocode_change - new file import { CodebaseSearchTool } from "../../tool/warpgrep" import { RecallTool } from "../../tool/recall" -import { Tool } from "../../tool" +import * as Tool from "../../tool/tool" import { Flag } from "@/flag/flag" -import { ProviderID } from "../../provider/schema" import { Effect } from "effect" +import { KiloIndexing } from "@/kilocode/indexing" +import { SemanticSearchTool } from "@/kilocode/tool/semantic-search" export namespace KiloToolRegistry { /** Resolve Kilo-specific tool Infos outside any InstanceState, so their Truncate/Agent deps are @@ -12,16 +13,18 @@ export namespace KiloToolRegistry { export function infos() { return Effect.gen(function* () { const codebase = yield* CodebaseSearchTool + const semantic = yield* SemanticSearchTool const recall = yield* RecallTool - return { codebase, recall } + return { codebase, semantic, recall } }) } /** Finalize Kilo-specific tools into Tool.Defs. Call this inside the InstanceState state Effect — * it has no Service deps beyond what Tool.init itself needs. */ - export function build(tools: { codebase: Tool.Info; recall: Tool.Info }) { + export function build(tools: { codebase: Tool.Info; semantic: Tool.Info; recall: Tool.Info }) { return Effect.all({ codebase: Tool.init(tools.codebase), + semantic: Tool.init(tools.semantic), recall: Tool.init(tools.recall), }) } @@ -43,10 +46,15 @@ export namespace KiloToolRegistry { /** Kilo-specific tools to append to the builtin list */ export function extra( - tools: { codebase: Tool.Def; recall: Tool.Def }, + tools: { codebase: Tool.Def; semantic: Tool.Def; recall: Tool.Def }, cfg: { experimental?: { codebase_search?: boolean } }, ): Tool.Def[] { - return [...(cfg.experimental?.codebase_search === true ? [tools.codebase] : []), tools.recall] + const ready = KiloIndexing.ready() + return [ + ...(cfg.experimental?.codebase_search === true ? [tools.codebase] : []), + ...(ready ? [tools.semantic] : []), + tools.recall, + ] } /** Check for E2E LLM URL (uses KILO_E2E_LLM_URL env var) */ diff --git a/packages/opencode/src/kilocode/tool/semantic-search.ts b/packages/opencode/src/kilocode/tool/semantic-search.ts new file mode 100644 index 00000000000..c6452766451 --- /dev/null +++ b/packages/opencode/src/kilocode/tool/semantic-search.ts @@ -0,0 +1,126 @@ +import z from "zod" +import { Effect } from "effect" +import path from "path" +import * as Tool from "@/tool/tool" +import { KiloIndexing } from "@/kilocode/indexing" +import { Instance } from "@/project/instance" + +import DESCRIPTION from "./semantic-search.txt" + +const Parameters = z.object({ + query: z.string().describe("The search query, expressed in natural language."), + path: z + .string() + .optional() + .describe( + "Limit search to specific subdirectory (relative to the current workspace directory). Leave empty for entire workspace.", + ), +}) + +type SearchResult = { + filePath: string + score: number + startLine: number + endLine: number + codeChunk: string +} + +type Meta = { + results: SearchResult[] +} + +export const SemanticSearchTool = Tool.define( + "semantic_search", + Effect.succeed({ + description: DESCRIPTION, + parameters: Parameters, + execute: (params, ctx) => + Effect.gen(function* () { + if (!params.query) { + throw new Error("query is required") + } + + yield* ctx.ask({ + permission: "semantic_search", + patterns: [params.query], + always: ["*"], + metadata: { + query: params.query, + path: params.path, + }, + }) + + const prefix = normalizeSearchPath(params.path) + const matches = yield* Effect.promise(() => KiloIndexing.search(params.query, prefix)) + + const results = matches.flatMap((item) => { + const payload = item.payload + if (!payload) return [] + if ( + typeof payload.filePath !== "string" || + typeof payload.codeChunk !== "string" || + typeof payload.startLine !== "number" || + typeof payload.endLine !== "number" + ) { + return [] + } + + return [ + { + filePath: normalizePath(payload.filePath), + score: item.score, + startLine: payload.startLine, + endLine: payload.endLine, + codeChunk: payload.codeChunk, + }, + ] + }) + + if (results.length === 0) { + return { + title: "Codebase Search", + metadata: { + results, + }, + output: `No relevant code found for "${params.query}"${prefix ? ` in ${normalizePath(prefix)}` : ""}.`, + } + } + + const output = [ + `Found ${results.length} result${results.length === 1 ? "" : "s"} for "${params.query}"${prefix ? ` in ${normalizePath(prefix)}` : ""}.`, + "", + ...results.flatMap((item, index) => { + return [ + `${index + 1}. ${item.filePath}:${item.startLine}-${item.endLine} (score ${item.score.toFixed(4)})`, + item.codeChunk, + "", + ] + }), + ] + + return { + title: "Codebase Search", + metadata: { + results, + }, + output: output.join("\n").trim(), + } + }).pipe(Effect.orDie), + }), +) + +function normalizeSearchPath(input?: string): string | undefined { + if (!input) return undefined + + const absolute = path.resolve(Instance.directory, input) + const relative = path.relative(Instance.directory, absolute) + if (!relative || relative === ".") return undefined + if (path.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${path.sep}`)) { + throw new Error(`path must be within the current workspace: ${input}`) + } + return path.normalize(relative) +} + +function normalizePath(value: string): string { + return value.replaceAll("\\", "/") +} diff --git a/packages/opencode/src/kilocode/tool/semantic-search.txt b/packages/opencode/src/kilocode/tool/semantic-search.txt new file mode 100644 index 00000000000..f7b1f36718c --- /dev/null +++ b/packages/opencode/src/kilocode/tool/semantic-search.txt @@ -0,0 +1,12 @@ +- Find files most relevant to the search query using semantic search. +- Searches based on meaning rather than exact text matches. +- By default searches entire workspace, with capability to filter by path. + +Usage Notes: +- Use this tool any time you start exploring a new area of the codebase. This tool will help discover all areas of the codebase related to the query, even if they do not match an exact symbol name. +- Queries MUST be in English (translate if needed). +- Prefer the Grep tool if you know the exact symbol name to search for and do not need semantic context. + +Example Queries: +- "User login and password hashing" +- "database connection pooling" diff --git a/packages/opencode/src/kilocode/ts-client.ts b/packages/opencode/src/kilocode/ts-client.ts index 6c9ccc24d90..3cd3a955be2 100644 --- a/packages/opencode/src/kilocode/ts-client.ts +++ b/packages/opencode/src/kilocode/ts-client.ts @@ -72,12 +72,13 @@ export namespace TsClient { // trigger notify.open() but should NOT spawn tsgo. The actual // check is deferred to waitForDiagnostics() which is only // called when tools need diagnostics (write, edit, apply_patch). + return 0 }, }, get diagnostics() { return diagnostics }, - async waitForDiagnostics(_input: { path: string }) { + async waitForDiagnostics(_input: { path: string; version: number; mode?: "document" | "full"; after?: number }) { // Run tsgo --noEmit and wait for results. Coalesces concurrent calls. // 30s cap matches the process timeout in TsCheck.run(). Silent catch // matches the real LSPClient's .catch(() => {}) on its 3s timeout. diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index b20e8ae7f00..f6d5110a6c4 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -14,6 +14,16 @@ import { withTimeout } from "../util/timeout" import { Filesystem } from "../util" const DIAGNOSTICS_DEBOUNCE_MS = 150 +const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000 +const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000 +const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000 + +const INITIALIZE_TIMEOUT_MS = 45_000 + +// LSP spec constants +const FILE_CHANGE_CREATED = 1 +const FILE_CHANGE_CHANGED = 2 +const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2 const log = Log.create({ service: "lsp.client" }) @@ -38,48 +48,194 @@ export const Event = { ), } +type DocumentDiagnosticReport = { + items?: Diagnostic[] + relatedDocuments?: Record +} + +type WorkspaceDiagnosticReport = { + items?: { + uri?: string + items?: Diagnostic[] + }[] +} + +type DiagnosticRequestResult = { + handled: boolean + matched: boolean + byFile: Map +} + +type CapabilityRegistration = { + id: string + method: string + registerOptions?: { + identifier?: string + workspaceDiagnostics?: boolean + } +} + +type ServerCapabilities = { + textDocumentSync?: + | number + | { + change?: number + } + diagnosticProvider?: unknown + [key: string]: unknown +} + +function getFilePath(uri: string) { + if (!uri.startsWith("file://")) return + return Filesystem.normalizePath(fileURLToPath(uri)) +} + +function getSyncKind(capabilities?: ServerCapabilities) { + if (!capabilities) return + const sync = capabilities.textDocumentSync + if (typeof sync === "number") return sync + return sync?.change +} + +function endPosition(text: string) { + const lines = text.split(/\r\n|\r|\n/) + return { + line: lines.length - 1, + character: lines.at(-1)?.length ?? 0, + } +} + +function dedupeDiagnostics(items: Diagnostic[]) { + const seen = new Set() + return items.filter((item) => { + const key = JSON.stringify({ + code: item.code, + severity: item.severity, + message: item.message, + source: item.source, + range: item.range, + }) + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function configurationValue(settings: unknown, section?: string) { + if (!section) return settings ?? null + const result = section.split(".").reduce((acc, key) => { + if (!acc || typeof acc !== "object" || !(key in acc)) return undefined + return (acc as Record)[key] + }, settings) + return result ?? null +} + +// TypeScript's built-in LSP pushes diagnostics aggressively on first open. +// We seed the push cache on the very first publish so waitForFreshPush can +// resolve immediately instead of waiting for a second debounced push. +function shouldSeedDiagnosticsOnFirstPush(serverID: string) { + return serverID === "typescript" +} + export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) { - const l = log.clone().tag("serverID", input.serverID) - l.info("starting client") + const logger = log.clone().tag("serverID", input.serverID) + logger.info("starting client") const connection = createMessageConnection( new StreamMessageReader(input.server.process.stdout as any), new StreamMessageWriter(input.server.process.stdin as any), ) + // Server stderr can contain both real errors and routine informational logs, + // which is normal stderr practice for some tools. Keep the raw stream at + // debug so users can opt in with --print-logs --log-level DEBUG without + // polluting normal logs. + input.server.process.stderr?.on("data", (data: Buffer) => { + const text = data.toString().trim() + if (text) logger.debug("server stderr", { text: text.slice(0, 1000) }) + }) + + // --- Connection state --- + + const pushDiagnostics = new Map() + const pullDiagnostics = new Map() + const published = new Map() + const diagnosticRegistrations = new Map() + const registrationListeners = new Set<() => void>() + const mergedDiagnostics = (filePath: string) => + dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])]) + const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => { + pushDiagnostics.set(filePath, next) + Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) + } + const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => { + pullDiagnostics.set(filePath, next) + } + const emitRegistrationChange = () => { + for (const listener of [...registrationListeners]) listener() + } + + // --- LSP connection handlers --- - const diagnostics = new Map() connection.onNotification("textDocument/publishDiagnostics", (params) => { - const filePath = Filesystem.normalizePath(fileURLToPath(params.uri)) - l.info("textDocument/publishDiagnostics", { + const filePath = getFilePath(params.uri) + if (!filePath) return + logger.info("textDocument/publishDiagnostics", { path: filePath, count: params.diagnostics.length, + version: params.version, }) - const exists = diagnostics.has(filePath) - diagnostics.set(filePath, params.diagnostics) - if (!exists && input.serverID === "typescript") return - Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID }) + published.set(filePath, { + at: Date.now(), + version: typeof params.version === "number" ? params.version : undefined, + }) + if (shouldSeedDiagnosticsOnFirstPush(input.serverID) && !pushDiagnostics.has(filePath)) { + pushDiagnostics.set(filePath, params.diagnostics) + return + } + updatePushDiagnostics(filePath, params.diagnostics) }) connection.onRequest("window/workDoneProgress/create", (params) => { - l.info("window/workDoneProgress/create", params) + logger.info("window/workDoneProgress/create", params) return null }) - connection.onRequest("workspace/configuration", async () => { - // Return server initialization options - return [input.server.initialization ?? {}] + connection.onRequest("workspace/configuration", async (params) => { + const items = (params as { items?: { section?: string }[] }).items ?? [] + return items.map((item) => configurationValue(input.server.initialization, item.section)) + }) + connection.onRequest("client/registerCapability", async (params) => { + const registrations = (params as { registrations?: CapabilityRegistration[] }).registrations ?? [] + let changed = false + for (const registration of registrations) { + if (registration.method !== "textDocument/diagnostic") continue + diagnosticRegistrations.set(registration.id, registration) + changed = true + } + if (changed) emitRegistrationChange() + }) + connection.onRequest("client/unregisterCapability", async (params) => { + const registrations = (params as { unregisterations?: { id: string; method: string }[] }).unregisterations ?? [] + let changed = false + for (const registration of registrations) { + if (registration.method !== "textDocument/diagnostic") continue + diagnosticRegistrations.delete(registration.id) + changed = true + } + if (changed) emitRegistrationChange() }) - connection.onRequest("client/registerCapability", async () => {}) - connection.onRequest("client/unregisterCapability", async () => {}) connection.onRequest("workspace/workspaceFolders", async () => [ { name: "workspace", uri: pathToFileURL(input.root).href, }, ]) + connection.onRequest("workspace/diagnostic/refresh", async () => null) connection.listen() - l.info("sending initialize") - await withTimeout( - connection.sendRequest("initialize", { + // --- Initialize handshake --- + + logger.info("sending initialize") + const initialized = await withTimeout( + connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", { rootUri: pathToFileURL(input.root).href, processId: input.server.process.pid, workspaceFolders: [ @@ -100,21 +256,28 @@ export async function create(input: { serverID: string; server: LSPServer.Handle didChangeWatchedFiles: { dynamicRegistration: true, }, + diagnostics: { + refreshSupport: false, + }, }, textDocument: { synchronization: { didOpen: true, didChange: true, }, + diagnostic: { + dynamicRegistration: true, + relatedDocumentSupport: true, + }, publishDiagnostics: { - versionSupport: true, + versionSupport: false, }, }, }, }), - 45_000, + INITIALIZE_TIMEOUT_MS, ).catch((err) => { - l.error("initialize error", { error: err }) + logger.error("initialize error", { error: err }) throw new InitializeError( { serverID: input.serverID }, { @@ -123,6 +286,9 @@ export async function create(input: { serverID: string; server: LSPServer.Handle ) }) + const syncKind = getSyncKind(initialized.capabilities) + const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider) + await connection.sendNotification("initialized", {}) if (input.server.initialization) { @@ -131,9 +297,280 @@ export async function create(input: { serverID: string; server: LSPServer.Handle }) } - const files: { - [path: string]: number - } = {} + const files: Record = {} + + // --- Diagnostic helpers --- + + const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => { + const handled = results.some((result) => result.handled) + const matched = results.some((result) => result.matched) + if (!handled) return { handled: false, matched: false } + + const merged = new Map() + for (const result of results) { + for (const [target, items] of result.byFile.entries()) { + const existing = merged.get(target) ?? [] + merged.set(target, existing.concat(items)) + } + } + + if (matched && !merged.has(filePath)) merged.set(filePath, []) + for (const [target, items] of merged.entries()) { + updatePullDiagnostics(target, dedupeDiagnostics(items)) + } + + return { handled, matched } + } + + async function requestDiagnosticReport(filePath: string, identifier?: string): Promise { + const report = await withTimeout( + connection.sendRequest("textDocument/diagnostic", { + ...(identifier ? { identifier } : {}), + textDocument: { + uri: pathToFileURL(filePath).href, + }, + }), + DIAGNOSTICS_REQUEST_TIMEOUT_MS, + ).catch(() => null) + if (!report) return { handled: false, matched: false, byFile: new Map() } + + const byFile = new Map() + const push = (target: string, items: Diagnostic[]) => { + const existing = byFile.get(target) ?? [] + byFile.set(target, existing.concat(items)) + } + + let handled = false + let matched = false + if (Array.isArray(report.items)) { + push(filePath, report.items) + handled = true + matched = true + } + for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) { + const relatedPath = getFilePath(uri) + if (!relatedPath || !Array.isArray(related.items)) continue + push(relatedPath, related.items) + handled = true + matched = matched || relatedPath === filePath + } + + return { handled, matched, byFile } + } + + async function requestWorkspaceDiagnosticReport( + filePath: string, + identifier?: string, + ): Promise { + const report = await withTimeout( + connection.sendRequest("workspace/diagnostic", { + ...(identifier ? { identifier } : {}), + previousResultIds: [], + }), + DIAGNOSTICS_REQUEST_TIMEOUT_MS, + ).catch(() => null) + if (!report) return { handled: false, matched: false, byFile: new Map() } + + const byFile = new Map() + let matched = false + for (const item of report.items ?? []) { + const relatedPath = item.uri ? getFilePath(item.uri) : undefined + if (!relatedPath || !Array.isArray(item.items)) continue + const existing = byFile.get(relatedPath) ?? [] + byFile.set(relatedPath, existing.concat(item.items)) + matched = matched || relatedPath === filePath + } + + return { handled: true, matched, byFile } + } + + function documentPullState() { + const documentRegistrations = [...diagnosticRegistrations.values()].filter( + (registration) => registration.registerOptions?.workspaceDiagnostics !== true, + ) + return { + documentIdentifiers: [ + ...new Set(documentRegistrations.flatMap((registration) => registration.registerOptions?.identifier ?? [])), + ], + supported: hasStaticPullDiagnostics || documentRegistrations.length > 0, + } + } + + function workspacePullState() { + const workspaceRegistrations = [...diagnosticRegistrations.values()].filter( + (registration) => registration.registerOptions?.workspaceDiagnostics === true, + ) + return { + workspaceIdentifiers: [ + ...new Set(workspaceRegistrations.flatMap((registration) => registration.registerOptions?.identifier ?? [])), + ], + supported: workspaceRegistrations.length > 0, + } + } + + const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) => + results.some((result) => (result.byFile.get(filePath)?.length ?? 0) > 0) + + async function requestDiagnostics( + filePath: string, + requests: Promise[], + done: (results: DiagnosticRequestResult[]) => boolean, + ) { + if (!requests.length) return { handled: false, matched: false } + + const results: DiagnosticRequestResult[] = [] + return new Promise<{ handled: boolean; matched: boolean }>((resolve) => { + let pending = requests.length + let resolved = false + const finish = (merged: { handled: boolean; matched: boolean }, force = false) => { + if (resolved) return + if (!force && !done(results)) return + resolved = true + resolve(merged) + } + + for (const request of requests) { + request.then((result) => { + results.push(result) + pending -= 1 + const merged = mergeResults(filePath, results) + finish(merged) + if (pending === 0) finish(merged, true) + }) + } + }) + } + + // LATENCY-CRITICAL: dispatch identifier pulls in parallel and unblock once one + // batch already produced diagnostics for the current file. Let slower pulls keep + // merging in the background; do not sequence identifier-by-identifier, and do + // not add a post-match settle/debounce delay. See PR #23771. + async function requestDocumentDiagnostics(filePath: string) { + const state = documentPullState() + if (!state.supported) return { handled: false, matched: false } + return requestDiagnostics( + filePath, + [ + requestDiagnosticReport(filePath), + ...state.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)), + ], + (results) => hasCurrentFileDiagnostics(filePath, results), + ) + } + + async function requestFullDiagnostics(filePath: string) { + const documentState = documentPullState() + const workspaceState = workspacePullState() + if (!documentState.supported && !workspaceState.supported) return { handled: false, matched: false } + return mergeResults( + filePath, + await Promise.all([ + ...(documentState.supported ? [requestDiagnosticReport(filePath)] : []), + ...documentState.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)), + ...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []), + ...workspaceState.workspaceIdentifiers.map((identifier) => + requestWorkspaceDiagnosticReport(filePath, identifier), + ), + ]), + ) + } + + function waitForRegistrationChange(timeout: number) { + if (timeout <= 0) return Promise.resolve(false) + return new Promise((resolve) => { + let finished = false + let timer: ReturnType | undefined + const finish = (result: boolean) => { + if (finished) return + finished = true + if (timer) clearTimeout(timer) + registrationListeners.delete(listener) + resolve(result) + } + const listener = () => finish(true) + registrationListeners.add(listener) + timer = setTimeout(() => finish(false), timeout) + }) + } + + function waitForFreshPush(request: { path: string; version: number; after: number; timeout: number }) { + if (request.timeout <= 0) return Promise.resolve(false) + return new Promise((resolve) => { + let finished = false + let debounceTimer: ReturnType | undefined + let timeoutTimer: ReturnType | undefined + let unsub: (() => void) | undefined + const finish = (result: boolean) => { + if (finished) return + finished = true + if (debounceTimer) clearTimeout(debounceTimer) + if (timeoutTimer) clearTimeout(timeoutTimer) + unsub?.() + resolve(result) + } + const schedule = () => { + const hit = published.get(request.path) + if (!hit) return + if (typeof hit.version === "number" && hit.version !== request.version) return + if (hit.at < request.after && hit.version !== request.version) return + if (debounceTimer) clearTimeout(debounceTimer) + debounceTimer = setTimeout(() => finish(true), Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at))) + } + + timeoutTimer = setTimeout(() => finish(false), request.timeout) + unsub = Bus.subscribe(Event.Diagnostics, (event) => { + if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return + schedule() + }) + schedule() + }) + } + + async function waitForDocumentDiagnostics(request: { path: string; version: number; after?: number }) { + const startedAt = request.after ?? Date.now() + const pushWait = waitForFreshPush({ + path: request.path, + version: request.version, + after: startedAt, + timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS, + }) + + while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) { + const result = await requestDocumentDiagnostics(request.path) + if (result.matched) return + const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt) + if (remaining <= 0) return + const next = await Promise.race([ + pushWait.then((ready) => (ready ? "push" : ("timeout" as const))), + waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : ("timeout" as const))), + ]) + if (next !== "registration") return + } + } + + async function waitForFullDiagnostics(request: { path: string; version: number; after?: number }) { + const startedAt = request.after ?? Date.now() + const pushWait = waitForFreshPush({ + path: request.path, + version: request.version, + after: startedAt, + timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS, + }) + + while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) { + const result = await requestFullDiagnostics(request.path) + if (result.handled || result.matched) return + const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt) + if (remaining <= 0) return + const next = await Promise.race([ + pushWait.then((ready) => (ready ? "push" : ("timeout" as const))), + waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : ("timeout" as const))), + ]) + if (next !== "registration") return + } + } + + // --- Public API --- const result = { root: input.root, @@ -145,26 +582,32 @@ export async function create(input: { serverID: string; server: LSPServer.Handle }, notify: { async open(request: { path: string }) { - request.path = path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path) + request.path = Filesystem.normalizePath( + path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path), + ) const text = await Filesystem.readText(request.path) const extension = path.extname(request.path) const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext" - const version = files[request.path] - if (version !== undefined) { - log.info("workspace/didChangeWatchedFiles", request) + const document = files[request.path] + if (document !== undefined) { + // Do not wipe diagnostics on didChange. Some servers (e.g. clangd) only + // re-emit diagnostics when the content actually changes, so clearing + // here would lose errors for no-op touchFile calls. Let the server's + // next push/pull overwrite naturally. + logger.info("workspace/didChangeWatchedFiles", request) await connection.sendNotification("workspace/didChangeWatchedFiles", { changes: [ { uri: pathToFileURL(request.path).href, - type: 2, // Changed + type: FILE_CHANGE_CHANGED, }, ], }) - const next = version + 1 - files[request.path] = next - log.info("textDocument/didChange", { + const next = document.version + 1 + files[request.path] = { version: next, text } + logger.info("textDocument/didChange", { path: request.path, version: next, }) @@ -173,23 +616,35 @@ export async function create(input: { serverID: string; server: LSPServer.Handle uri: pathToFileURL(request.path).href, version: next, }, - contentChanges: [{ text }], + contentChanges: + syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL + ? [ + { + range: { + start: { line: 0, character: 0 }, + end: endPosition(document.text), + }, + text, + }, + ] + : [{ text }], }) - return + return next } - log.info("workspace/didChangeWatchedFiles", request) + logger.info("workspace/didChangeWatchedFiles", request) await connection.sendNotification("workspace/didChangeWatchedFiles", { changes: [ { uri: pathToFileURL(request.path).href, - type: 1, // Created + type: FILE_CHANGE_CREATED, }, ], }) - log.info("textDocument/didOpen", request) - diagnostics.delete(request.path) + logger.info("textDocument/didOpen", request) + pushDiagnostics.delete(request.path) + pullDiagnostics.delete(request.path) await connection.sendNotification("textDocument/didOpen", { textDocument: { uri: pathToFileURL(request.path).href, @@ -198,52 +653,42 @@ export async function create(input: { serverID: string; server: LSPServer.Handle text, }, }) - files[request.path] = 0 - return + files[request.path] = { version: 0, text } + return 0 }, }, get diagnostics() { - return diagnostics + const result = new Map() + for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) { + result.set(key, mergedDiagnostics(key)) + } + return result }, - async waitForDiagnostics(request: { path: string }) { + async waitForDiagnostics(request: { path: string; version: number; mode?: "document" | "full"; after?: number }) { const normalizedPath = Filesystem.normalizePath( path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path), ) - log.info("waiting for diagnostics", { path: normalizedPath }) - let unsub: () => void - let debounceTimer: ReturnType | undefined - return await withTimeout( - new Promise((resolve) => { - unsub = Bus.subscribe(Event.Diagnostics, (event) => { - if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) { - // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax) - if (debounceTimer) clearTimeout(debounceTimer) - debounceTimer = setTimeout(() => { - log.info("got diagnostics", { path: normalizedPath }) - unsub?.() - resolve() - }, DIAGNOSTICS_DEBOUNCE_MS) - } - }) - }), - 3000, - ) - .catch(() => {}) - .finally(() => { - if (debounceTimer) clearTimeout(debounceTimer) - unsub?.() - }) + logger.info("waiting for diagnostics", { + path: normalizedPath, + mode: request.mode ?? "full", + version: request.version, + }) + if (request.mode === "document") { + await waitForDocumentDiagnostics({ path: normalizedPath, version: request.version, after: request.after }) + return + } + await waitForFullDiagnostics({ path: normalizedPath, version: request.version, after: request.after }) }, async shutdown() { - l.info("shutting down") + logger.info("shutting down") connection.end() connection.dispose() await Process.stop(input.server.process) - l.info("shutdown") + logger.info("shutdown") }, } - l.info("initialized") + logger.info("initialized") return result } diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts index 309a9fa84d8..08a8a3e4c32 100644 --- a/packages/opencode/src/lsp/lsp.ts +++ b/packages/opencode/src/lsp/lsp.ts @@ -10,10 +10,12 @@ import { Config } from "../config" import { Flag } from "@/flag/flag" import { Process } from "../util" import { spawn as lspspawn } from "./launch" -import { Effect, Layer, Context } from "effect" +import { Effect, Layer, Context, Schema } from "effect" import { InstanceState } from "@/effect" import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { TsClient } from "../kilocode/ts-client" // kilocode_change +import { withStatics } from "@/util/schema" +import { zod, ZodOverride } from "@/util/effect-zod" const log = Log.create({ service: "lsp" }) @@ -21,60 +23,53 @@ export const Event = { Updated: BusEvent.define("lsp.updated", z.object({})), } -export const Range = z - .object({ - start: z.object({ - line: z.number(), - character: z.number(), - }), - end: z.object({ - line: z.number(), - character: z.number(), - }), - }) - .meta({ - ref: "Range", - }) -export type Range = z.infer +const Position = Schema.Struct({ + line: Schema.Number, + character: Schema.Number, +}) -export const Symbol = z - .object({ - name: z.string(), - kind: z.number(), - location: z.object({ - uri: z.string(), - range: Range, - }), - }) - .meta({ - ref: "Symbol", - }) -export type Symbol = z.infer +export const Range = Schema.Struct({ + start: Position, + end: Position, +}) + .annotate({ identifier: "Range" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Range = typeof Range.Type -export const DocumentSymbol = z - .object({ - name: z.string(), - detail: z.string().optional(), - kind: z.number(), +export const Symbol = Schema.Struct({ + name: Schema.String, + kind: Schema.Number, + location: Schema.Struct({ + uri: Schema.String, range: Range, - selectionRange: Range, - }) - .meta({ - ref: "DocumentSymbol", - }) -export type DocumentSymbol = z.infer + }), +}) + .annotate({ identifier: "Symbol" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Symbol = typeof Symbol.Type -export const Status = z - .object({ - id: z.string(), - name: z.string(), - root: z.string(), - status: z.union([z.literal("connected"), z.literal("error")]), - }) - .meta({ - ref: "LSPStatus", - }) -export type Status = z.infer +export const DocumentSymbol = Schema.Struct({ + name: Schema.String, + detail: Schema.optional(Schema.String), + kind: Schema.Number, + range: Range, + selectionRange: Range, +}) + .annotate({ identifier: "DocumentSymbol" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type DocumentSymbol = typeof DocumentSymbol.Type + +export const Status = Schema.Struct({ + id: Schema.String, + name: Schema.String, + root: Schema.String, + status: Schema.Literals(["connected", "error"]).annotate({ + [ZodOverride]: z.union([z.literal("connected"), z.literal("error")]), + }), +}) + .annotate({ identifier: "LSPStatus" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Status = typeof Status.Type enum SymbolKind { File = 1, @@ -142,7 +137,7 @@ export interface Interface { readonly init: () => Effect.Effect readonly status: () => Effect.Effect readonly hasClients: (file: string) => Effect.Effect - readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect + readonly touchFile: (input: string, diagnostics?: "document" | "full") => Effect.Effect readonly diagnostics: () => Effect.Effect> readonly hover: (input: LocInput) => Effect.Effect readonly definition: (input: LocInput) => Effect.Effect @@ -379,15 +374,21 @@ export const layer = Layer.effect( }) }) - const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) { + const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") { log.info("touching file", { file: input }) const clients = yield* getClients(input) yield* Effect.promise(() => Promise.all( clients.map(async (client) => { - const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve() - await client.notify.open({ path: input }) - return wait + const after = Date.now() + const version = await client.notify.open({ path: input }) + if (!diagnostics) return + return client.waitForDiagnostics({ + path: input, + version, + mode: diagnostics, + after, + }) }), ).catch((err) => { log.error("failed to touch file", { err, file: input }) diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 30ef500aacb..8cf65635c0a 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -488,7 +488,7 @@ export const Pyright: Info = { const args = [] if (!binary) { if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return - const resolved = await Npm.which("pyright") + const resolved = await Npm.which("pyright", "pyright-langserver") if (!resolved) return binary = resolved } @@ -703,32 +703,32 @@ export const CSharp: Info = { root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), extensions: [".cs"], async spawn(root) { - let bin = which("csharp-ls") + let bin = which("roslyn-language-server") if (!bin) { if (!which("dotnet")) { - log.error(".NET SDK is required to install csharp-ls") + log.error(".NET SDK is required to install roslyn-language-server") return } if (Flag.KILO_DISABLE_LSP_DOWNLOAD) return - log.info("installing csharp-ls via dotnet tool") - const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], { + log.info("installing roslyn-language-server via dotnet tool") + const proc = Process.spawn(["dotnet", "tool", "install", "--global", "roslyn-language-server", "--prerelease"], { stdout: "pipe", stderr: "pipe", stdin: "pipe", }) const exit = await proc.exited if (exit !== 0) { - log.error("Failed to install csharp-ls") + log.error("Failed to install roslyn-language-server") return } - bin = path.join(Global.Path.bin, "csharp-ls" + (process.platform === "win32" ? ".exe" : "")) - log.info(`installed csharp-ls`, { bin }) + bin = path.join(Global.Path.bin, "roslyn-language-server" + (process.platform === "win32" ? ".exe" : "")) + log.info(`installed roslyn-language-server`, { bin }) } return { - process: spawn(bin, { + process: spawn(bin, ["--stdio", "--autoLoadProjects"], { cwd: root, }), } diff --git a/packages/opencode/src/npm/config.ts b/packages/opencode/src/npm/config.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 477e99e06af..d6322d5488b 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -1,8 +1,11 @@ export * as Npm from "." import path from "path" +import { fileURLToPath } from "url" import npa from "npm-package-arg" import semver from "semver" +import Config from "@npmcli/config" +import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js" import { Effect, Schema, Context, Layer, Option, FileSystem } from "effect" import { NodeFileSystem } from "@effect/platform-node" import { AppFileSystem } from "@opencode-ai/shared/filesystem" @@ -34,18 +37,45 @@ export interface Interface { }, ) => Effect.Effect readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect - readonly which: (pkg: string) => Effect.Effect> + readonly which: (pkg: string, bin?: string) => Effect.Effect> } export class Service extends Context.Service()("@opencode/Npm") {} const illegal = process.platform === "win32" ? new Set(["<", ">", ":", '"', "|", "?", "*"]) : undefined +const npmPath = fileURLToPath(new URL("../..", import.meta.url)) export function sanitize(pkg: string) { if (!illegal) return pkg return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("") } +const loadOptions = (dir: string) => + Effect.tryPromise({ + try: async () => { + const config = new Config({ + npmPath, + cwd: dir, + env: { ...process.env }, + argv: [process.execPath, process.execPath], + execPath: process.execPath, + platform: process.platform, + definitions, + flatten, + nerfDarts, + shorthands, + warn: false, + }) + await config.load() + return config.flat + }, + catch: (cause) => + new InstallFailedError({ + cause, + dir, + }), + }) + const resolveEntryPoint = (name: string, dir: string): EntryPoint => { let entrypoint: Option.Option try { @@ -81,7 +111,10 @@ export const layer = Layer.effect( Effect.gen(function* () { yield* flock.acquire(`npm-install:${input.dir}`) const { Arborist } = yield* Effect.promise(() => import("@npmcli/arborist")) + const add = input.add ?? [] + const npmOptions = yield* loadOptions(input.dir) const arborist = new Arborist({ + ...npmOptions, path: input.dir, binLinks: true, progress: false, @@ -91,14 +124,15 @@ export const layer = Layer.effect( return yield* Effect.tryPromise({ try: () => arborist.reify({ - add: input?.add || [], + ...npmOptions, + add, save: true, saveType: "prod", }), catch: (cause) => new InstallFailedError({ cause, - add: input?.add, + add, dir: input.dir, }), }) as Effect.Effect @@ -207,7 +241,7 @@ export const layer = Layer.effect( return }, Effect.scoped) - const which = Effect.fn("Npm.which")(function* (pkg: string) { + const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) { const dir = directory(pkg) const binDir = path.join(dir, "node_modules", ".bin") @@ -215,6 +249,9 @@ export const layer = Layer.effect( const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[]))) if (files.length === 0) return Option.none() + // Caller picked a specific bin (e.g. pyright exposes both `pyright` and + // `pyright-langserver`); trust the hint if the package provides it. + if (bin) return files.includes(bin) ? Option.some(bin) : Option.none() if (files.length === 1) return Option.some(files[0]) const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option) @@ -223,11 +260,11 @@ export const layer = Layer.effect( const parsed = pkgJson.value as { bin?: string | Record } if (parsed?.bin) { const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg - const bin = parsed.bin - if (typeof bin === "string") return Option.some(unscoped) - const keys = Object.keys(bin) + const parsedBin = parsed.bin + if (typeof parsedBin === "string") return Option.some(unscoped) + const keys = Object.keys(parsedBin) if (keys.length === 1) return Option.some(keys[0]) - return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0]) + return parsedBin[unscoped] ? Option.some(unscoped) : Option.some(keys[0]) } } diff --git a/packages/opencode/src/npmcli-config.d.ts b/packages/opencode/src/npmcli-config.d.ts new file mode 100644 index 00000000000..c9b20517ad9 --- /dev/null +++ b/packages/opencode/src/npmcli-config.d.ts @@ -0,0 +1,43 @@ +declare module "@npmcli/config" { + type Data = Record + type Where = "default" | "builtin" | "global" | "user" | "project" | "env" | "cli" + + namespace Config { + interface Options { + definitions: Data + shorthands: Record + npmPath: string + flatten?: (input: Data, flat?: Data) => Data + nerfDarts?: string[] + argv?: string[] + cwd?: string + env?: NodeJS.ProcessEnv + execPath?: string + platform?: NodeJS.Platform + warn?: boolean + } + } + + class Config { + constructor(input: Config.Options) + + readonly data: Map + readonly flat: Data + + load(): Promise + } + + export = Config +} + +declare module "@npmcli/config/lib/definitions" { + export const definitions: Record + export const shorthands: Record + export const flatten: (input: Record, flat?: Record) => Record + export const nerfDarts: string[] + export const proxyEnv: string[] +} + +declare module "@npmcli/config/lib/definitions/index.js" { + export * from "@npmcli/config/lib/definitions" +} diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index 2b3e9c20b3c..80dbf03be59 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -3,6 +3,7 @@ import * as path from "path" import * as fs from "fs/promises" import { Log } from "../util" import { Encoding } from "../kilocode/encoding" // kilocode_change +import * as Bom from "../util/bom" const log = Log.create({ service: "patch" }) @@ -305,24 +306,26 @@ export function maybeParseApplyPatch( interface ApplyPatchFileUpdate { unified_diff: string content: string + bom: boolean encoding: string // kilocode_change } export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate { // Read original file content - let originalContent: string + let originalContent: ReturnType let encoding: string // kilocode_change - track detected encoding for round-trip write try { - // kilocode_change start - encoding-aware read replaces readFileSync(filePath, "utf-8") + // kilocode_change start - encoding-aware read replaces readFileSync(filePath, "utf-8"). + // Encoding.readSync strips UTF-8 BOMs so the BOM flag is derived from the encoding label. const result = Encoding.readSync(filePath) - originalContent = result.text + originalContent = { bom: result.encoding === "utf-8-bom", text: result.text } encoding = result.encoding // kilocode_change end } catch (error) { throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error }) } - let originalLines = originalContent.split("\n") + let originalLines = originalContent.text.split("\n") // Drop trailing empty element for consistent line counting if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") { @@ -337,14 +340,16 @@ export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFile newLines.push("") } - const newContent = newLines.join("\n") + const next = Bom.split(newLines.join("\n")) + const newContent = next.text // Generate unified diff - const unifiedDiff = generateUnifiedDiff(originalContent, newContent) + const unifiedDiff = generateUnifiedDiff(originalContent.text, newContent) return { unified_diff: unifiedDiff, content: newContent, + bom: originalContent.bom || next.bom, encoding, // kilocode_change - include detected encoding for round-trip write } } @@ -549,13 +554,13 @@ export async function applyHunksToFiles(hunks: Hunk[]): Promise { if (hunk.move_path) { // Handle file move - await Encoding.write(hunk.move_path, fileUpdate.content, fileUpdate.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces fs.mkdir + fs.writeFile + await Encoding.write(hunk.move_path, Bom.join(fileUpdate.content, fileUpdate.bom), fileUpdate.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces fs.mkdir + fs.writeFile await fs.unlink(hunk.path) modified.push(hunk.move_path) log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`) } else { // Regular update - await Encoding.write(hunk.path, fileUpdate.content, fileUpdate.encoding) // kilocode_change - encoding-aware write replaces fs.writeFile + await Encoding.write(hunk.path, Bom.join(fileUpdate.content, fileUpdate.bom), fileUpdate.encoding) // kilocode_change - encoding-aware write replaces fs.writeFile modified.push(hunk.path) log.info(`Updated file: ${hunk.path}`) } diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 46a3190fcf2..9e2d71b6331 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -457,8 +457,18 @@ function expand(pattern: string): string { } export function fromConfig(permission: ConfigPermission.Info) { + // Sort top-level keys so wildcard permissions (`*`, `mcp_*`) come before + // specific ones. Combined with `findLast` in evaluate(), this gives the + // intuitive semantic "specific tool rules override the `*` fallback" + // regardless of the user's JSON key order. Sub-pattern order inside a + // single permission key is preserved — only top-level keys are sorted. + const entries = Object.entries(permission).sort(([a], [b]) => { + const aWild = a.includes("*") + const bWild = b.includes("*") + return aWild === bWild ? 0 : aWild ? -1 : 1 + }) const ruleset: Ruleset = [] - for (const [key, value] of Object.entries(permission)) { + for (const [key, value] of entries) { if (typeof value === "string") { ruleset.push({ permission: key, action: value, pattern: "*" }) continue @@ -483,7 +493,7 @@ export function merge(...rulesets: Ruleset[]): Ruleset { return rulesets.flat() } -const EDIT_TOOLS = ["edit", "write", "apply_patch", "multiedit"] +const EDIT_TOOLS = ["edit", "write", "apply_patch"] export function disabled(tools: string[], ruleset: Ruleset): Set { const result = new Set() diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index 6ac9389a583..4eddc6a47ad 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -1,8 +1,7 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" -import { ZodOverride } from "@/util/effect-zod" +import { zod, ZodOverride } from "@/util/effect-zod" import { Newtype } from "@/util/schema" export class PermissionID extends Newtype()( @@ -13,5 +12,5 @@ export class PermissionID extends Newtype()( return this.make(Identifier.ascending("permission", id)) } - static readonly zod = Identifier.schema("permission") as unknown as z.ZodType + static readonly zod = zod(this) } diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 731eb85bdbb..c3e2b70fb81 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -385,6 +385,8 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { for (const [modelId, model] of Object.entries(provider.models)) { if (modelId.includes("codex")) continue if (allowedModels.has(model.api.id)) continue + const match = model.api.id.match(/^gpt-(\d+\.\d+)/) + if (match && parseFloat(match[1]) > 5.4) continue delete provider.models[modelId] } diff --git a/packages/opencode/src/plugin/loader.ts b/packages/opencode/src/plugin/loader.ts index e61612561bc..5d4f9c3f48f 100644 --- a/packages/opencode/src/plugin/loader.ts +++ b/packages/opencode/src/plugin/loader.ts @@ -10,6 +10,7 @@ import { } from "./shared" import { ConfigPlugin } from "@/config/plugin" import { InstallationVersion } from "@/installation/version" +import { isIndexingPlugin } from "@kilocode/kilo-indexing/detect" // kilocode_change export namespace PluginLoader { // A normalized plugin declaration derived from config before any filesystem or npm work happens. @@ -141,7 +142,7 @@ export namespace PluginLoader { // Deprecated plugin packages are silently ignored because they are now built in. if (plan.deprecated) return - + if (isIndexingPlugin(candidate.plan.spec)) return // kilocode_change report?.start?.(candidate, retry) const resolved = await resolve(plan, kind) diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index fe19073ffee..874f39e9a75 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -10,7 +10,7 @@ import { Command } from "../command" import { Instance } from "./instance" import { Log } from "@/util" import { FileWatcher } from "@/file/watcher" -import { KiloSessions } from "@/kilo-sessions/kilo-sessions" // kilocode_change +import { KilocodeBootstrap } from "@/kilocode/bootstrap" // kilocode_change import * as Effect from "effect/Effect" import { Config } from "@/config" @@ -21,7 +21,7 @@ export const InstanceBootstrap = Effect.gen(function* () { // Plugin can mutate config so it has to be initialized before anything else. yield* Plugin.Service.use((svc) => svc.init()) // kilocode_change start - bootstrap Kilo session ingest/remote subscriptions instead of ShareNext - yield* Effect.promise(() => KiloSessions.init()).pipe(Effect.forkDetach) + yield* Effect.promise(() => KilocodeBootstrap.init()).pipe(Effect.forkDetach) // kilocode_change end yield* Effect.all( [ diff --git a/packages/opencode/src/project/project.sql.ts b/packages/opencode/src/project/project.sql.ts index efbc400b5ee..2d486114a36 100644 --- a/packages/opencode/src/project/project.sql.ts +++ b/packages/opencode/src/project/project.sql.ts @@ -8,6 +8,7 @@ export const ProjectTable = sqliteTable("project", { vcs: text(), name: text(), icon_url: text(), + icon_url_override: text(), icon_color: text(), ...Timestamps, time_initialized: integer(), diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 3ebf7919254..23e8a32f8ff 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -61,7 +61,13 @@ type Row = typeof ProjectTable.$inferSelect export function fromRow(row: Row): Info { const icon = - row.icon_url || row.icon_color ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } : undefined + row.icon_url || row.icon_url_override || row.icon_color + ? { + url: row.icon_url ?? undefined, + override: row.icon_url_override ?? undefined, + color: row.icon_color ?? undefined, + } + : undefined return { id: row.id, worktree: row.worktree, @@ -160,8 +166,8 @@ export const layer: Layer.Layer< const scope = yield* Scope.Scope const readCachedProjectId = Effect.fnUntraced(function* (dir: string) { - // kilocode change start - return yield* fs.readFileString(pathSvc.join(dir, "kilo")).pipe( + // kilocode change start + return yield* fs.readFileString(pathSvc.join(dir, "kilo")).pipe( // kilocode change end Effect.map((x) => x.trim()), Effect.map(ProjectID.make), @@ -210,13 +216,13 @@ export const layer: Layer.Layer< vcs: fakeVcs, } } - const worktree = (() => { - const common = resolveGitPath(sandbox, commonDir.text.trim()) - return common === sandbox ? sandbox : pathSvc.dirname(common) - })() + const common = resolveGitPath(sandbox, commonDir.text.trim()) + const bareCheck = yield* git(["config", "--bool", "core.bare"], { cwd: sandbox }) + const isBareRepo = bareCheck.code === 0 && bareCheck.text.trim() === "true" + const worktree = common === sandbox ? sandbox : isBareRepo ? common : pathSvc.dirname(common) if (id == null) { - id = yield* readCachedProjectId(pathSvc.join(worktree, ".git")) + id = yield* readCachedProjectId(common) } if (!id) { @@ -229,9 +235,7 @@ export const layer: Layer.Layer< id = roots[0] ? ProjectID.make(roots[0]) : undefined if (id) { - // kilocode_change start - yield* fs.writeFileString(pathSvc.join(worktree, ".git", "kilo"), id).pipe(Effect.ignore) - // kilocode_change end + yield* fs.writeFileString(pathSvc.join(common, "kilo"), id).pipe(Effect.ignore) // kilocode_change } } @@ -294,6 +298,7 @@ export const layer: Layer.Layer< vcs: result.vcs ?? null, name: result.name, icon_url: result.icon?.url, + icon_url_override: result.icon?.override, icon_color: result.icon?.color, time_created: result.time.created, time_updated: result.time.updated, @@ -308,6 +313,7 @@ export const layer: Layer.Layer< vcs: result.vcs ?? null, name: result.name, icon_url: result.icon?.url, + icon_url_override: result.icon?.override, icon_color: result.icon?.color, time_updated: result.time.updated, time_initialized: result.time.initialized, @@ -370,6 +376,7 @@ export const layer: Layer.Layer< .set({ name: input.name, icon_url: input.icon?.url, + icon_url_override: input.icon?.override, icon_color: input.icon?.color, commands: input.commands, time_updated: Date.now(), @@ -496,4 +503,4 @@ export function setInitialized(id: ProjectID) { const { runPromise } = makeRuntime(Service, defaultLayer) export const fromDirectory = (directory: string) => runPromise((svc) => svc.fromDirectory(directory)) export const sandboxes = (id: ProjectID) => runPromise((svc) => svc.sandboxes(id)) -// kilocode_change end \ No newline at end of file +// kilocode_change end diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts index d10c82e2c3d..7708b8de1ee 100644 --- a/packages/opencode/src/project/schema.ts +++ b/packages/opencode/src/project/schema.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import z from "zod" +import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID")) @@ -10,6 +10,6 @@ export type ProjectID = typeof projectIdSchema.Type export const ProjectID = projectIdSchema.pipe( withStatics((schema: typeof projectIdSchema) => ({ global: schema.make("global"), - zod: z.string().pipe(z.custom()), + zod: zod(schema), })), ) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 060c55055df..857c02416ea 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -20,6 +20,7 @@ import { zod } from "@/util/effect-zod" import { iife } from "@/util/iife" import { Global } from "../global" import path from "path" +import { pathToFileURL } from "url" import { Effect, Layer, Context, Schema, Types } from "effect" import { EffectBridge } from "@/effect" import { InstanceState } from "@/effect" @@ -425,6 +426,16 @@ function custom(dep: CustomDep): Record { }, }, }), + nvidia: () => + Effect.succeed({ + autoload: false, + options: { + headers: { + "HTTP-Referer": "https://opencode.ai/", + "X-Title": "opencode", + }, + }, + }), vercel: () => Effect.succeed({ autoload: false, @@ -1217,12 +1228,22 @@ const layer: Layer.Layer< database[providerID] = parsed } + // kilocode_change start - load auths before env so OAuth plugins can override inherited credentials + const auths = yield* auth.all().pipe(Effect.orDie) // load env const envs = yield* env.all() for (const [id, provider] of Object.entries(database)) { const providerID = ProviderID.make(id) if (disabled.has(providerID)) continue + // kilocode_change start - prefer explicit OAuth auth over inherited env credentials + if ( + auths[providerID]?.type === "oauth" && + plugins.some((x) => x.auth?.provider === providerID && x.auth.loader) + ) { + continue + } const apiKey = provider.env.map((item) => envs[item]).find(Boolean) + // kilocode_change end if (!apiKey) continue mergeProvider(providerID, { source: "env", @@ -1231,7 +1252,6 @@ const layer: Layer.Layer< } // load apikeys - const auths = yield* auth.all().pipe(Effect.orDie) for (const [id, provider] of Object.entries(auths)) { const providerID = ProviderID.make(id) if (disabled.has(providerID)) continue @@ -1291,8 +1311,13 @@ const layer: Layer.Layer< // load config - re-apply with updated data for (const [id, provider] of configProviders) { const providerID = ProviderID.make(id) - const partial: Partial = { source: "config" } + // kilocode_change start - keep OAuth plugin source when config and Codex auth coexist + const oauth = + auths[providerID]?.type === "oauth" && + plugins.some((x) => x.auth?.provider === providerID && x.auth.loader) + const partial: Partial = oauth ? {} : { source: "config" } if (provider.env) partial.env = provider.env + // kilocode_change end if (provider.name) partial.name = provider.name if (provider.options) partial.options = provider.options mergeProvider(providerID, partial) @@ -1529,7 +1554,10 @@ const layer: Layer.Layer< installedPath = model.api.npm } - const mod = await import(installedPath) + // `installedPath` is a local entry path or an existing `file://` URL. Normalize + // only path inputs so Node on Windows accepts the dynamic import. + const importSpec = installedPath.startsWith("file://") ? installedPath : pathToFileURL(installedPath).href + const mod = await import(importSpec) const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!] const loaded = fn({ diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index cb664820a12..aa75b0c2198 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -1,6 +1,6 @@ import { Schema } from "effect" -import z from "zod" +import { zod } from "@/util/effect-zod" import { withStatics } from "@/util/schema" const providerIdSchema = Schema.String.pipe(Schema.brand("ProviderID")) @@ -9,7 +9,7 @@ export type ProviderID = typeof providerIdSchema.Type export const ProviderID = providerIdSchema.pipe( withStatics((schema: typeof providerIdSchema) => ({ - zod: z.string().pipe(z.custom()), + zod: zod(schema), // Well-known providers kilo: schema.make("kilo"), // kilocode_change opencode: schema.make("opencode"), @@ -31,7 +31,7 @@ const modelIdSchema = Schema.String.pipe(Schema.brand("ModelID")) export type ModelID = typeof modelIdSchema.Type export const ModelID = modelIdSchema.pipe( - withStatics((_schema: typeof modelIdSchema) => ({ - zod: z.string().pipe(z.custom()), + withStatics((schema: typeof modelIdSchema) => ({ + zod: zod(schema), })), ) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 51725600af3..8f2c7184d95 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -460,10 +460,9 @@ export function variants(model: Provider.Model): Record ({ ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)), - zod: Identifier.schema("pty").pipe(z.custom()), + zod: zod(schema), })), ) diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index 41186161d00..f7a0e096a3c 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -1,8 +1,7 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" -import { ZodOverride } from "@/util/effect-zod" +import { zod, ZodOverride } from "@/util/effect-zod" import { Newtype } from "@/util/schema" export class QuestionID extends Newtype()( @@ -13,5 +12,5 @@ export class QuestionID extends Newtype()( return this.make(Identifier.ascending("question", id)) } - static readonly zod = Identifier.schema("question") as unknown as z.ZodType + static readonly zod = zod(this) } diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts index 2d7268b7174..d10f2cd522f 100644 --- a/packages/opencode/src/server/routes/global.ts +++ b/packages/opencode/src/server/routes/global.ts @@ -160,7 +160,7 @@ export const GlobalRoutes = lazy(() => description: "Get global config info", content: { "application/json": { - schema: resolver(Config.Info), + schema: resolver(Config.Info.zod), }, }, }, @@ -181,14 +181,14 @@ export const GlobalRoutes = lazy(() => description: "Successfully updated global config", content: { "application/json": { - schema: resolver(Config.Info), + schema: resolver(Config.Info.zod), }, }, }, ...errors(400), }, }), - validator("json", Config.Info), + validator("json", Config.Info.zod), async (c) => { const config = c.req.valid("json") const next = await AppRuntime.runPromise(Config.Service.use((cfg) => cfg.updateGlobal(config))) diff --git a/packages/opencode/src/server/routes/instance/config.ts b/packages/opencode/src/server/routes/instance/config.ts index e75c1d44cbb..3bc3066a278 100644 --- a/packages/opencode/src/server/routes/instance/config.ts +++ b/packages/opencode/src/server/routes/instance/config.ts @@ -26,7 +26,7 @@ export const ConfigRoutes = lazy(() => description: "Get config info", content: { "application/json": { - schema: resolver(Config.Info), + schema: resolver(Config.Info.zod), }, }, }, @@ -49,14 +49,14 @@ export const ConfigRoutes = lazy(() => description: "Successfully updated config", content: { "application/json": { - schema: resolver(Config.Info), + schema: resolver(Config.Info.zod), }, }, }, ...errors(400), }, }), - validator("json", Config.Info), + validator("json", Config.Info.zod), async (c) => jsonRequest("ConfigRoutes.update", c, function* () { const config = c.req.valid("json") diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts index 47502956524..8c8c1a507b9 100644 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ b/packages/opencode/src/server/routes/instance/experimental.ts @@ -342,7 +342,7 @@ export const ExperimentalRoutes = lazy(() => description: "File diffs", content: { "application/json": { - schema: resolver(z.array(Snapshot.FileDiff)), + schema: resolver(z.array(Snapshot.FileDiff.zod)), }, }, }, @@ -387,7 +387,7 @@ export const ExperimentalRoutes = lazy(() => description: "Diff summary items", content: { "application/json": { - schema: resolver(z.array(WorktreeDiff.Item)), + schema: resolver(z.array(WorktreeDiff.Item.zod)), }, }, }, @@ -420,7 +420,7 @@ export const ExperimentalRoutes = lazy(() => description: "Diff detail item", content: { "application/json": { - schema: resolver(WorktreeDiff.Item.nullable()), + schema: resolver(WorktreeDiff.Item.zod.nullable()), }, }, }, diff --git a/packages/opencode/src/server/routes/instance/file.ts b/packages/opencode/src/server/routes/instance/file.ts index bbef679a855..f92fe6e7e5f 100644 --- a/packages/opencode/src/server/routes/instance/file.ts +++ b/packages/opencode/src/server/routes/instance/file.ts @@ -90,7 +90,7 @@ export const FileRoutes = lazy(() => description: "Symbols", content: { "application/json": { - schema: resolver(LSP.Symbol.array()), + schema: resolver(LSP.Symbol.zod.array()), }, }, }, diff --git a/packages/opencode/src/server/routes/instance/httpapi/config.ts b/packages/opencode/src/server/routes/instance/httpapi/config.ts index 14aa94f9fcf..2dfdec172a5 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/config.ts @@ -9,6 +9,15 @@ export const ConfigApi = HttpApi.make("config") .add( HttpApiGroup.make("config") .add( + HttpApiEndpoint.get("get", root, { + success: Config.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "config.get", + summary: "Get configuration", + description: "Retrieve the current OpenCode configuration settings and preferences.", + }), + ), HttpApiEndpoint.get("providers", `${root}/providers`, { success: Provider.ConfigProvidersResult, }).annotateMerge( @@ -36,16 +45,23 @@ export const ConfigApi = HttpApi.make("config") export const configHandlers = Layer.unwrap( Effect.gen(function* () { - const svc = yield* Provider.Service + const providerSvc = yield* Provider.Service + const configSvc = yield* Config.Service + + const get = Effect.fn("ConfigHttpApi.get")(function* () { + return yield* configSvc.get() + }) const providers = Effect.fn("ConfigHttpApi.providers")(function* () { - const providers = yield* svc.list() + const providers = yield* providerSvc.list() return { providers: Object.values(providers), default: Provider.defaultModelIDs(providers), } }) - return HttpApiBuilder.group(ConfigApi, "config", (handlers) => handlers.handle("providers", providers)) + return HttpApiBuilder.group(ConfigApi, "config", (handlers) => + handlers.handle("get", get).handle("providers", providers), + ) }), ).pipe(Layer.provide(Provider.defaultLayer), Layer.provide(Config.defaultLayer)) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 49a1fa5619b..8c4380d0668 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -41,6 +41,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context)) app.get("/permission", (c) => handler(c.req.raw, context)) app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context)) + app.get("/config", (c) => handler(c.req.raw, context)) app.get("/config/providers", (c) => handler(c.req.raw, context)) app.get("/provider", (c) => handler(c.req.raw, context)) app.get("/provider/auth", (c) => handler(c.req.raw, context)) @@ -261,7 +262,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { description: "LSP server status", content: { "application/json": { - schema: resolver(LSP.Status.array()), + schema: resolver(LSP.Status.zod.array()), }, }, }, diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index 8da42c310e5..7404a5f4b08 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -471,7 +471,7 @@ export const SessionRoutes = lazy(() => description: "Successfully retrieved diff", content: { "application/json": { - schema: resolver(Snapshot.FileDiff.array()), + schema: resolver(Snapshot.FileDiff.zod.array()), }, }, }, @@ -611,7 +611,7 @@ export const SessionRoutes = lazy(() => description: "List of messages", content: { "application/json": { - schema: resolver(MessageV2.WithParts.array()), + schema: resolver(MessageV2.WithParts.zod.array()), }, }, }, @@ -701,8 +701,8 @@ export const SessionRoutes = lazy(() => "application/json": { schema: resolver( z.object({ - info: MessageV2.Info, - parts: MessageV2.Part.array(), + info: MessageV2.Info.zod, + parts: MessageV2.Part.zod.array(), }), ), }, @@ -813,7 +813,7 @@ export const SessionRoutes = lazy(() => description: "Successfully updated part", content: { "application/json": { - schema: resolver(MessageV2.Part), + schema: resolver(MessageV2.Part.zod), }, }, }, @@ -828,7 +828,7 @@ export const SessionRoutes = lazy(() => partID: PartID.zod, }), ), - validator("json", MessageV2.Part), + validator("json", MessageV2.Part.zod), async (c) => { const params = c.req.valid("param") const body = c.req.valid("json") @@ -856,8 +856,8 @@ export const SessionRoutes = lazy(() => "application/json": { schema: resolver( z.object({ - info: MessageV2.Assistant, - parts: MessageV2.Part.array(), + info: MessageV2.Assistant.zod, + parts: MessageV2.Part.zod.array(), }), ), }, @@ -882,7 +882,9 @@ export const SessionRoutes = lazy(() => const msg = await runRequest( "SessionRoutes.prompt", c, - SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })), + SessionPrompt.Service.use((svc) => + svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput), + ), ) void stream.write(JSON.stringify(msg)) }) @@ -915,7 +917,9 @@ export const SessionRoutes = lazy(() => void runRequest( "SessionRoutes.prompt_async", c, - SessionPrompt.Service.use((svc) => svc.prompt({ ...body, sessionID })), + SessionPrompt.Service.use((svc) => + svc.prompt({ ...body, sessionID } as unknown as SessionPrompt.PromptInput), + ), ).catch((err) => { log.error("prompt_async failed", { sessionID, error: err }) void Bus.publish(Session.Event.Error, { @@ -940,8 +944,8 @@ export const SessionRoutes = lazy(() => "application/json": { schema: resolver( z.object({ - info: MessageV2.Assistant, - parts: MessageV2.Part.array(), + info: MessageV2.Assistant.zod, + parts: MessageV2.Part.zod.array(), }), ), }, @@ -976,7 +980,7 @@ export const SessionRoutes = lazy(() => description: "Created message", content: { "application/json": { - schema: resolver(MessageV2.WithParts), + schema: resolver(MessageV2.WithParts.zod), }, }, }, diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 212f5fdbab8..defdb870d7d 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -15,7 +15,9 @@ import { NotFoundError } from "@/storage" import { ModelID, ProviderID } from "@/provider/schema" import { Effect, Layer, Context } from "effect" import { InstanceState } from "@/effect" -import { isOverflow as overflow } from "./overflow" +import { isOverflow as overflow, usable } from "./overflow" +import { makeRuntime } from "@/effect/run-service" +import { fn } from "@/util/fn" const log = Log.create({ service: "session.compaction" }) @@ -30,7 +32,154 @@ export const Event = { export const PRUNE_MINIMUM = 20_000 export const PRUNE_PROTECT = 40_000 +const TOOL_OUTPUT_MAX_CHARS = 2_000 const PRUNE_PROTECTED_TOOLS = ["skill"] +const DEFAULT_TAIL_TURNS = 2 +const MIN_PRESERVE_RECENT_TOKENS = 2_000 +const MAX_PRESERVE_RECENT_TOKENS = 8_000 +const SUMMARY_TEMPLATE = `Output exactly this Markdown structure and keep the section order unchanged: +--- +## Goal +- [single-sentence task summary] + +## Constraints & Preferences +- [user constraints, preferences, specs, or "(none)"] + +## Progress +### Done +- [completed work or "(none)"] + +### In Progress +- [current work or "(none)"] + +### Blocked +- [blockers or "(none)"] + +## Key Decisions +- [decision and why, or "(none)"] + +## Next Steps +- [ordered next actions or "(none)"] + +## Critical Context +- [important technical facts, errors, open questions, or "(none)"] + +## Relevant Files +- [file or directory path: why it matters, or "(none)"] +--- + +Rules: +- Keep every section, even when empty. +- Use terse bullets, not prose paragraphs. +- Preserve exact file paths, commands, error strings, and identifiers when known. +- Do not mention the summary process or that context was compacted.` +type Turn = { + start: number + end: number + id: MessageID +} + +type Tail = { + start: number + id: MessageID +} + +type CompletedCompaction = { + userIndex: number + assistantIndex: number + summary: string | undefined +} + +function summaryText(message: MessageV2.WithParts) { + const text = message.parts + .filter((part): part is MessageV2.TextPart => part.type === "text") + .map((part) => part.text.trim()) + .filter(Boolean) + .join("\n\n") + .trim() + return text || undefined +} + +function completedCompactions(messages: MessageV2.WithParts[]) { + const users = new Map() + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + if (msg.info.role !== "user") continue + if (!msg.parts.some((part) => part.type === "compaction")) continue + users.set(msg.info.id, i) + } + + return messages.flatMap((msg, assistantIndex): CompletedCompaction[] => { + if (msg.info.role !== "assistant") return [] + if (!msg.info.summary || !msg.info.finish || msg.info.error) return [] + const userIndex = users.get(msg.info.parentID) + if (userIndex === undefined) return [] + return [{ userIndex, assistantIndex, summary: summaryText(msg) }] + }) +} + +function buildPrompt(input: { previousSummary?: string; context: string[] }) { + const anchor = input.previousSummary + ? [ + "Update the anchored summary below using the conversation history above.", + "Preserve still-true details, remove stale details, and merge in the new facts.", + "", + input.previousSummary, + "", + ].join("\n") + : "Create a new anchored summary from the conversation history above." + return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n") +} + +function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) { + return ( + input.cfg.compaction?.preserve_recent_tokens ?? + Math.min(MAX_PRESERVE_RECENT_TOKENS, Math.max(MIN_PRESERVE_RECENT_TOKENS, Math.floor(usable(input) * 0.25))) + ) +} + +function turns(messages: MessageV2.WithParts[]) { + const result: Turn[] = [] + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + if (msg.info.role !== "user") continue + if (msg.parts.some((part) => part.type === "compaction")) continue + result.push({ + start: i, + end: messages.length, + id: msg.info.id, + }) + } + for (let i = 0; i < result.length - 1; i++) { + result[i].end = result[i + 1].start + } + return result +} + +function splitTurn(input: { + messages: MessageV2.WithParts[] + turn: Turn + model: Provider.Model + budget: number + estimate: (input: { messages: MessageV2.WithParts[]; model: Provider.Model }) => Effect.Effect +}) { + return Effect.gen(function* () { + if (input.budget <= 0) return undefined + if (input.turn.end - input.turn.start <= 1) return undefined + for (let start = input.turn.start + 1; start < input.turn.end; start++) { + const size = yield* input.estimate({ + messages: input.messages.slice(start, input.turn.end), + model: input.model, + }) + if (size > input.budget) continue + return { + start, + id: input.messages[start]!.info.id, + } satisfies Tail + } + return undefined + }) +} export interface Interface { readonly isOverflow: (input: { @@ -84,11 +233,70 @@ export const layer: Layer.Layer< return overflow({ cfg: yield* config.get(), tokens: input.tokens, model: input.model }) }) + const estimate = Effect.fn("SessionCompaction.estimate")(function* (input: { + messages: MessageV2.WithParts[] + model: Provider.Model + }) { + const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model) + return Token.estimate(JSON.stringify(msgs)) + }) + + const select = Effect.fn("SessionCompaction.select")(function* (input: { + messages: MessageV2.WithParts[] + cfg: Config.Info + model: Provider.Model + }) { + const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS + if (limit <= 0) return { head: input.messages, tail_start_id: undefined } + const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model }) + const all = turns(input.messages) + if (!all.length) return { head: input.messages, tail_start_id: undefined } + const recent = all.slice(-limit) + const sizes = yield* Effect.forEach( + recent, + (turn) => + estimate({ + messages: input.messages.slice(turn.start, turn.end), + model: input.model, + }), + { concurrency: 1 }, + ) + + let total = 0 + let keep: Tail | undefined + for (let i = recent.length - 1; i >= 0; i--) { + const turn = recent[i]! + const size = sizes[i] + if (total + size <= budget) { + total += size + keep = { start: turn.start, id: turn.id } + continue + } + const remaining = budget - total + const split = yield* splitTurn({ + messages: input.messages, + turn, + model: input.model, + budget: remaining, + estimate, + }) + if (split) keep = split + else if (!keep) log.info("tail fallback", { budget, size, total }) + break + } + + if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined } + return { + head: input.messages.slice(0, keep.start), + tail_start_id: keep.id, + } + }) + // goes backwards through parts until there are PRUNE_PROTECT tokens worth of tool // calls, then erases output of older tool calls to free context space const prune = Effect.fn("SessionCompaction.prune")(function* (input: { sessionID: SessionID }) { const cfg = yield* config.get() - if (cfg.compaction?.prune === false) return + if (!cfg.compaction?.prune) return log.info("pruning") const msgs = yield* session @@ -108,17 +316,15 @@ export const layer: Layer.Layer< if (msg.info.role === "assistant" && msg.info.summary) break loop for (let partIndex = msg.parts.length - 1; partIndex >= 0; partIndex--) { const part = msg.parts[partIndex] - if (part.type === "tool") - if (part.state.status === "completed") { - if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue - if (part.state.time.compacted) break loop - const estimate = Token.estimate(part.state.output) - total += estimate - if (total > PRUNE_PROTECT) { - pruned += estimate - toPrune.push(part) - } - } + if (part.type !== "tool") continue + if (part.state.status !== "completed") continue + if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue + if (part.state.time.compacted) break loop + const estimate = Token.estimate(part.state.output) + total += estimate + if (total <= PRUNE_PROTECT) continue + pruned += estimate + toPrune.push(part) } } @@ -146,6 +352,7 @@ export const layer: Layer.Layer< throw new Error(`Compaction parent must be a user message: ${input.parentID}`) } const userMessage = parent.info + const compactionPart = parent.parts.find((part): part is MessageV2.CompactionPart => part.type === "compaction") let messages = input.messages let replay: @@ -176,46 +383,29 @@ export const layer: Layer.Layer< const model = agent.model ? yield* provider.getModel(agent.model.providerID, agent.model.modelID) : yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID) + const cfg = yield* config.get() + const history = compactionPart && messages.at(-1)?.info.id === input.parentID ? messages.slice(0, -1) : messages + const prior = completedCompactions(history) + const hidden = new Set(prior.flatMap((item) => [item.userIndex, item.assistantIndex])) + const previousSummary = prior.at(-1)?.summary + const selected = yield* select({ + messages: history.filter((_, index) => !hidden.has(index)), + cfg, + model, + }) // Allow plugins to inject context or replace compaction prompt. const compacting = yield* plugin.trigger( "experimental.session.compacting", { sessionID: input.sessionID }, { context: [], prompt: undefined }, ) - const defaultPrompt = `Provide a detailed prompt for continuing our conversation above. -Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next. -The summary that you construct will be used so that another agent can read it and continue the work. -Do not call any tools. Respond only with the summary text. -Respond in the same language as the user's messages in the conversation. - -When constructing the summary, try to stick to this template: ---- -## Goal - -[What goal(s) is the user trying to accomplish?] - -## Instructions - -- [What important instructions did the user give you that are relevant] -- [If there is a plan or spec, include information about it so next agent can continue using it] - -## Discoveries - -[What notable things were learned during this conversation that would be useful for the next agent to know when continuing the work] - -## Accomplished - -[What work has been completed, what work is still in progress, and what work is left?] - -## Relevant files / directories - -[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.] ----` - - const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n") - const msgs = structuredClone(messages) + const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) + const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true }) + const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { + stripMedia: true, + toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, + }) const ctx = yield* InstanceState.context const msg: MessageV2.Assistant = { id: MessageID.ascending(), @@ -259,7 +449,7 @@ When constructing the summary, try to stick to this template: ...modelMessages, { role: "user", - content: [{ type: "text", text: prompt }], + content: [{ type: "text", text: nextPrompt }], }, ], model, @@ -276,6 +466,13 @@ When constructing the summary, try to stick to this template: return "stop" } + if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) { + yield* session.updatePart({ + ...compactionPart, + tail_start_id: selected.tail_start_id, + }) + } + if (result === "continue" && input.auto) { if (replay) { const original = replay.info @@ -409,4 +606,25 @@ export const defaultLayer = Layer.suspend(() => ), ) +const { runPromise } = makeRuntime(Service, defaultLayer) + +export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { + return runPromise((svc) => svc.isOverflow(input)) +} + +export async function prune(input: { sessionID: SessionID }) { + return runPromise((svc) => svc.prune(input)) +} + +export const create = fn( + z.object({ + sessionID: SessionID.zod, + agent: z.string(), + model: z.object({ providerID: ProviderID.zod, modelID: ModelID.zod }), + auto: z.boolean(), + overflow: z.boolean().optional(), + }), + (input) => runPromise((svc) => svc.create(input)), +) + export * as SessionCompaction from "./compaction" diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index ff12bbda585..0687f166cc8 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -15,8 +15,11 @@ import { isMedia } from "@/util/media" import type { SystemError } from "bun" import type { Provider } from "@/provider" import { ModelID, ProviderID } from "@/provider/schema" -import { Effect } from "effect" import { SessionNetwork } from "./network" // kilocode_change +import { Effect, Schema, Types } from "effect" +import { zod, ZodOverride } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" +import { namedSchemaError } from "@/util/named-schema-error" import { EffectLogger } from "@/effect" /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ @@ -29,441 +32,560 @@ interface FetchDecompressionError extends Error { export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:" export { isMedia } -export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({})) -export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() })) -export const StructuredOutputError = NamedError.create( - "StructuredOutputError", - z.object({ - message: z.string(), - retries: z.number(), - }), -) -export const AuthError = NamedError.create( - "ProviderAuthError", - z.object({ - providerID: z.string(), - message: z.string(), - }), -) -export const APIError = NamedError.create( - "APIError", - z.object({ - message: z.string(), - statusCode: z.number().optional(), - isRetryable: z.boolean(), - responseHeaders: z.record(z.string(), z.string()).optional(), - responseBody: z.string().optional(), - metadata: z.record(z.string(), z.string()).optional(), - }), -) +export const OutputLengthError = namedSchemaError("MessageOutputLengthError", {}) +export const AbortedError = namedSchemaError("MessageAbortedError", { message: Schema.String }) +export const StructuredOutputError = namedSchemaError("StructuredOutputError", { + message: Schema.String, + retries: Schema.Number, +}) +export const AuthError = namedSchemaError("ProviderAuthError", { + providerID: Schema.String, + message: Schema.String, +}) +export const APIError = namedSchemaError("APIError", { + message: Schema.String, + statusCode: Schema.optional(Schema.Number), + isRetryable: Schema.Boolean, + responseHeaders: Schema.optional(Schema.Record(Schema.String, Schema.String)), + responseBody: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}) export type APIError = z.infer -export const ContextOverflowError = NamedError.create( - "ContextOverflowError", - z.object({ message: z.string(), responseBody: z.string().optional() }), -) - -export const OutputFormatText = z - .object({ - type: z.literal("text"), - }) - .meta({ - ref: "OutputFormatText", - }) - -export const OutputFormatJsonSchema = z - .object({ - type: z.literal("json_schema"), - schema: z.record(z.string(), z.any()).meta({ ref: "JSONSchema" }), - retryCount: z.number().int().min(0).default(2), - }) - .meta({ - ref: "OutputFormatJsonSchema", - }) - -export const Format = z.discriminatedUnion("type", [OutputFormatText, OutputFormatJsonSchema]).meta({ - ref: "OutputFormat", -}) -export type OutputFormat = z.infer - -const PartBase = z.object({ - id: PartID.zod, - sessionID: SessionID.zod, - messageID: MessageID.zod, +export const ContextOverflowError = namedSchemaError("ContextOverflowError", { + message: Schema.String, + responseBody: Schema.optional(Schema.String), }) -export const SnapshotPart = PartBase.extend({ - type: z.literal("snapshot"), - snapshot: z.string(), -}).meta({ - ref: "SnapshotPart", -}) -export type SnapshotPart = z.infer +export class OutputFormatText extends Schema.Class("OutputFormatText")({ + type: Schema.Literal("text"), +}) { + static readonly zod = zod(this) +} -export const PatchPart = PartBase.extend({ - type: z.literal("patch"), - hash: z.string(), - files: z.string().array(), -}).meta({ - ref: "PatchPart", -}) -export type PatchPart = z.infer +export class OutputFormatJsonSchema extends Schema.Class("OutputFormatJsonSchema")({ + type: Schema.Literal("json_schema"), + schema: Schema.Record(Schema.String, Schema.Any).annotate({ identifier: "JSONSchema" }), + retryCount: Schema.Number.check(Schema.isInt()) + .check(Schema.isGreaterThanOrEqualTo(0)) + .pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(2))), +}) { + static readonly zod = zod(this) +} -export const TextPart = PartBase.extend({ - type: z.literal("text"), - text: z.string(), - synthetic: z.boolean().optional(), - ignored: z.boolean().optional(), - time: z - .object({ - start: z.number(), - end: z.number().optional(), - }) - .optional(), - metadata: z.record(z.string(), z.any()).optional(), -}).meta({ - ref: "TextPart", +const _Format = Schema.Union([OutputFormatText, OutputFormatJsonSchema]).annotate({ + discriminator: "type", + identifier: "OutputFormat", }) -export type TextPart = z.infer +export const Format = Object.assign(_Format, { zod: zod(_Format) }) +export type OutputFormat = Schema.Schema.Type -export const ReasoningPart = PartBase.extend({ - type: z.literal("reasoning"), - text: z.string(), - metadata: z.record(z.string(), z.any()).optional(), - time: z.object({ - start: z.number(), - end: z.number().optional(), - }), -}).meta({ - ref: "ReasoningPart", +const partBase = { + id: PartID, + sessionID: SessionID, + messageID: MessageID, +} + +export const SnapshotPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("snapshot"), + snapshot: Schema.String, }) -export type ReasoningPart = z.infer + .annotate({ identifier: "SnapshotPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type SnapshotPart = Types.DeepMutable> -const FilePartSourceBase = z.object({ - text: z - .object({ - value: z.string(), - start: z.number().int(), - end: z.number().int(), - }) - .meta({ - ref: "FilePartSourceText", +export const PatchPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("patch"), + hash: Schema.String, + files: Schema.Array(Schema.String), +}) + .annotate({ identifier: "PatchPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type PatchPart = Types.DeepMutable> + +export const TextPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("text"), + text: Schema.String, + synthetic: Schema.optional(Schema.Boolean), + ignored: Schema.optional(Schema.Boolean), + time: Schema.optional( + Schema.Struct({ + start: Schema.Number, + end: Schema.optional(Schema.Number), }), + ), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), }) + .annotate({ identifier: "TextPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type TextPart = Types.DeepMutable> -export const FileSource = FilePartSourceBase.extend({ - type: z.literal("file"), - path: z.string(), -}).meta({ - ref: "FileSource", +export const ReasoningPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("reasoning"), + text: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: Schema.Number, + end: Schema.optional(Schema.Number), + }), }) + .annotate({ identifier: "ReasoningPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ReasoningPart = Types.DeepMutable> -export const SymbolSource = FilePartSourceBase.extend({ - type: z.literal("symbol"), - path: z.string(), +const filePartSourceBase = { + text: Schema.Struct({ + value: Schema.String, + start: Schema.Number.check(Schema.isInt()), + end: Schema.Number.check(Schema.isInt()), + }).annotate({ identifier: "FilePartSourceText" }), +} + +export const FileSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("file"), + path: Schema.String, +}) + .annotate({ identifier: "FileSource" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + +export const SymbolSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("symbol"), + path: Schema.String, range: LSP.Range, - name: z.string(), - kind: z.number().int(), -}).meta({ - ref: "SymbolSource", + name: Schema.String, + kind: Schema.Number.check(Schema.isInt()), }) + .annotate({ identifier: "SymbolSource" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) -export const ResourceSource = FilePartSourceBase.extend({ - type: z.literal("resource"), - clientName: z.string(), - uri: z.string(), -}).meta({ - ref: "ResourceSource", +export const ResourceSource = Schema.Struct({ + ...filePartSourceBase, + type: Schema.Literal("resource"), + clientName: Schema.String, + uri: Schema.String, }) + .annotate({ identifier: "ResourceSource" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) -export const FilePartSource = z.discriminatedUnion("type", [FileSource, SymbolSource, ResourceSource]).meta({ - ref: "FilePartSource", +const _FilePartSource = Schema.Union([FileSource, SymbolSource, ResourceSource]).annotate({ + discriminator: "type", + identifier: "FilePartSource", }) +export const FilePartSource = Object.assign(_FilePartSource, { zod: zod(_FilePartSource) }) -export const FilePart = PartBase.extend({ - type: z.literal("file"), - mime: z.string(), - filename: z.string().optional(), - url: z.string(), - source: FilePartSource.optional(), -}).meta({ - ref: "FilePart", +export const FilePart = Schema.Struct({ + ...partBase, + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(_FilePartSource), }) -export type FilePart = z.infer + .annotate({ identifier: "FilePart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type FilePart = Types.DeepMutable> -export const AgentPart = PartBase.extend({ - type: z.literal("agent"), - name: z.string(), - source: z - .object({ - value: z.string(), - start: z.number().int(), - end: z.number().int(), - }) - .optional(), -}).meta({ - ref: "AgentPart", +export const AgentPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("agent"), + name: Schema.String, + source: Schema.optional( + Schema.Struct({ + value: Schema.String, + start: Schema.Number.check(Schema.isInt()), + end: Schema.Number.check(Schema.isInt()), + }), + ), }) -export type AgentPart = z.infer + .annotate({ identifier: "AgentPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type AgentPart = Types.DeepMutable> -export const CompactionPart = PartBase.extend({ - type: z.literal("compaction"), - auto: z.boolean(), - overflow: z.boolean().optional(), -}).meta({ - ref: "CompactionPart", +export const CompactionPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("compaction"), + auto: Schema.Boolean, + overflow: Schema.optional(Schema.Boolean), + tail_start_id: Schema.optional(MessageID), }) -export type CompactionPart = z.infer + .annotate({ identifier: "CompactionPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type CompactionPart = Types.DeepMutable> -export const SubtaskPart = PartBase.extend({ - type: z.literal("subtask"), - prompt: z.string(), - description: z.string(), - agent: z.string(), - model: z - .object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - }) - .optional(), - command: z.string().optional(), -}).meta({ - ref: "SubtaskPart", +export const SubtaskPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("subtask"), + prompt: Schema.String, + description: Schema.String, + agent: Schema.String, + model: Schema.optional( + Schema.Struct({ + providerID: ProviderID, + modelID: ModelID, + }), + ), + command: Schema.optional(Schema.String), }) -export type SubtaskPart = z.infer + .annotate({ identifier: "SubtaskPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type SubtaskPart = Types.DeepMutable> -export const RetryPart = PartBase.extend({ - type: z.literal("retry"), - attempt: z.number(), - error: APIError.Schema, - time: z.object({ - created: z.number(), +export const RetryPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("retry"), + attempt: Schema.Number, + // APIError is still NamedError-based Zod; bridge via ZodOverride until errors migrate. + error: Schema.Any.annotate({ [ZodOverride]: APIError.Schema }), + time: Schema.Struct({ + created: Schema.Number, }), -}).meta({ - ref: "RetryPart", }) -export type RetryPart = z.infer + .annotate({ identifier: "RetryPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type RetryPart = Omit>, "error"> & { + error: APIError +} -export const StepStartPart = PartBase.extend({ - type: z.literal("step-start"), - snapshot: z.string().optional(), -}).meta({ - ref: "StepStartPart", +export const StepStartPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("step-start"), + snapshot: Schema.optional(Schema.String), }) -export type StepStartPart = z.infer + .annotate({ identifier: "StepStartPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type StepStartPart = Types.DeepMutable> -export const StepFinishPart = PartBase.extend({ - type: z.literal("step-finish"), - reason: z.string(), - snapshot: z.string().optional(), - cost: z.number(), - tokens: z.object({ - total: z.number().optional(), - input: z.number(), - output: z.number(), - reasoning: z.number(), - cache: z.object({ - read: z.number(), - write: z.number(), +export const StepFinishPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("step-finish"), + reason: Schema.String, + snapshot: Schema.optional(Schema.String), + cost: Schema.Number, + tokens: Schema.Struct({ + total: Schema.optional(Schema.Number), + input: Schema.Number, + output: Schema.Number, + reasoning: Schema.Number, + cache: Schema.Struct({ + read: Schema.Number, + write: Schema.Number, }), }), -}).meta({ - ref: "StepFinishPart", }) -export type StepFinishPart = z.infer + .annotate({ identifier: "StepFinishPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type StepFinishPart = Types.DeepMutable> -export const ToolStatePending = z - .object({ - status: z.literal("pending"), - input: z.record(z.string(), z.any()), - raw: z.string(), - }) - .meta({ - ref: "ToolStatePending", - }) - -export type ToolStatePending = z.infer - -export const ToolStateRunning = z - .object({ - status: z.literal("running"), - input: z.record(z.string(), z.any()), - title: z.string().optional(), - metadata: z.record(z.string(), z.any()).optional(), - time: z.object({ - start: z.number(), - }), - }) - .meta({ - ref: "ToolStateRunning", - }) -export type ToolStateRunning = z.infer - -export const ToolStateCompleted = z - .object({ - status: z.literal("completed"), - input: z.record(z.string(), z.any()), - output: z.string(), - title: z.string(), - metadata: z.record(z.string(), z.any()), - time: z.object({ - start: z.number(), - end: z.number(), - compacted: z.number().optional(), - }), - attachments: FilePart.array().optional(), - }) - .meta({ - ref: "ToolStateCompleted", - }) -export type ToolStateCompleted = z.infer - -export const ToolStateError = z - .object({ - status: z.literal("error"), - input: z.record(z.string(), z.any()), - error: z.string(), - metadata: z.record(z.string(), z.any()).optional(), - time: z.object({ - start: z.number(), - end: z.number(), - }), - }) - .meta({ - ref: "ToolStateError", - }) -export type ToolStateError = z.infer - -export const ToolState = z - .discriminatedUnion("status", [ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]) - .meta({ - ref: "ToolState", - }) - -export const ToolPart = PartBase.extend({ - type: z.literal("tool"), - callID: z.string(), - tool: z.string(), - state: ToolState, - metadata: z.record(z.string(), z.any()).optional(), -}).meta({ - ref: "ToolPart", +export const ToolStatePending = Schema.Struct({ + status: Schema.Literal("pending"), + input: Schema.Record(Schema.String, Schema.Any), + raw: Schema.String, }) -export type ToolPart = z.infer + .annotate({ identifier: "ToolStatePending" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ToolStatePending = Types.DeepMutable> -const Base = z.object({ - id: MessageID.zod, - sessionID: SessionID.zod, -}) - -export const User = Base.extend({ - role: z.literal("user"), - time: z.object({ - created: z.number(), +export const ToolStateRunning = Schema.Struct({ + status: Schema.Literal("running"), + input: Schema.Record(Schema.String, Schema.Any), + title: Schema.optional(Schema.String), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: Schema.Number, }), - format: Format.optional(), - summary: z - .object({ - title: z.string().optional(), - body: z.string().optional(), - diffs: Snapshot.FileDiff.array(), - }) - .optional(), - agent: z.string(), - model: z.object({ - providerID: ProviderID.zod, - modelID: ModelID.zod, - variant: z.string().optional(), +}) + .annotate({ identifier: "ToolStateRunning" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ToolStateRunning = Types.DeepMutable> + +export const ToolStateCompleted = Schema.Struct({ + status: Schema.Literal("completed"), + input: Schema.Record(Schema.String, Schema.Any), + output: Schema.String, + title: Schema.String, + metadata: Schema.Record(Schema.String, Schema.Any), + time: Schema.Struct({ + start: Schema.Number, + end: Schema.Number, + compacted: Schema.optional(Schema.Number), }), - system: z.string().optional(), - tools: z.record(z.string(), z.boolean()).optional(), + attachments: Schema.optional(Schema.Array(FilePart)), +}) + .annotate({ identifier: "ToolStateCompleted" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ToolStateCompleted = Types.DeepMutable> + +function truncateToolOutput(text: string, maxChars?: number) { + if (!maxChars || text.length <= maxChars) return text + const omitted = text.length - maxChars + return `${text.slice(0, maxChars)}\n[Tool output truncated for compaction: omitted ${omitted} chars]` +} + +export const ToolStateError = Schema.Struct({ + status: Schema.Literal("error"), + input: Schema.Record(Schema.String, Schema.Any), + error: Schema.String, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), + time: Schema.Struct({ + start: Schema.Number, + end: Schema.Number, + }), +}) + .annotate({ identifier: "ToolStateError" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ToolStateError = Types.DeepMutable> + +const _ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).annotate({ + discriminator: "status", + identifier: "ToolState", +}) +// Cast the derived zod so downstream z.infer sees the same mutable shape that +// our exported TS types expose (the pre-migration Zod inferences were mutable). +export const ToolState = Object.assign(_ToolState, { + zod: zod(_ToolState) as unknown as z.ZodType< + ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError + >, +}) +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError + +export const ToolPart = Schema.Struct({ + ...partBase, + type: Schema.Literal("tool"), + callID: Schema.String, + tool: Schema.String, + state: _ToolState, + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}) + .annotate({ identifier: "ToolPart" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type ToolPart = Omit>, "state"> & { + state: ToolState +} + +const messageBase = { + id: MessageID, + sessionID: SessionID, +} + +export const User = Schema.Struct({ + ...messageBase, + role: Schema.Literal("user"), + time: Schema.Struct({ + created: Schema.Number, + }), + format: Schema.optional(_Format), + summary: Schema.optional( + Schema.Struct({ + title: Schema.optional(Schema.String), + body: Schema.optional(Schema.String), + diffs: Schema.Array(Snapshot.FileDiff), + }), + ), + agent: Schema.String, + model: Schema.Struct({ + providerID: ProviderID, + modelID: ModelID, + variant: Schema.optional(Schema.String), + }), + system: Schema.optional(Schema.String), + tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), // kilocode_change start - editorContext: z - .object({ - visibleFiles: z.array(z.string()).optional(), - openTabs: z.array(z.string()).optional(), - activeFile: z.string().optional(), - shell: z.string().optional(), - }) - .optional(), + editorContext: Schema.optional( + Schema.Struct({ + visibleFiles: Schema.optional(Schema.Array(Schema.String)), + openTabs: Schema.optional(Schema.Array(Schema.String)), + activeFile: Schema.optional(Schema.String), + shell: Schema.optional(Schema.String), + }), + ), // kilocode_change end -}).meta({ - ref: "UserMessage", }) -export type User = z.infer + .annotate({ identifier: "UserMessage" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type User = Types.DeepMutable> -export const Part = z - .discriminatedUnion("type", [ - TextPart, - SubtaskPart, - ReasoningPart, - FilePart, - ToolPart, - StepStartPart, - StepFinishPart, - SnapshotPart, - PatchPart, - AgentPart, - RetryPart, - CompactionPart, - ]) - .meta({ - ref: "Part", - }) -export type Part = z.infer +const _Part = Schema.Union([ + TextPart, + SubtaskPart, + ReasoningPart, + FilePart, + ToolPart, + StepStartPart, + StepFinishPart, + SnapshotPart, + PatchPart, + AgentPart, + RetryPart, + CompactionPart, +]).annotate({ discriminator: "type", identifier: "Part" }) +export const Part = Object.assign(_Part, { + zod: zod(_Part) as unknown as z.ZodType< + | TextPart + | SubtaskPart + | ReasoningPart + | FilePart + | ToolPart + | StepStartPart + | StepFinishPart + | SnapshotPart + | PatchPart + | AgentPart + | RetryPart + | CompactionPart + >, +}) +export type Part = + | TextPart + | SubtaskPart + | ReasoningPart + | FilePart + | ToolPart + | StepStartPart + | StepFinishPart + | SnapshotPart + | PatchPart + | AgentPart + | RetryPart + | CompactionPart -export const Assistant = Base.extend({ - role: z.literal("assistant"), - time: z.object({ - created: z.number(), - completed: z.number().optional(), +// Errors are still NamedError-based Zod; bridge via ZodOverride so the derived +// Zod + JSON Schema emit the original discriminatedUnion shape. Migrating the +// error classes to Schema.TaggedErrorClass is a separate slice. +const AssistantErrorZod = z.discriminatedUnion("name", [ + AuthError.Schema, + NamedError.Unknown.Schema, + OutputLengthError.Schema, + AbortedError.Schema, + StructuredOutputError.Schema, + ContextOverflowError.Schema, + APIError.Schema, +]) +type AssistantError = z.infer + +// ── Prompt input schemas ───────────────────────────────────────────────────── +// +// Consumers of `SessionPrompt.PromptInput.parts` send part drafts without the +// ambient IDs (`messageID`, `sessionID`) that live on stored parts, and may +// omit `id` to let the server allocate one. These Schema-Struct variants +// carry that shape, and `SessionPrompt.PromptInput` just references the +// derived `.zod` (no omit/partial gymnastics needed at the call site). + +export const TextPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("text"), + text: Schema.String, + synthetic: Schema.optional(Schema.Boolean), + ignored: Schema.optional(Schema.Boolean), + time: Schema.optional( + Schema.Struct({ + start: Schema.Number, + end: Schema.optional(Schema.Number), + }), + ), + metadata: Schema.optional(Schema.Record(Schema.String, Schema.Any)), +}) + .annotate({ identifier: "TextPartInput" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type TextPartInput = Types.DeepMutable> + +export const FilePartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("file"), + mime: Schema.String, + filename: Schema.optional(Schema.String), + url: Schema.String, + source: Schema.optional(_FilePartSource), +}) + .annotate({ identifier: "FilePartInput" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type FilePartInput = Types.DeepMutable> + +export const AgentPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("agent"), + name: Schema.String, + source: Schema.optional( + Schema.Struct({ + value: Schema.String, + start: Schema.Number.check(Schema.isInt()), + end: Schema.Number.check(Schema.isInt()), + }), + ), +}) + .annotate({ identifier: "AgentPartInput" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type AgentPartInput = Types.DeepMutable> + +export const SubtaskPartInput = Schema.Struct({ + id: Schema.optional(PartID), + type: Schema.Literal("subtask"), + prompt: Schema.String, + description: Schema.String, + agent: Schema.String, + model: Schema.optional( + Schema.Struct({ + providerID: ProviderID, + modelID: ModelID, + }), + ), + command: Schema.optional(Schema.String), +}) + .annotate({ identifier: "SubtaskPartInput" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type SubtaskPartInput = Types.DeepMutable> + +export const Assistant = Schema.Struct({ + ...messageBase, + role: Schema.Literal("assistant"), + time: Schema.Struct({ + created: Schema.Number, + completed: Schema.optional(Schema.Number), }), - error: z - .discriminatedUnion("name", [ - AuthError.Schema, - NamedError.Unknown.Schema, - OutputLengthError.Schema, - AbortedError.Schema, - StructuredOutputError.Schema, - ContextOverflowError.Schema, - APIError.Schema, - ]) - .optional(), - parentID: MessageID.zod, - modelID: ModelID.zod, - providerID: ProviderID.zod, + error: Schema.optional(Schema.Any.annotate({ [ZodOverride]: AssistantErrorZod })), + parentID: MessageID, + modelID: ModelID, + providerID: ProviderID, /** * @deprecated */ - mode: z.string(), - agent: z.string(), - path: z.object({ - cwd: z.string(), - root: z.string(), + mode: Schema.String, + agent: Schema.String, + path: Schema.Struct({ + cwd: Schema.String, + root: Schema.String, }), - summary: z.boolean().optional(), - cost: z.number(), - tokens: z.object({ - total: z.number().optional(), - input: z.number(), - output: z.number(), - reasoning: z.number(), - cache: z.object({ - read: z.number(), - write: z.number(), + summary: Schema.optional(Schema.Boolean), + cost: Schema.Number, + tokens: Schema.Struct({ + total: Schema.optional(Schema.Number), + input: Schema.Number, + output: Schema.Number, + reasoning: Schema.Number, + cache: Schema.Struct({ + read: Schema.Number, + write: Schema.Number, }), }), - structured: z.any().optional(), - variant: z.string().optional(), - finish: z.string().optional(), -}).meta({ - ref: "AssistantMessage", + structured: Schema.optional(Schema.Any), + variant: Schema.optional(Schema.String), + finish: Schema.optional(Schema.String), }) -export type Assistant = z.infer + .annotate({ identifier: "AssistantMessage" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Assistant = Omit>, "error"> & { + error?: AssistantError +} -export const Info = z.discriminatedUnion("role", [User, Assistant]).meta({ - ref: "Message", +const _Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "Message" }) +export const Info = Object.assign(_Info, { + zod: zod(_Info) as unknown as z.ZodType, }) -export type Info = z.infer +export type Info = User | Assistant export const Event = { Updated: SyncEvent.define({ @@ -472,7 +594,7 @@ export const Event = { aggregate: "sessionID", schema: z.object({ sessionID: SessionID.zod, - info: Info, + info: Info.zod, }), }), Removed: SyncEvent.define({ @@ -490,7 +612,7 @@ export const Event = { aggregate: "sessionID", schema: z.object({ sessionID: SessionID.zod, - part: Part, + part: Part.zod, time: z.number(), }), }), @@ -516,11 +638,31 @@ export const Event = { }), } -export const WithParts = z.object({ - info: Info, - parts: z.array(Part), +export const WithParts = Schema.Struct({ + info: _Info, + parts: Schema.Array(_Part), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type WithParts = { + info: Info + parts: Part[] +} + +const Cursor = Schema.Struct({ + id: MessageID, + time: Schema.Number, }) -export type WithParts = z.infer +type Cursor = typeof Cursor.Type + +const decodeCursor = Schema.decodeUnknownSync(Cursor) + +export const cursor = { + encode(input: Cursor) { + return Buffer.from(JSON.stringify(input)).toString("base64url") + }, + decode(input: string) { + return decodeCursor(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) + }, +} // kilocode_change start - strip bloated metadata fields from stored parts to prevent multi-MB payloads // This handles both legacy data that was stored with full file contents and keeps the API response lean. @@ -577,21 +719,6 @@ export function stripMessageMetadata(info: Info): Info { } // kilocode_change end -const Cursor = z.object({ - id: MessageID.zod, - time: z.number(), -}) -type Cursor = z.infer - -export const cursor = { - encode(input: Cursor) { - return Buffer.from(JSON.stringify(input)).toString("base64url") - }, - decode(input: string) { - return Cursor.parse(JSON.parse(Buffer.from(input, "base64url").toString("utf8"))) - }, -} - // kilocode_change - apply stripping inside helpers so all read paths are covered const info = (row: typeof MessageTable.$inferSelect) => stripMessageMetadata({ @@ -647,7 +774,7 @@ function providerMeta(metadata: Record | undefined) { export const toModelMessagesEffect = Effect.fnUntraced(function* ( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean }, + options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, ) { const result: UIMessage[] = [] const toolNames = new Set() @@ -786,7 +913,9 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( if (part.type === "tool") { toolNames.add(part.tool) if (part.state.status === "completed") { - const outputText = part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output + const outputText = part.state.time.compacted + ? "[Old tool result content cleared]" + : truncateToolOutput(part.state.output, options?.toolOutputMaxChars) const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? []) // For providers that don't support media in tool results, extract media files @@ -902,7 +1031,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* ( export function toModelMessages( input: WithParts[], model: Provider.Model, - options?: { stripMedia?: boolean }, + options?: { stripMedia?: boolean; toolOutputMaxChars?: number }, ): Promise { return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer))) } @@ -993,8 +1122,21 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With export function filterCompacted(msgs: Iterable) { const result = [] as WithParts[] const completed = new Set() + let retain: MessageID | undefined for (const msg of msgs) { result.push(msg) + if (retain) { + if (msg.info.id === retain) break + continue + } + if (msg.info.role === "user" && completed.has(msg.info.id)) { + const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction") + if (!part) continue + if (!part.tail_start_id) break + retain = part.tail_start_id + if (msg.info.id === retain) break + continue + } if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction")) break if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error) diff --git a/packages/opencode/src/session/overflow.ts b/packages/opencode/src/session/overflow.ts index 6f48a760df8..477b5815b2f 100644 --- a/packages/opencode/src/session/overflow.ts +++ b/packages/opencode/src/session/overflow.ts @@ -5,18 +5,22 @@ import type { MessageV2 } from "./message-v2" const COMPACTION_BUFFER = 20_000 -export function isOverflow(input: { cfg: Config.Info; tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { - if (input.cfg.compaction?.auto === false) return false +export function usable(input: { cfg: Config.Info; model: Provider.Model }) { const context = input.model.limit.context - if (context === 0) return false - - const count = - input.tokens.total || input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write + if (context === 0) return 0 const reserved = input.cfg.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model)) - const usable = input.model.limit.input - ? input.model.limit.input - reserved - : context - ProviderTransform.maxOutputTokens(input.model) - return count >= usable + return input.model.limit.input + ? Math.max(0, input.model.limit.input - reserved) + : Math.max(0, context - ProviderTransform.maxOutputTokens(input.model)) +} + +export function isOverflow(input: { cfg: Config.Info; tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { + if (input.cfg.compaction?.auto === false) return false + if (input.model.limit.context === 0) return false + + const count = + input.tokens.total || input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write + return count >= usable(input) } diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 79e10579686..67f3418e9b0 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -19,6 +19,7 @@ import type { Provider } from "@/provider" import { Question } from "@/question" import { KiloSessionProcessor } from "@/kilocode/session/processor" // kilocode_change import { Suggestion } from "@/kilocode/suggestion" // kilocode_change +import { NotFoundError } from "@/storage" // kilocode_change import { errorMessage } from "@/util/error" import { Log } from "@/util" import { isRecord } from "@/util/record" @@ -157,6 +158,22 @@ export const layer: Layer.Layer< return { call, part } }) + // kilocode_change start - tolerate deleted sessions during subagent cost reconciliation (#6321) + const reconcile = Effect.fn("SessionProcessor.reconcileCost")(function* () { + const fresh = yield* Effect.sync(() => { + try { + return MessageV2.get({ sessionID: ctx.assistantMessage.sessionID, messageID: ctx.assistantMessage.id }) + } catch (err) { + if (NotFoundError.isInstance(err)) return + throw err + } + }) + if (fresh?.info.role !== "assistant") return + if (fresh.info.cost <= ctx.assistantMessage.cost) return + ctx.assistantMessage.cost = fresh.info.cost + }) + // kilocode_change end + const updateToolCall = Effect.fn("SessionProcessor.updateToolCall")(function* ( toolCallID: string, update: (part: MessageV2.ToolPart) => MessageV2.ToolPart, @@ -410,6 +427,9 @@ export const layer: Layer.Layer< }) // kilocode_change end ctx.assistantMessage.finish = value.finishReason + // kilocode_change start - capture any subagent cost propagated by tool calls during this step (#6321) + yield* reconcile() + // kilocode_change end ctx.assistantMessage.cost += usage.cost ctx.assistantMessage.tokens = usage.tokens yield* session.updatePart({ @@ -567,6 +587,9 @@ export const layer: Layer.Layer< ctx.toolcalls = {} KiloSessionProcessor.guardEmptyToolCalls(ctx.assistantMessage, MessageV2.parts(ctx.assistantMessage.id)) // kilocode_change ctx.assistantMessage.time.completed = Date.now() + // kilocode_change start - reconcile cost with any subagent propagation written during tool calls (#6321) + yield* reconcile() + // kilocode_change end yield* session.updateMessage(ctx.assistantMessage) }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 83d9d5e9ab5..9a429b6770a 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -4,6 +4,7 @@ import fs from "fs/promises" import { KiloSessionPrompt } from "@/kilocode/session/prompt" // kilocode_change import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" // kilocode_change import { KiloSession } from "@/kilocode/session" // kilocode_change +import { KiloCostPropagation } from "@/kilocode/session/cost-propagation" // kilocode_change import { Suggestion } from "@/kilocode/suggestion" // kilocode_change import { Question } from "@/question" // kilocode_change import z from "zod" @@ -604,6 +605,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the let error: Error | undefined const taskAbort = new AbortController() + // kilocode_change start - shared reader for the child session id written by task.ts ctx.metadata (#6321) + const childID = () => { + const meta = part.state.status !== "pending" ? part.state.metadata : undefined + return (meta as { sessionId?: string } | undefined)?.sessionId + } + // kilocode_change end const result = yield* taskTool .execute(taskArgs, { agent: task.agent, @@ -648,6 +655,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the taskAbort.abort() assistantMessage.finish = "tool-calls" assistantMessage.time.completed = Date.now() + // kilocode_change start - propagate partial subagent cost on cancel (#6321) + const cid = childID() + if (cid) { + assistantMessage.cost = yield* KiloCostPropagation.childCost(sessions, SessionID.make(cid)) + } + // kilocode_change end yield* sessions.updateMessage(assistantMessage) if (part.state.status === "running") { yield* sessions.updatePart({ @@ -680,6 +693,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the assistantMessage.finish = "tool-calls" assistantMessage.time.completed = Date.now() + // kilocode_change start - include subagent total cost on the wrapper message (#6321) + const cid = result?.metadata?.sessionId ?? childID() + if (cid) { + assistantMessage.cost = yield* KiloCostPropagation.childCost(sessions, SessionID.make(cid)) + } + // kilocode_change end yield* sessions.updateMessage(assistantMessage) if (result && part.state.status === "running") { @@ -804,12 +823,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the const shellName = ( process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh) ).toLowerCase() - const cwd = ctx.directory // kilocode_change - moved up to use in invocations below + const cwd = ctx.directory const invocations: Record = { nu: { args: ["-c", input.command] }, fish: { args: ["-c", input.command] }, zsh: { - // kilocode_change start - port anomalyco/opencode#24215: pass cwd as positional arg instead of $PWD (CI resets $PWD after startup files) args: [ "-l", "-c", @@ -822,10 +840,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the "opencode", cwd, ], - // kilocode_change end }, bash: { - // kilocode_change start - port anomalyco/opencode#24215: pass cwd as positional arg instead of $PWD (CI resets $PWD after startup files) args: [ "-l", "-c", @@ -838,7 +854,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the "opencode", cwd, ], - // kilocode_change end }, cmd: { args: ["/c", input.command] }, powershell: { args: ["-NoProfile", "-Command", input.command] }, @@ -1270,7 +1285,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the { message: info, parts }, ) - const parsed = MessageV2.Info.safeParse(info) + const parsed = MessageV2.Info.zod.safeParse(info) if (!parsed.success) { log.error("invalid user message before save", { sessionID: input.sessionID, @@ -1281,7 +1296,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the }) } parts.forEach((part, index) => { - const p = MessageV2.Part.safeParse(part) + const p = MessageV2.Part.zod.safeParse(part) if (p.success) return log.error("invalid user part before save", { sessionID: input.sessionID, @@ -1870,7 +1885,7 @@ export const PromptInput = z.object({ .record(z.string(), z.boolean()) .optional() .describe("@deprecated tools and permissions have been merged, you can set permissions on the session itself now"), - format: MessageV2.Format.optional(), + format: MessageV2.Format.zod.optional(), system: z.string().optional(), variant: z.string().optional(), // kilocode_change start @@ -1885,50 +1900,25 @@ export const PromptInput = z.object({ // kilocode_change end parts: z.array( z.discriminatedUnion("type", [ - MessageV2.TextPart.omit({ - messageID: true, - sessionID: true, - }) - .partial({ - id: true, - }) - .meta({ - ref: "TextPartInput", - }), - MessageV2.FilePart.omit({ - messageID: true, - sessionID: true, - }) - .partial({ - id: true, - }) - .meta({ - ref: "FilePartInput", - }), - MessageV2.AgentPart.omit({ - messageID: true, - sessionID: true, - }) - .partial({ - id: true, - }) - .meta({ - ref: "AgentPartInput", - }), - MessageV2.SubtaskPart.omit({ - messageID: true, - sessionID: true, - }) - .partial({ - id: true, - }) - .meta({ - ref: "SubtaskPartInput", - }), + MessageV2.TextPartInput.zod as unknown as z.ZodObject, + MessageV2.FilePartInput.zod as unknown as z.ZodObject, + MessageV2.AgentPartInput.zod as unknown as z.ZodObject, + MessageV2.SubtaskPartInput.zod as unknown as z.ZodObject, ]), ), }) -export type PromptInput = z.infer +// `z.discriminatedUnion` erases the discriminated members' shapes back to +// `{}` because the derived `.zod` on each input is typed as an opaque +// `z.ZodType`. Restore the precise `parts` type from the exported Schema +// input types so callers see a proper tagged union. +type PartInputUnion = + | MessageV2.TextPartInput + | MessageV2.FilePartInput + | MessageV2.AgentPartInput + | MessageV2.SubtaskPartInput +export type PromptInput = Omit, "parts"> & { + parts: PartInputUnion[] +} export const LoopInput = z.object({ sessionID: SessionID.zod, @@ -1956,14 +1946,19 @@ export const CommandInput = z.object({ arguments: z.string(), command: z.string(), variant: z.string().optional(), + // Inlined (no `.meta({ ref })`) to keep the original SDK output — the + // PromptInput call site below references FilePartInput by ref via the + // Schema export in message-v2.ts. parts: z .array( z.discriminatedUnion("type", [ - MessageV2.FilePart.omit({ - messageID: true, - sessionID: true, - }).partial({ - id: true, + z.object({ + id: PartID.zod.optional(), + type: z.literal("file"), + mime: z.string(), + filename: z.string().optional(), + url: z.string(), + source: MessageV2.FilePartSource.zod.optional(), }), ]), ) diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts index 1448aa9f35c..bd84ae563dd 100644 --- a/packages/opencode/src/session/revert.ts +++ b/packages/opencode/src/session/revert.ts @@ -83,8 +83,8 @@ export const layer = Layer.effect( if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string) yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore) yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs }) - // kilocode_change start - strip full file contents before persisting to DB - const summaryDiffs = diffs.map((d) => ({ + // kilocode_change start + const summaryDiffs: Snapshot.SummaryFileDiff[] = diffs.map((d) => ({ file: d.file, additions: d.additions, deletions: d.deletions, diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index efed280c98c..487cbcd34a7 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,15 +1,14 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" -import { ZodOverride } from "@/util/effect-zod" +import { zod, ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe( Schema.brand("SessionID"), withStatics((s) => ({ descending: (id?: string) => s.make(Identifier.descending("session", id)), - zod: Identifier.schema("session").pipe(z.custom>()), + zod: zod(s), })), ) @@ -19,7 +18,7 @@ export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.sche Schema.brand("MessageID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("message", id)), - zod: Identifier.schema("message").pipe(z.custom>()), + zod: zod(s), })), ) @@ -29,7 +28,7 @@ export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema( Schema.brand("PartID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("part", id)), - zod: Identifier.schema("part").pipe(z.custom>()), + zod: zod(s), })), ) diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts index 011ee5fd25c..b018db64fd5 100644 --- a/packages/opencode/src/session/session.sql.ts +++ b/packages/opencode/src/session/session.sql.ts @@ -30,11 +30,7 @@ export const SessionTable = sqliteTable( summary_additions: integer(), summary_deletions: integer(), summary_files: integer(), - // kilocode_change start - lightweight diff type (no file contents) - summary_diffs: text({ mode: "json" }).$type< - { file: string; additions: number; deletions: number; status?: "added" | "deleted" | "modified" }[] - >(), - // kilocode_change end + summary_diffs: text({ mode: "json" }).$type(), // kilocode_change revert: text({ mode: "json" }).$type<{ messageID: MessageID; partID?: PartID; snapshot?: string; diff?: string }>(), permission: text({ mode: "json" }).$type(), ...Timestamps, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index e1e5fa088ae..b57c5660e02 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -130,17 +130,7 @@ export const Info = z additions: z.number(), deletions: z.number(), files: z.number(), - // kilocode_change start - use lightweight diff schema (without before/after file contents) - diffs: z - .object({ - file: z.string(), - additions: z.number(), - deletions: z.number(), - status: z.enum(["added", "deleted", "modified"]).optional(), - }) - .array() - .optional(), - // kilocode_change end + diffs: Snapshot.SummaryFileDiff.zod.array().optional(), // kilocode_change }) .optional(), share: z @@ -254,14 +244,15 @@ export const Event = { "session.diff", z.object({ sessionID: SessionID.zod, - diff: Snapshot.FileDiff.array(), + diff: Snapshot.FileDiff.zod.array(), }), ), Error: BusEvent.define( "session.error", z.object({ sessionID: SessionID.zod.optional(), - error: MessageV2.Assistant.shape.error, + // z.lazy defers access to break circular dep: session → message-v2 → provider → plugin → session + error: z.lazy(() => (MessageV2.Assistant.zod as unknown as z.ZodObject).shape.error), }), ), // kilocode_change start @@ -846,7 +837,7 @@ export const messages = fn(MessagesInput, (input) => runPromise((svc) => svc.mes export const children = fn(ChildrenInput, (id) => runPromise((svc) => svc.children(id))) export const remove = fn(RemoveInput, (id) => runPromise((svc) => svc.remove(id))) export async function updateMessage(msg: T): Promise { - MessageV2.Info.parse(msg) + MessageV2.Info.zod.parse(msg) // kilocode_change return runPromise((svc) => svc.updateMessage(msg)) } @@ -860,7 +851,7 @@ export const removePart = fn( ) export async function updatePart(part: T): Promise { - MessageV2.Part.parse(part) + MessageV2.Part.zod.parse(part) // kilocode_change return runPromise((svc) => svc.updatePart(part)) } diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 9d961638b54..6ac5b5b633a 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -1,4 +1,4 @@ -import { Cause, Duration, Effect, Layer, Schedule, Semaphore, Context, Stream } from "effect" +import { Cause, Duration, Effect, Layer, Schedule, Schema, Semaphore, Struct, Context, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { formatPatch, structuredPatch } from "diff" import path from "path" @@ -13,25 +13,32 @@ import { Global } from "../global" import { Log } from "../util" import { Flag } from "@/flag/flag" // kilocode_change import { DiffFull } from "../kilocode/snapshot/diff-full" // kilocode_change +import { withStatics } from "@/util/schema" +import { zod } from "@/util/effect-zod" -export const Patch = z.object({ - hash: z.string(), - files: z.string().array(), +export const Patch = Schema.Struct({ + hash: Schema.String, + files: Schema.mutable(Schema.Array(Schema.String)), +}).pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Patch = typeof Patch.Type + +export const FileDiff = Schema.Struct({ + file: Schema.String, + patch: Schema.String, + additions: Schema.Number, + deletions: Schema.Number, + status: Schema.optional(Schema.Literals(["added", "deleted", "modified"])), }) -export type Patch = z.infer + .annotate({ identifier: "SnapshotFileDiff" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type FileDiff = typeof FileDiff.Type -export const FileDiff = z - .object({ - file: z.string(), - patch: z.string(), - additions: z.number(), - deletions: z.number(), - status: z.enum(["added", "deleted", "modified"]).optional(), - }) - .meta({ - ref: "SnapshotFileDiff", - }) -export type FileDiff = z.infer +// kilocode_change start - lightweight FileDiff without `patch` for session.summary.diffs (keeps DB payload small) +export const SummaryFileDiff = FileDiff.mapFields(Struct.omit(["patch"])) + .annotate({ identifier: "SnapshotSummaryFileDiff" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type SummaryFileDiff = typeof SummaryFileDiff.Type +// kilocode_change end const log = Log.create({ service: "snapshot" }) const prune = "7.days" diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts index 12133ce4355..05588db0f67 100644 --- a/packages/opencode/src/storage/json-migration.ts +++ b/packages/opencode/src/storage/json-migration.ts @@ -168,6 +168,7 @@ export async function run(db: SQLiteBunDatabase | NodeSQLiteDatabase(key: string[], content: T) => runPromise((svc) => svc.w export const remove = (key: string[]) => runPromise((svc) => svc.remove(key)) export const list = (prefix: string[]) => runPromise((svc) => svc.list(prefix)) export const update = (key: string[], fn: (draft: T) => void) => runPromise((svc) => svc.update(key, fn)) -// kilocode_change end \ No newline at end of file +// kilocode_change end diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts index 37cdbd718fb..e714b86ae0c 100644 --- a/packages/opencode/src/sync/schema.ts +++ b/packages/opencode/src/sync/schema.ts @@ -1,14 +1,13 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" -import { ZodOverride } from "@/util/effect-zod" +import { zod, ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe( Schema.brand("EventID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("event", id)), - zod: Identifier.schema("event").pipe(z.custom>()), + zod: zod(s), })), ) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index d81511c5517..edb74a52c74 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -17,6 +17,7 @@ import { filterDiagnostics } from "./diagnostics" // kilocode_change import { ConfigValidation } from "../kilocode/config-validation" // kilocode_change import { EncodedIO } from "../kilocode/tool/encoded-io" // kilocode_change import { Format } from "../format" +import * as Bom from "@/util/bom" const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), @@ -62,6 +63,7 @@ export const ApplyPatchTool = Tool.define( diff: string additions: number deletions: number + bom: boolean encoding: string // kilocode_change - preserved per-file encoding }> = [] @@ -76,11 +78,12 @@ export const ApplyPatchTool = Tool.define( const oldContent = "" const newContent = hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` - const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) + const next = Bom.split(newContent) + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, next.text)) let additions = 0 let deletions = 0 - for (const change of diffLines(oldContent, newContent)) { + for (const change of diffLines(oldContent, next.text)) { if (change.added) additions += change.count || 0 if (change.removed) deletions += change.count || 0 } @@ -88,11 +91,12 @@ export const ApplyPatchTool = Tool.define( fileChanges.push({ filePath, oldContent, - newContent, + newContent: next.text, type: "add", diff, additions, deletions, + bom: next.bom, encoding: "utf-8", // kilocode_change - new files default to utf-8 }) @@ -109,14 +113,17 @@ export const ApplyPatchTool = Tool.define( ) } - const oldContent = (yield* EncodedIO.read(filePath)).text // kilocode_change - encoding-aware read + const source = yield* Bom.readFile(afs, filePath) + const oldContent = source.text let newContent = oldContent + let bom = source.bom let encoding: string // kilocode_change - filled in by the patch helper below // Apply the update chunks to get new content try { const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) newContent = fileUpdate.content + bom = fileUpdate.bom encoding = fileUpdate.encoding // kilocode_change } catch (error) { return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`)) @@ -143,6 +150,7 @@ export const ApplyPatchTool = Tool.define( diff, additions, deletions, + bom, encoding, // kilocode_change }) @@ -151,7 +159,7 @@ export const ApplyPatchTool = Tool.define( } case "delete": { - // kilocode_change start - encoding-aware read + // kilocode_change start - encoding-aware read so non-UTF-8 files decode without corruption const deleteRead = yield* EncodedIO.read(filePath).pipe( Effect.catch((error) => Effect.fail( @@ -162,6 +170,7 @@ export const ApplyPatchTool = Tool.define( ), ) const contentToDelete = deleteRead.text + const source = Bom.split(contentToDelete) // kilocode_change end const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) @@ -175,6 +184,7 @@ export const ApplyPatchTool = Tool.define( diff: deleteDiff, additions: 0, deletions, + bom: source.bom, encoding: deleteRead.encoding, // kilocode_change }) @@ -216,19 +226,19 @@ export const ApplyPatchTool = Tool.define( switch (change.type) { case "add": // Create parent directories (recursive: true is safe on existing/root dirs) - yield* EncodedIO.write(change.filePath, change.newContent, change.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces afs.writeWithDirs + yield* EncodedIO.write(change.filePath, Bom.join(change.newContent, change.bom), change.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces afs.writeWithDirs updates.push({ file: change.filePath, event: "add" }) break case "update": - yield* EncodedIO.write(change.filePath, change.newContent, change.encoding) // kilocode_change - encoding-aware write replaces afs.writeWithDirs + yield* EncodedIO.write(change.filePath, Bom.join(change.newContent, change.bom), change.encoding) // kilocode_change - encoding-aware write replaces afs.writeWithDirs updates.push({ file: change.filePath, event: "change" }) break case "move": if (change.movePath) { // Create parent directories (recursive: true is safe on existing/root dirs) - yield* EncodedIO.write(change.movePath!, change.newContent, change.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces afs.writeWithDirs + yield* EncodedIO.write(change.movePath!, Bom.join(change.newContent, change.bom), change.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces afs.writeWithDirs yield* afs.remove(change.filePath) updates.push({ file: change.filePath, event: "unlink" }) updates.push({ file: change.movePath, event: "add" }) @@ -242,7 +252,9 @@ export const ApplyPatchTool = Tool.define( } if (edited) { - yield* format.file(edited) + if (yield* format.file(edited)) { + yield* Bom.syncFile(afs, edited, change.bom) + } yield* bus.publish(File.Event.Edited, { file: edited }) } } @@ -256,7 +268,7 @@ export const ApplyPatchTool = Tool.define( for (const change of fileChanges) { if (change.type === "delete") continue const target = change.movePath ?? change.filePath - yield* lsp.touchFile(target, true) + yield* lsp.touchFile(target, "document") } const diagnostics = yield* lsp.diagnostics() diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index f7e17553b64..6af481e10ce 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -5,7 +5,7 @@ import z from "zod" import * as path from "path" -import { Effect } from "effect" +import { Effect, Semaphore } from "effect" import * as Tool from "./tool" import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" @@ -18,6 +18,7 @@ import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" import { assertExternalDirectoryEffect } from "./external-directory" import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import * as Bom from "@/util/bom" import { filterDiagnostics } from "./diagnostics" // kilocode_change import { ConfigValidation } from "../kilocode/config-validation" // kilocode_change import { EncodedIO } from "../kilocode/tool/encoded-io" // kilocode_change @@ -57,6 +58,18 @@ function convertToLineEnding(text: string, ending: "\n" | "\r\n"): string { return text.replaceAll("\n", "\r\n") } +const locks = new Map() + +function lock(filePath: string) { + const resolvedFilePath = AppFileSystem.resolve(filePath) + const hit = locks.get(resolvedFilePath) + if (hit) return hit + + const next = Semaphore.makeUnsafe(1) + locks.set(resolvedFilePath, next) + return next +} + const Parameters = z.object({ filePath: z.string().describe("The absolute path to the file to modify"), oldString: z.string().describe("The text to replace"), @@ -94,16 +107,69 @@ export const EditTool = Tool.define( let contentOld = "" let contentNew = "" let cachedFilediff: Snapshot.FileDiff | undefined // kilocode_change - yield* Effect.gen(function* () { - if (params.oldString === "") { - const existed = yield* afs.existsSafe(filePath) - // kilocode_change start - preserve file encoding on write - const pre = existed ? yield* EncodedIO.read(filePath) : { text: "", encoding: "utf-8" } - contentOld = pre.text - const encoding = pre.encoding + yield* lock(filePath).withPermits(1)( + Effect.gen(function* () { + if (params.oldString === "") { + const existed = yield* afs.existsSafe(filePath) + // kilocode_change start - encoding-aware read; Encoding.read strips UTF-8 BOMs so + // derive the BOM flag from the detected encoding label instead of the decoded text. + const pre = existed ? yield* EncodedIO.read(filePath) : { text: "", encoding: "utf-8" } + const source = { bom: pre.encoding === "utf-8-bom", text: pre.text, encoding: pre.encoding } + // kilocode_change end + const next = Bom.split(params.newString) + const desiredBom = source.bom || next.bom + contentOld = source.text + contentNew = next.text + diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) + cachedFilediff = buildFileDiff(filePath, contentOld, contentNew) // kilocode_change + yield* ctx.ask({ + permission: "edit", + patterns: [path.relative(Instance.worktree, filePath)], + always: ["*"], + metadata: { + filepath: filePath, + diff, + filediff: cachedFilediff, // kilocode_change + }, + }) + yield* EncodedIO.write(filePath, Bom.join(contentNew, desiredBom), source.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces afs.writeWithDirs + if (yield* format.file(filePath)) { + contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) + } + yield* bus.publish(File.Event.Edited, { file: filePath }) + yield* bus.publish(FileWatcher.Event.Updated, { + file: filePath, + event: existed ? "change" : "add", + }) + return + } + + const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!info) throw new Error(`File ${filePath} not found`) + if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`) + // kilocode_change start - encoding-aware read; Encoding.read strips UTF-8 BOMs so + // derive the BOM flag from the detected encoding label instead of the decoded text. + const pre = yield* EncodedIO.read(filePath) + const source = { bom: pre.encoding === "utf-8-bom", text: pre.text, encoding: pre.encoding } // kilocode_change end - contentNew = params.newString - diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) + contentOld = source.text + + const ending = detectLineEnding(contentOld) + const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending) + const replacement = convertToLineEnding(normalizeLineEndings(params.newString), ending) + + const next = Bom.split(replace(contentOld, old, replacement, params.replaceAll)) + const desiredBom = source.bom || next.bom + contentNew = next.text + + diff = trimDiff( + createTwoFilesPatch( + filePath, + filePath, + normalizeLineEndings(contentOld), + normalizeLineEndings(contentNew), + ), + ) cachedFilediff = buildFileDiff(filePath, contentOld, contentNew) // kilocode_change yield* ctx.ask({ permission: "edit", @@ -115,68 +181,26 @@ export const EditTool = Tool.define( filediff: cachedFilediff, // kilocode_change }, }) - yield* EncodedIO.write(filePath, params.newString, encoding) // kilocode_change - preserve encoding; replaces afs.writeWithDirs - yield* format.file(filePath) + + yield* EncodedIO.write(filePath, Bom.join(contentNew, desiredBom), source.encoding) // kilocode_change - encoding-aware write replaces afs.writeWithDirs + if (yield* format.file(filePath)) { + contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) + } yield* bus.publish(File.Event.Edited, { file: filePath }) yield* bus.publish(FileWatcher.Event.Updated, { file: filePath, - event: existed ? "change" : "add", + event: "change", }) - return - } - - const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined))) - if (!info) throw new Error(`File ${filePath} not found`) - if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`) - // kilocode_change start - preserve file encoding - const pre = yield* EncodedIO.read(filePath) - contentOld = pre.text - const encoding = pre.encoding - // kilocode_change end - - const ending = detectLineEnding(contentOld) - const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending) - const next = convertToLineEnding(normalizeLineEndings(params.newString), ending) - - contentNew = replace(contentOld, old, next, params.replaceAll) - - diff = trimDiff( - createTwoFilesPatch( - filePath, - filePath, - normalizeLineEndings(contentOld), - normalizeLineEndings(contentNew), - ), - ) - cachedFilediff = buildFileDiff(filePath, contentOld, contentNew) // kilocode_change - yield* ctx.ask({ - permission: "edit", - patterns: [path.relative(Instance.worktree, filePath)], - always: ["*"], - metadata: { - filepath: filePath, - diff, - filediff: cachedFilediff, // kilocode_change - }, - }) - - yield* EncodedIO.write(filePath, contentNew, encoding) // kilocode_change - preserve encoding; replaces afs.writeWithDirs - yield* format.file(filePath) - yield* bus.publish(File.Event.Edited, { file: filePath }) - yield* bus.publish(FileWatcher.Event.Updated, { - file: filePath, - event: "change", - }) - contentNew = (yield* EncodedIO.read(filePath)).text // kilocode_change - re-read via encoding-aware helper; replaces afs.readFileString - diff = trimDiff( - createTwoFilesPatch( - filePath, - filePath, - normalizeLineEndings(contentOld), - normalizeLineEndings(contentNew), - ), - ) - }).pipe(Effect.orDie) + diff = trimDiff( + createTwoFilesPatch( + filePath, + filePath, + normalizeLineEndings(contentOld), + normalizeLineEndings(contentNew), + ), + ) + }).pipe(Effect.orDie), + ) const filediff: Snapshot.FileDiff = cachedFilediff ?? buildFileDiff(filePath, contentOld, contentNew) // kilocode_change @@ -189,7 +213,7 @@ export const EditTool = Tool.define( }) let output = "Edit applied successfully." - yield* lsp.touchFile(filePath, true) + yield* lsp.touchFile(filePath, "document") const diagnostics = yield* lsp.diagnostics() const normalizedFilePath = AppFileSystem.normalizePath(filePath) const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? []) diff --git a/packages/opencode/src/tool/external-directory.ts b/packages/opencode/src/tool/external-directory.ts index 88b73da5096..c6518e20d2b 100644 --- a/packages/opencode/src/tool/external-directory.ts +++ b/packages/opencode/src/tool/external-directory.ts @@ -3,7 +3,6 @@ import { Effect } from "effect" import { EffectLogger } from "@/effect" import { InstanceState } from "@/effect" import type * as Tool from "./tool" -import { Instance } from "../project/instance" import { AppFileSystem } from "@opencode-ai/shared/filesystem" type Kind = "file" | "directory" @@ -13,6 +12,16 @@ type Options = { kind?: Kind } +// kilocode_change start - root boundaries must not auto-allow external_directory +function root(dir: string) { + return path.parse(dir).root === dir +} + +function inside(dir: string, file: string) { + return !root(dir) && AppFileSystem.contains(dir, file) +} +// kilocode_change end + export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirectory")(function* ( ctx: Tool.Context, target?: string, @@ -24,7 +33,9 @@ export const assertExternalDirectoryEffect = Effect.fn("Tool.assertExternalDirec const ins = yield* InstanceState.context const full = process.platform === "win32" ? AppFileSystem.normalizePath(target) : target - if (Instance.containsPath(full, ins)) return + // kilocode_change start - keep root-workspace behavior intact outside permission prompts + if (inside(ins.directory, full) || inside(ins.worktree, full)) return + // kilocode_change end const kind = options?.kind ?? "file" const dir = kind === "directory" ? full : path.dirname(full) diff --git a/packages/opencode/src/tool/grep.txt b/packages/opencode/src/tool/grep.txt index adf583695ae..e9d53d057dd 100644 --- a/packages/opencode/src/tool/grep.txt +++ b/packages/opencode/src/tool/grep.txt @@ -5,4 +5,5 @@ - Returns file paths and line numbers with at least one match sorted by modification time - Use this tool when you need to find files containing specific patterns - If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`. -- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead +- When you are doing an open-ended search where you do not know the exact symbol name, use the SemanticSearch tool instead +- When you are doing a deep search that may require multiple tool invocations, use the Task tool instead diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts index 263bfe81d2f..0a0edc61edd 100644 --- a/packages/opencode/src/tool/lsp.ts +++ b/packages/opencode/src/tool/lsp.ts @@ -55,7 +55,7 @@ export const LspTool = Tool.define( const available = yield* lsp.hasClients(file) if (!available) throw new Error("No LSP server available for this file type.") - yield* lsp.touchFile(file, true) + yield* lsp.touchFile(file, "document") const result: unknown[] = yield* (() => { switch (args.operation) { diff --git a/packages/opencode/src/tool/multiedit.ts b/packages/opencode/src/tool/multiedit.ts deleted file mode 100644 index 004d3c870dd..00000000000 --- a/packages/opencode/src/tool/multiedit.ts +++ /dev/null @@ -1,61 +0,0 @@ -import z from "zod" -import { Effect } from "effect" -import * as Tool from "./tool" -import { EditTool } from "./edit" -import DESCRIPTION from "./multiedit.txt" -import path from "path" -import { Instance } from "../project/instance" - -export const MultiEditTool = Tool.define( - "multiedit", - Effect.gen(function* () { - const editInfo = yield* EditTool - const edit = yield* editInfo.init() - - return { - description: DESCRIPTION, - parameters: z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - edits: z - .array( - z.object({ - filePath: z.string().describe("The absolute path to the file to modify"), - oldString: z.string().describe("The text to replace"), - newString: z.string().describe("The text to replace it with (must be different from oldString)"), - replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"), - }), - ) - .describe("Array of edit operations to perform sequentially on the file"), - }), - execute: ( - params: { - filePath: string - edits: Array<{ filePath: string; oldString: string; newString: string; replaceAll?: boolean }> - }, - ctx: Tool.Context, - ) => - Effect.gen(function* () { - const results = [] - for (const [, entry] of params.edits.entries()) { - const result = yield* edit.execute( - { - filePath: params.filePath, - oldString: entry.oldString, - newString: entry.newString, - replaceAll: entry.replaceAll, - }, - ctx, - ) - results.push(result) - } - return { - title: path.relative(Instance.worktree, params.filePath), - metadata: { - results: results.map((r) => r.metadata), - }, - output: results.at(-1)!.output, - } - }), - } - }), -) diff --git a/packages/opencode/src/tool/multiedit.txt b/packages/opencode/src/tool/multiedit.txt deleted file mode 100644 index bb4815124d1..00000000000 --- a/packages/opencode/src/tool/multiedit.txt +++ /dev/null @@ -1,41 +0,0 @@ -This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit tool when you need to make multiple edits to the same file. - -Before using this tool: - -1. Use the Read tool to understand the file's contents and context -2. Verify the directory path is correct - -To make multiple file edits, provide the following: -1. file_path: The absolute path to the file to modify (must be absolute, not relative) -2. edits: An array of edit operations to perform, where each edit contains: - - oldString: The text to replace (must match the file contents exactly, including all whitespace and indentation) - - newString: The edited text to replace the oldString - - replaceAll: Replace all occurrences of oldString. This parameter is optional and defaults to false. - -IMPORTANT: -- All edits are applied in sequence, in the order they are provided -- Each edit operates on the result of the previous edit -- All edits must be valid for the operation to succeed - if any edit fails, none will be applied -- This tool is ideal when you need to make several changes to different parts of the same file - -CRITICAL REQUIREMENTS: -1. All edits follow the same requirements as the single Edit tool -2. The edits are atomic - either all succeed or none are applied -3. Plan your edits carefully to avoid conflicts between sequential operations - -WARNING: -- The tool will fail if edits.oldString doesn't match the file contents exactly (including whitespace) -- The tool will fail if edits.oldString and edits.newString are the same -- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find - -When making edits: -- Ensure all edits result in idiomatic, correct code -- Do not leave the code in a broken state -- Always use absolute file paths (starting with /) -- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. -- Use replaceAll for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. - -If you want to create a new file, use: -- A new file path, including dir name if needed -- First edit: empty oldString and the new file's contents as newString -- Subsequent edits: normal edit operations on the created content diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 9f664019aaa..a44a9e02700 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -80,7 +80,7 @@ export const ReadTool = Tool.define( }) const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) { - yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope)) + yield* lsp.touchFile(filepath).pipe(Effect.ignore, Effect.forkIn(scope)) }) const readSample = Effect.fn("ReadTool.readSample")(function* ( diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 58e118e4301..9f12acaba07 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -162,9 +162,9 @@ export const layer: Layer.Layer< if (matches.length) yield* config.waitForDependencies() for (const match of matches) { const namespace = path.basename(match, path.extname(match)) - const mod = yield* Effect.promise( - () => import(process.platform === "win32" ? match : pathToFileURL(match).href), - ) + // `match` is an absolute filesystem path from `Glob.scanSync(..., { absolute: true })`. + // Import it as `file://` so Node on Windows accepts the dynamic import. + const mod = yield* Effect.promise(() => import(pathToFileURL(match).href)) for (const [id, def] of Object.entries(mod)) { custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def)) } @@ -180,6 +180,7 @@ export const layer: Layer.Layer< const cfg = yield* config.get() const questionEnabled = KiloToolRegistry.question() // kilocode_change + // kilocode_change start const tool = yield* Effect.all({ invalid: Tool.init(invalid), bash: Tool.init(bash), @@ -200,9 +201,11 @@ export const layer: Layer.Layer< plan: Tool.init(plan), suggest: Tool.init(suggesttool), // kilocode_change }) + // kilocode_change end const kilo = yield* KiloToolRegistry.build(kiloToolInfos) // kilocode_change + // kilocode_change start return { custom, builtin: [ @@ -229,6 +232,7 @@ export const layer: Layer.Layer< task: tool.task, read: tool.read, } + // kilocode_change end }), ) diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index ac41fd1606e..9ce7bece2b0 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -1,8 +1,7 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" -import { ZodOverride } from "@/util/effect-zod" +import { zod, ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID")) @@ -12,6 +11,6 @@ export type ToolID = typeof toolIdSchema.Type export const ToolID = toolIdSchema.pipe( withStatics((schema: typeof toolIdSchema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("tool", id)), - zod: Identifier.schema("tool").pipe(z.custom()), + zod: zod(schema), })), ) diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index aed95c9f12f..bd9e5fb62f6 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -9,6 +9,7 @@ import type { SessionPrompt } from "../session/prompt" import { Config } from "../config" import { Effect } from "effect" import { KiloTask } from "../kilocode/tool/task" // kilocode_change +import { KiloCostPropagation } from "../kilocode/session/cost-propagation" // kilocode_change export interface TaskPromptOps { cancel(sessionID: SessionID): void @@ -141,9 +142,12 @@ export const TaskTool = Tool.define( } return yield* Effect.acquireUseRelease( - Effect.sync(() => { + // kilocode_change start - snapshot child cost so we propagate only the delta on resume (#6321) + Effect.gen(function* () { ctx.abort.addEventListener("abort", cancel) + return yield* KiloCostPropagation.childCost(sessions, nextSession.id) }), + // kilocode_change end () => Effect.gen(function* () { const parts = yield* ops.resolvePromptParts(params.prompt) @@ -180,10 +184,14 @@ export const TaskTool = Tool.define( ].join("\n"), } }), - () => - Effect.sync(() => { + // kilocode_change start - propagate subagent cost delta to parent on every exit path (#6321) + (costBefore) => + Effect.gen(function* () { ctx.abort.removeEventListener("abort", cancel) + const costAfter = yield* KiloCostPropagation.childCost(sessions, nextSession.id) + yield* KiloCostPropagation.propagate(sessions, ctx.sessionID, ctx.messageID, costAfter - costBefore) }), + // kilocode_change end ) }) diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 2bfcf850d26..d1d69e40c42 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -16,6 +16,7 @@ import { assertExternalDirectoryEffect } from "./external-directory" import { filterDiagnostics } from "./diagnostics" // kilocode_change import { ConfigValidation } from "../kilocode/config-validation" // kilocode_change import { EncodedIO } from "../kilocode/tool/encoded-io" // kilocode_change +import * as Bom from "@/util/bom" const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -41,14 +42,18 @@ export const WriteTool = Tool.define( yield* assertExternalDirectoryEffect(ctx, filepath) const exists = yield* fs.existsSafe(filepath) - // kilocode_change start - preserve file encoding on write + // kilocode_change start - encoding-aware read; Encoding.read strips UTF-8 BOMs so + // derive the BOM flag from the detected encoding label instead of the decoded text. const pre = exists ? yield* EncodedIO.read(filepath) : { text: "", encoding: "utf-8" } - const contentOld = pre.text - const encoding = pre.encoding + const source = { bom: pre.encoding === "utf-8-bom", text: pre.text, encoding: pre.encoding } // kilocode_change end + const next = Bom.split(params.content) + const desiredBom = source.bom || next.bom + const contentOld = source.text + const contentNew = next.text - const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) - const filediff = buildFileDiff(filepath, contentOld, params.content) // kilocode_change + const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew)) + const filediff = buildFileDiff(filepath, contentOld, contentNew) // kilocode_change yield* ctx.ask({ permission: "edit", patterns: [path.relative(Instance.worktree, filepath)], @@ -60,8 +65,10 @@ export const WriteTool = Tool.define( }, }) - yield* EncodedIO.write(filepath, params.content, encoding) // kilocode_change - preserve encoding; replaces fs.writeWithDirs - yield* format.file(filepath) + yield* EncodedIO.write(filepath, Bom.join(contentNew, desiredBom), source.encoding) // kilocode_change - encoding-aware write (mkdirs) replaces fs.writeWithDirs + if (yield* format.file(filepath)) { + yield* Bom.syncFile(fs, filepath, desiredBom) + } yield* bus.publish(File.Event.Edited, { file: filepath }) yield* bus.publish(FileWatcher.Event.Updated, { file: filepath, @@ -69,7 +76,7 @@ export const WriteTool = Tool.define( }) let output = "Wrote file successfully." - yield* lsp.touchFile(filepath, true) + yield* lsp.touchFile(filepath, "document") const diagnostics = yield* lsp.diagnostics() const normalizedFilepath = AppFileSystem.normalizePath(filepath) let projectDiagnosticsCount = 0 diff --git a/packages/opencode/src/util/bom.ts b/packages/opencode/src/util/bom.ts new file mode 100644 index 00000000000..484228f3d41 --- /dev/null +++ b/packages/opencode/src/util/bom.ts @@ -0,0 +1,31 @@ +import { Effect } from "effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" + +const BOM_CODE = 0xfeff +const BOM = String.fromCharCode(BOM_CODE) + +export function split(text: string) { + if (text.charCodeAt(0) !== BOM_CODE) return { bom: false, text } + return { bom: true, text: text.slice(1) } +} + +export function join(text: string, bom: boolean) { + const stripped = split(text).text + if (!bom) return stripped + return BOM + stripped +} + +export const readFile = Effect.fn("Bom.readFile")(function* (fs: AppFileSystem.Interface, filePath: string) { + return split(new TextDecoder("utf-8", { ignoreBOM: true }).decode(yield* fs.readFile(filePath))) +}) + +export const syncFile = Effect.fn("Bom.syncFile")(function* ( + fs: AppFileSystem.Interface, + filePath: string, + bom: boolean, +) { + const current = yield* readFile(fs, filePath) + if (current.bom === bom) return current.text + yield* fs.writeWithDirs(filePath, join(current.text, bom)) + return current.text +}) diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index bf1caa035b0..f6d2c5e5c04 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -8,43 +8,6 @@ import z from "zod" */ export const ZodOverride: unique symbol = Symbol.for("effect-zod/override") -/** - * Annotation key for a pre-parse transform that runs on the raw input before - * the derived Zod schema validates it. The walker emits - * `z.preprocess(fn, inner)` when this annotation is present. - * - * Models zod's `z.preprocess(fn, schema)` pattern — useful when the schema - * needs to inspect the user's raw input (e.g. to capture insertion order) - * before `Schema.Struct` canonicalises the object. - * - * TODO: This exists to paper over a missing Effect Schema feature. The - * parser canonicalises open struct output (known fields first in - * declaration order, then catchall fields) before any user-defined - * transform sees the value, and there is no pre-parse hook — so the - * user's original property insertion order is gone by the time - * `Schema.decodeTo` or `middlewareDecoding` runs. - * - * That canonicalisation is a reasonable default, but `config/permission.ts` - * encodes rule precedence in the user's JSON key order (`evaluate.ts` - * uses `findLast`, so later entries win), which the canonicalisation - * silently destroys. - * - * The cleanest upstream fix would be either: - * - * 1. A `preserveInputOrder` option on `Schema.Struct` / - * `Schema.StructWithRest` that keeps the input's insertion order in - * the parsed object (opt-in; canonical order stays default). - * 2. A generic pre-parse hook (`Schema.preprocess(schema, fn)` or a - * transformation whose decode receives the raw `unknown`). - * - * Either of those would let us delete `ZodPreprocess` and the - * `__originalKeys` hack. Alternatively, the permission model could move - * to specificity-based precedence (exact keys beat wildcards) or an - * explicit ordered array of rules, which removes the ordering - * dependency at the data-model level. - */ -export const ZodPreprocess: unique symbol = Symbol.for("effect-zod/preprocess") - // AST nodes are immutable and frequently shared across schemas (e.g. a single // Schema.Class embedded in multiple parents). Memoizing by node identity // avoids rebuilding equivalent Zod subtrees and keeps derived children stable @@ -85,11 +48,9 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) const base = hasTransform ? encoded(ast) : body(ast) const checked = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base - const preprocess = (ast.annotations as { [ZodPreprocess]?: (val: unknown) => unknown } | undefined)?.[ZodPreprocess] - const out = preprocess ? z.preprocess(preprocess, checked) : checked const desc = SchemaAST.resolveDescription(ast) const ref = SchemaAST.resolveIdentifier(ast) - const described = desc ? out.describe(desc) : out + const described = desc ? checked.describe(desc) : checked return ref ? described.meta({ ref }) : described } diff --git a/packages/opencode/src/util/error.ts b/packages/opencode/src/util/error.ts index 75fef9fc9a0..fbda2dc50e0 100644 --- a/packages/opencode/src/util/error.ts +++ b/packages/opencode/src/util/error.ts @@ -26,6 +26,10 @@ export function errorMessage(error: unknown): string { return error.message } + if (isRecord(error) && isRecord(error.data) && typeof error.data.message === "string" && error.data.message) { + return error.data.message + } + const text = String(error) if (text && text !== "[object Object]") return text diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index 6c4d455224e..38b7e1b7ed8 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -1,7 +1,9 @@ import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises" import { createWriteStream, existsSync, statSync } from "fs" import { realpathSync } from "fs" -import { dirname, join, relative, resolve as pathResolve, win32 } from "path" +// kilocode_change start - harden containment checks +import { dirname, isAbsolute, join, relative, resolve as pathResolve, sep, win32 } from "path" +// kilocode_change end import { Readable } from "stream" import { pipeline } from "stream/promises" import { Glob } from "@opencode-ai/shared/util/glob" @@ -162,7 +164,10 @@ export function overlaps(a: string, b: string) { } export function contains(parent: string, child: string) { - return !relative(parent, child).startsWith("..") + // kilocode_change start - reject cross-drive and escaped relative paths + const rel = relative(parent, child) + return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`)) + // kilocode_change end } export async function findUp( diff --git a/packages/opencode/src/util/named-schema-error.ts b/packages/opencode/src/util/named-schema-error.ts new file mode 100644 index 00000000000..e144f2f9064 --- /dev/null +++ b/packages/opencode/src/util/named-schema-error.ts @@ -0,0 +1,54 @@ +import { Schema } from "effect" +import z from "zod" +import { zod } from "@/util/effect-zod" + +/** + * Create a Schema-backed NamedError-shaped class. + * + * Drop-in replacement for `NamedError.create(tag, zodShape)` but backed by + * `Schema.Struct` under the hood. The wire shape emitted by the derived + * `.Schema` is still `{ name: tag, data: {...fields} }` so the generated + * OpenAPI/SDK output is byte-identical to the original NamedError schema. + * + * Preserves the existing surface: + * - static `Schema` (Zod schema of the wire shape) + * - static `isInstance(x)` + * - instance `toObject()` returning `{ name, data }` + * - `new X({ ...data }, { cause })` + */ +export function namedSchemaError(tag: Tag, fields: Fields) { + // Wire shape matches the original NamedError output so the SDK stays stable. + const dataSchema = Schema.Struct(fields) + const wire = z + .object({ + name: z.literal(tag), + data: zod(dataSchema), + }) + .meta({ ref: tag }) + + type Data = Schema.Schema.Type + + class NamedSchemaError extends Error { + static readonly Schema = wire + static readonly tag = tag + public static isInstance(input: unknown): input is NamedSchemaError { + return typeof input === "object" && input !== null && "name" in input && (input as { name: unknown }).name === tag + } + + public override readonly name: Tag = tag + public readonly data: Data + + constructor(data: Data, options?: ErrorOptions) { + super(tag, options) + this.data = data + } + + toObject(): { name: Tag; data: Data } { + return { name: tag, data: this.data } + } + } + + Object.defineProperty(NamedSchemaError, "name", { value: tag }) + + return NamedSchemaError +} diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index bbebeaa4965..4fe5e475270 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -354,9 +354,11 @@ export const layer: Layer.Layer< } function cleanDirectory(target: string) { + const retries = process.platform === "win32" ? 30 : 5 // kilocode_change - Windows may release git worktree handles slowly + const delay = process.platform === "win32" ? 250 : 100 // kilocode_change return Effect.promise(() => import("fs/promises") - .then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })) + .then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: retries, retryDelay: delay })) // kilocode_change .catch((error) => { const message = errorMessage(error) throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" }) diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index ab4c7516193..544d3e9ebf3 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -591,7 +591,7 @@ test("legacy tools config converts to permissions", async () => { }) }) -test("legacy tools config maps write/edit/patch/multiedit to edit permission", async () => { +test("legacy tools config maps write/edit/patch to edit permission", async () => { await using tmp = await tmpdir({ config: { agent: { diff --git a/packages/opencode/test/cli/pr.test.ts b/packages/opencode/test/cli/pr.test.ts new file mode 100644 index 00000000000..6101784d5c5 --- /dev/null +++ b/packages/opencode/test/cli/pr.test.ts @@ -0,0 +1,60 @@ +// kilocode_change - new file +import { expect, test } from "bun:test" +import { cliCommand } from "../../src/cli/cmd/pr" + +test("cliCommand uses the current script when argv[1] is a file path", () => { + const result = cliCommand({ + execPath: "/usr/bin/node", + argv: ["/usr/bin/node", "/tmp/kilo.js", "pr", "1"], + exists: (file) => file === "/tmp/kilo.js", + }) + + expect(result).toEqual(["/usr/bin/node", "/tmp/kilo.js"]) +}) + +test("cliCommand falls back to execPath when argv[1] is a subcommand", () => { + const result = cliCommand({ + execPath: "/usr/local/bin/kilo", + argv: ["/usr/local/bin/kilo", "pr", "1"], + exists: () => false, + }) + + expect(result).toEqual(["/usr/local/bin/kilo"]) +}) + +test("cliCommand ignores subcommand token even when it exists on disk", () => { + const result = cliCommand({ + execPath: "/usr/local/bin/kilo", + argv: ["/usr/local/bin/kilo", "pr", "1"], + exists: (file) => file === "pr", + }) + + expect(result).toEqual(["/usr/local/bin/kilo"]) +}) + +test("cliCommand falls back to execPath when argv[1] is missing", () => { + const result = cliCommand({ + execPath: "/usr/local/bin/kilo", + argv: ["/usr/local/bin/kilo"], + exists: () => false, + }) + + expect(result).toEqual(["/usr/local/bin/kilo"]) +}) + +test("cliCommand falls back to execPath for bun virtual script paths", () => { + const unix = cliCommand({ + execPath: "/tmp/kilo", + argv: ["/tmp/kilo", "/$bunfs/root/src/index.js", "pr", "1"], + exists: () => true, + }) + + const win = cliCommand({ + execPath: "C:/tmp/kilo.exe", + argv: ["C:/tmp/kilo.exe", "B:/~BUN/root/src/index.js", "pr", "1"], + exists: () => true, + }) + + expect(unix).toEqual(["/tmp/kilo"]) + expect(win).toEqual(["C:/tmp/kilo.exe"]) +}) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 9b72865e8a7..ba8345aefec 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1494,35 +1494,6 @@ test("migrates legacy patch tool to edit permission", async () => { }) }) -test("migrates legacy multiedit tool to edit permission", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Filesystem.write( - path.join(dir, "kilo.json"), - JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", - agent: { - test: { - tools: { - multiedit: false, - }, - }, - }, - }), - ) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await load() - expect(config.agent?.["test"]?.permission).toEqual({ - edit: "deny", - }) - }, - }) -}) - test("migrates mixed legacy tools config", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -1591,11 +1562,19 @@ test("merges legacy tools with existing permission config", async () => { }) }) -// kilocode_change start — isolate from global config to prevent cross-test contamination -// (migrateBashPermission may write permission.bash to a global config file created by other -// test files running in parallel, which mergeDeep then prepends to the project permission keys) -test("permission config preserves key order", async () => { +test("permission config canonicalises known keys first, preserves rest-key insertion order", async () => { + // ConfigPermission.Info is a StructWithRest schema — the decoder reorders + // keys into declaration-order for known permission names (edit, read, + // todowrite, external_directory are declared in `config/permission.ts`), + // followed by rest keys in the user's insertion order. + // + // Rule precedence is NOT affected by this reordering: `Permission.fromConfig` + // sorts wildcards before specifics before iterating. See the + // "fromConfig - specific key beats wildcard regardless of JSON key order" + // test in test/permission/next.test.ts for the behavioural guarantee. // kilocode_change start — isolate from global config to prevent cross-test contamination + // (migrateBashPermission may write permission.bash to a global config file created by other + // test files running in parallel, which mergeDeep then prepends to the project permission keys) await using globalTmp = await tmpdir() const prev = Global.Path.config ;(Global.Path as { config: string }).config = globalTmp.path @@ -1629,12 +1608,15 @@ test("permission config preserves key order", async () => { fn: async () => { const config = await load() expect(Object.keys(config.permission!)).toEqual([ - "*", - "edit", - "write", - "external_directory", + // known fields that the user provided, in declaration order from + // config/permission.ts (read, edit, ..., external_directory, todowrite) "read", + "edit", + "external_directory", "todowrite", + // rest keys (not in the known list), in user's insertion order + "*", + "write", "thoughts_*", "reasoning_model_*", "tools_*", @@ -1649,7 +1631,6 @@ test("permission config preserves key order", async () => { } // kilocode_change end }) -// kilocode_change end // MCP config merging tests @@ -2339,7 +2320,7 @@ describe("KILO_CONFIG_CONTENT token substitution", () => { test("parseManagedPlist strips MDM metadata keys", async () => { const config = ConfigParse.schema( - Config.Info, + Config.Info.zod, ConfigParse.jsonc( await ConfigManaged.parseManagedPlist( JSON.stringify({ @@ -2367,7 +2348,7 @@ test("parseManagedPlist strips MDM metadata keys", async () => { test("parseManagedPlist parses server settings", async () => { const config = ConfigParse.schema( - Config.Info, + Config.Info.zod, ConfigParse.jsonc( await ConfigManaged.parseManagedPlist( JSON.stringify({ @@ -2387,7 +2368,7 @@ test("parseManagedPlist parses server settings", async () => { test("parseManagedPlist parses permission rules", async () => { const config = ConfigParse.schema( - Config.Info, + Config.Info.zod, ConfigParse.jsonc( await ConfigManaged.parseManagedPlist( JSON.stringify({ @@ -2417,7 +2398,7 @@ test("parseManagedPlist parses permission rules", async () => { test("parseManagedPlist parses enabled_providers", async () => { const config = ConfigParse.schema( - Config.Info, + Config.Info.zod, ConfigParse.jsonc( await ConfigManaged.parseManagedPlist( JSON.stringify({ @@ -2434,7 +2415,7 @@ test("parseManagedPlist parses enabled_providers", async () => { test("parseManagedPlist handles empty config", async () => { const config = ConfigParse.schema( - Config.Info, + Config.Info.zod, ConfigParse.jsonc( await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })), "test:mobileconfig", diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts index 36992917ee9..49360d10b01 100644 --- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -169,7 +169,9 @@ describe("cross-spawn spawner", () => { 'process.stderr.write("stderr\\n", done)', ].join("\n"), ) - const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)]) + const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], { + concurrency: 2, + }) expect(stdout).toBe("stdout") expect(stderr).toBe("stderr") }), diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index 21dbc75b954..19d76e386de 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -676,7 +676,8 @@ describe("file/index Filesystem patterns", () => { }) }) - describe("search()", () => { + // kilocode_change - skip on windows: address windows ci failures #9496 + describe.skipIf(process.platform === "win32")("search()", () => { async function setupSearchableRepo() { const tmp = await tmpdir({ git: true }) await fs.writeFile(path.join(tmp.path, "index.ts"), "code", "utf-8") @@ -893,7 +894,8 @@ describe("file/index Filesystem patterns", () => { }) }) - describe("InstanceState isolation", () => { + // kilocode_change - skip on windows: address windows ci failures #9496 + describe.skipIf(process.platform === "win32")("InstanceState isolation", () => { test("two directories get independent file caches", async () => { await using one = await tmpdir({ git: true }) await using two = await tmpdir({ git: true }) diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts index 2bf4e899bd0..0c5788c7362 100644 --- a/packages/opencode/test/file/ripgrep.test.ts +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -9,7 +9,8 @@ import { Ripgrep } from "../../src/file/ripgrep" const run = (effect: Effect.Effect) => effect.pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) -describe("file.ripgrep", () => { +// kilocode_change - skip on windows: address windows ci failures #9496 +describe.skipIf(process.platform === "win32")("file.ripgrep", () => { test("defaults to include hidden", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/fixture/lsp/fake-lsp-server.js b/packages/opencode/test/fixture/lsp/fake-lsp-server.js index be62f96f38f..e6818009e1f 100644 --- a/packages/opencode/test/fixture/lsp/fake-lsp-server.js +++ b/packages/opencode/test/fixture/lsp/fake-lsp-server.js @@ -1,7 +1,23 @@ // Simple JSON-RPC 2.0 LSP-like fake server over stdio -// Implements a minimal LSP handshake and triggers a request upon notification let nextId = 1 +let readBuffer = Buffer.alloc(0) +let lastChange = null +let initializeParams = null +let diagnosticRequestCount = 0 +let registeredCapability = false +const pendingClientRequests = new Map() +let pullConfig = { + delayMs: 0, + registerOn: undefined, + registrations: [], + documentDiagnostics: [], + documentDiagnosticsByIdentifier: {}, + documentDelayMsByIdentifier: {}, + workspaceDiagnostics: [], + workspaceDiagnosticsByIdentifier: {}, + workspaceDelayMsByIdentifier: {}, +} function encode(message) { const json = JSON.stringify(message) @@ -14,29 +30,19 @@ function decodeFrames(buffer) { let idx while ((idx = buffer.indexOf("\r\n\r\n")) !== -1) { const header = buffer.slice(0, idx).toString("utf8") - const m = /Content-Length:\s*(\d+)/i.exec(header) - const len = m ? parseInt(m[1], 10) : 0 + const match = /Content-Length:\s*(\d+)/i.exec(header) + const length = match ? parseInt(match[1], 10) : 0 const bodyStart = idx + 4 - const bodyEnd = bodyStart + len + const bodyEnd = bodyStart + length if (buffer.length < bodyEnd) break - const body = buffer.slice(bodyStart, bodyEnd).toString("utf8") - results.push(body) + results.push(buffer.slice(bodyStart, bodyEnd).toString("utf8")) buffer = buffer.slice(bodyEnd) } return { messages: results, rest: buffer } } -let readBuffer = Buffer.alloc(0) - -process.stdin.on("data", (chunk) => { - readBuffer = Buffer.concat([readBuffer, chunk]) - const { messages, rest } = decodeFrames(readBuffer) - readBuffer = rest - for (const m of messages) handle(m) -}) - -function send(msg) { - process.stdout.write(encode(msg)) +function send(message) { + process.stdout.write(encode(message)) } function sendRequest(method, params) { @@ -45,6 +51,50 @@ function sendRequest(method, params) { return id } +function sendResponse(id, result) { + send({ jsonrpc: "2.0", id, result }) +} + +function sendNotification(method, params) { + send({ jsonrpc: "2.0", method, params }) +} + +function maybeRegister(method) { + if (pullConfig.registerOn !== method || registeredCapability) return + registeredCapability = true + sendRequest("client/registerCapability", { + registrations: pullConfig.registrations.map((registration, index) => ({ + id: registration.id ?? `pull-${index}`, + method: registration.method ?? "textDocument/diagnostic", + registerOptions: registration.registerOptions ?? registration, + })), + }) +} + +function delayed(id, result, delayMs = pullConfig.delayMs) { + if (!delayMs) { + sendResponse(id, result) + return + } + setTimeout(() => sendResponse(id, result), delayMs) +} + +function diagnosticsForIdentifier(identifier) { + return pullConfig.documentDiagnosticsByIdentifier[identifier] ?? pullConfig.documentDiagnostics +} + +function workspaceDiagnosticsForIdentifier(identifier) { + return pullConfig.workspaceDiagnosticsByIdentifier[identifier] ?? pullConfig.workspaceDiagnostics +} + +function documentDelayForIdentifier(identifier) { + return pullConfig.documentDelayMsByIdentifier[identifier] ?? pullConfig.delayMs +} + +function workspaceDelayForIdentifier(identifier) { + return pullConfig.workspaceDelayMsByIdentifier[identifier] ?? pullConfig.delayMs +} + function handle(raw) { let data try { @@ -52,24 +102,148 @@ function handle(raw) { } catch { return } + + if (typeof data.method === "undefined" && typeof data.id !== "undefined") { + const pending = pendingClientRequests.get(data.id) + if (!pending) return + pendingClientRequests.delete(data.id) + sendResponse(pending, data.result ?? null) + return + } + if (data.method === "initialize") { - send({ jsonrpc: "2.0", id: data.id, result: { capabilities: {} } }) + initializeParams = data.params + sendResponse(data.id, { + capabilities: { + textDocumentSync: { + change: 2, + }, + }, + }) return } - if (data.method === "initialized") { + + if (data.method === "test/get-initialize-params") { + sendResponse(data.id, initializeParams) return } - if (data.method === "workspace/didChangeConfiguration") { + + if (data.method === "test/request-configuration") { + const id = sendRequest("workspace/configuration", data.params) + pendingClientRequests.set(id, data.id) return } + + if (data.method === "initialized" || data.method === "workspace/didChangeConfiguration") { + return + } + + if (data.method === "textDocument/didOpen") { + maybeRegister("didOpen") + return + } + + if (data.method === "textDocument/didChange") { + lastChange = data.params + maybeRegister("didChange") + return + } + if (data.method === "test/trigger") { const method = data.params && data.params.method + if (method === "client/registerCapability") { + sendRequest(method, { + registrations: [ + { + id: "test-diagnostic-registration", + method: "textDocument/diagnostic", + registerOptions: { identifier: "syntax" }, + }, + ], + }) + return + } + if (method === "client/unregisterCapability") { + sendRequest(method, { + unregisterations: [{ id: "test-diagnostic-registration", method: "textDocument/diagnostic" }], + }) + return + } if (method) sendRequest(method, {}) return } - if (typeof data.id !== "undefined") { - // Respond OK to any request from client to keep transport flowing - send({ jsonrpc: "2.0", id: data.id, result: null }) + + if (data.method === "test/configure-pull-diagnostics") { + pullConfig = { + delayMs: data.params?.delayMs ?? 0, + registerOn: data.params?.registerOn, + registrations: data.params?.registrations ?? [], + documentDiagnostics: data.params?.documentDiagnostics ?? [], + documentDiagnosticsByIdentifier: data.params?.documentDiagnosticsByIdentifier ?? {}, + documentDelayMsByIdentifier: data.params?.documentDelayMsByIdentifier ?? {}, + workspaceDiagnostics: data.params?.workspaceDiagnostics ?? [], + workspaceDiagnosticsByIdentifier: data.params?.workspaceDiagnosticsByIdentifier ?? {}, + workspaceDelayMsByIdentifier: data.params?.workspaceDelayMsByIdentifier ?? {}, + } + registeredCapability = false + sendResponse(data.id, null) return } + + if (data.method === "test/register-configured-pull-diagnostics") { + maybeRegister(undefined) + sendResponse(data.id, null) + return + } + + if (data.method === "test/publish-diagnostics") { + sendNotification("textDocument/publishDiagnostics", data.params) + return + } + + if (data.method === "test/get-last-change") { + sendResponse(data.id, lastChange) + return + } + + if (data.method === "test/get-diagnostic-request-count") { + sendResponse(data.id, diagnosticRequestCount) + return + } + + if (data.method === "textDocument/diagnostic") { + diagnosticRequestCount += 1 + delayed( + data.id, + { + kind: "full", + items: diagnosticsForIdentifier(data.params?.identifier ?? ""), + }, + documentDelayForIdentifier(data.params?.identifier ?? ""), + ) + return + } + + if (data.method === "workspace/diagnostic") { + diagnosticRequestCount += 1 + delayed( + data.id, + { + items: workspaceDiagnosticsForIdentifier(data.params?.identifier ?? ""), + }, + workspaceDelayForIdentifier(data.params?.identifier ?? ""), + ) + return + } + + if (typeof data.id !== "undefined") { + sendResponse(data.id, null) + } } + +process.stdin.on("data", (chunk) => { + readBuffer = Buffer.concat([readBuffer, chunk]) + const { messages, rest } = decodeFrames(readBuffer) + readBuffer = rest + for (const message of messages) handle(message) +}) diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 5530e195b26..2f6f235aa16 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -126,6 +126,24 @@ describe("Format", () => { it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void))) + it.live("file() returns false when no formatter runs", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const file = `${dir}/test.txt` + yield* Effect.promise(() => Bun.write(file, "x")) + + const formatted = yield* Format.Service.use((fmt) => fmt.file(file)) + expect(formatted).toBe(false) + }), + { + config: { + formatter: false, + }, + }, + ), + ) + it.live("status() initializes formatter state per directory", () => Effect.gen(function* () { const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), { @@ -219,7 +237,7 @@ describe("Format", () => { yield* Format.Service.use((fmt) => Effect.gen(function* () { yield* fmt.init() - yield* fmt.file(file) + expect(yield* fmt.file(file)).toBe(true) }), ) @@ -229,11 +247,21 @@ describe("Format", () => { config: { formatter: { first: { - command: ["sh", "-c", 'sleep 0.05; v=$(cat "$1"); printf \'%sA\' "$v" > "$1"', "sh", "$FILE"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'A')", + "$FILE", + ], extensions: [".seq"], }, second: { - command: ["sh", "-c", 'v=$(cat "$1"); printf \'%sB\' "$v" > "$1"', "sh", "$FILE"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'B')", + "$FILE", + ], extensions: [".seq"], }, }, diff --git a/packages/opencode/test/kilocode/ask-agent-permissions.test.ts b/packages/opencode/test/kilocode/ask-agent-permissions.test.ts index f02465852ba..606620282a9 100644 --- a/packages/opencode/test/kilocode/ask-agent-permissions.test.ts +++ b/packages/opencode/test/kilocode/ask-agent-permissions.test.ts @@ -222,7 +222,7 @@ describe("Ask agent tool disabled checks", () => { }) test("edit tools are disabled", () => { - const tools = ["edit", "write", "patch", "multiedit"] + const tools = ["edit", "write", "patch"] const result = Permission.disabled(tools, ruleset) for (const tool of tools) { expect(result.has(tool)).toBe(true) diff --git a/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts b/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts new file mode 100644 index 00000000000..0b3f90ad85e --- /dev/null +++ b/packages/opencode/test/kilocode/config/indexing-default-plugin.test.ts @@ -0,0 +1,83 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { Effect, Layer, Option } from "effect" +import { NodeFileSystem, NodePath } from "@effect/platform-node" +import path from "path" +import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect" +import { Account } from "../../../src/account/account" +import { Auth } from "../../../src/auth" +import { Config } from "../../../src/config" +import * as CrossSpawnSpawner from "../../../src/effect/cross-spawn-spawner" +import { Env } from "../../../src/env" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { EffectFlock } from "@opencode-ai/shared/util/effect-flock" +import { Filesystem } from "../../../src/util" +import { Instance } from "../../../src/project/instance" +import { Npm } from "../../../src/npm" +import { tmpdir } from "../../fixture/fixture" + +const infra = CrossSpawnSpawner.defaultLayer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), +) +const emptyAccount = Layer.mock(Account.Service)({ + active: () => Effect.succeed(Option.none()), + activeOrg: () => Effect.succeed(Option.none()), +}) +const emptyAuth = Layer.mock(Auth.Service)({ + all: () => Effect.succeed({}), +}) +const noopNpm = Layer.mock(Npm.Service)({ + install: () => Effect.void, + add: () => Effect.die("not implemented"), + outdated: () => Effect.succeed(false), + which: () => Effect.succeed(Option.none()), +}) +const layer = Config.layer.pipe( + Layer.provide(EffectFlock.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(emptyAuth), + Layer.provide(emptyAccount), + Layer.provideMerge(infra), + Layer.provide(noopNpm), +) + +const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer))) +const clear = (wait = false) => + Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer))) + +describe("kilocode default indexing plugin", () => { + afterEach(async () => { + await Instance.disposeAll() + await clear(true) + }) + + test("does not hard-enable indexing plugin when default plugins are disabled", async () => { + const prev = process.env["KILO_DISABLE_DEFAULT_PLUGINS"] + process.env["KILO_DISABLE_DEFAULT_PLUGINS"] = "true" + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://app.kilo.ai/config.json", + plugin: ["global-plugin-1"], + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await load() + expect(hasIndexingPlugin(config.plugin ?? [])).toBe(false) + }, + }) + } finally { + if (prev === undefined) delete process.env["KILO_DISABLE_DEFAULT_PLUGINS"] + else process.env["KILO_DISABLE_DEFAULT_PLUGINS"] = prev + } + }) +}) diff --git a/packages/opencode/test/kilocode/config/opentelemetry-default.test.ts b/packages/opencode/test/kilocode/config/opentelemetry-default.test.ts index f8245f3ea4d..cd07dec7885 100644 --- a/packages/opencode/test/kilocode/config/opentelemetry-default.test.ts +++ b/packages/opencode/test/kilocode/config/opentelemetry-default.test.ts @@ -4,27 +4,27 @@ import { Config } from "../../../src/config" describe("Config.Info experimental.openTelemetry default", () => { test("defaults to true when experimental is set without openTelemetry", () => { - const parsed = Config.Info.parse({ experimental: {} }) + const parsed = Config.Info.zod.parse({ experimental: {} }) expect(parsed.experimental?.openTelemetry).toBe(true) }) test("defaults to true when openTelemetry is explicitly undefined", () => { - const parsed = Config.Info.parse({ experimental: { openTelemetry: undefined } }) + const parsed = Config.Info.zod.parse({ experimental: { openTelemetry: undefined } }) expect(parsed.experimental?.openTelemetry).toBe(true) }) test("respects explicit false", () => { - const parsed = Config.Info.parse({ experimental: { openTelemetry: false } }) + const parsed = Config.Info.zod.parse({ experimental: { openTelemetry: false } }) expect(parsed.experimental?.openTelemetry).toBe(false) }) test("respects explicit true", () => { - const parsed = Config.Info.parse({ experimental: { openTelemetry: true } }) + const parsed = Config.Info.zod.parse({ experimental: { openTelemetry: true } }) expect(parsed.experimental?.openTelemetry).toBe(true) }) test("experimental stays undefined when not set at all", () => { - const parsed = Config.Info.parse({}) + const parsed = Config.Info.zod.parse({}) expect(parsed.experimental).toBeUndefined() }) }) diff --git a/packages/opencode/test/kilocode/cost-propagation.test.ts b/packages/opencode/test/kilocode/cost-propagation.test.ts new file mode 100644 index 00000000000..343be816d00 --- /dev/null +++ b/packages/opencode/test/kilocode/cost-propagation.test.ts @@ -0,0 +1,94 @@ +// Verifies KiloCostPropagation.propagate() serializes concurrent writes to +// the same parent assistant message. Without the internal lock, parallel +// subagent completions race on read-modify-write and lose deltas (#6321). + +import { afterEach, describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Bus } from "../../src/bus" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { KiloCostPropagation } from "../../src/kilocode/session/cost-propagation" +import { Instance } from "../../src/project/instance" +import { ProviderID, ModelID } from "../../src/provider/schema" +import { Session } from "../../src/session" +import { MessageV2 } from "../../src/session/message-v2" +import { MessageID } from "../../src/session/schema" +import { Log } from "../../src/util" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +Log.init({ print: false }) + +afterEach(async () => { + await Instance.disposeAll() +}) + +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +const it = testEffect(Layer.mergeAll(Session.defaultLayer, Bus.layer, CrossSpawnSpawner.defaultLayer)) + +const seed = Effect.fn("CostPropagationTest.seed")(function* () { + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "parent" }) + const user = yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: chat.id, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + const assistant: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: user.id, + sessionID: chat.id, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + } + yield* sessions.updateMessage(assistant) + return { chat, assistant } +}) + +describe("KiloCostPropagation.propagate", () => { + it.live("sums deltas correctly under parallel execution", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const deltas = [0.01, 0.02, 0.04, 0.08, 0.16, 0.32, 0.64, 1.28] + yield* Effect.all( + deltas.map((d) => KiloCostPropagation.propagate(sessions, chat.id, assistant.id, d)), + { concurrency: "unbounded" }, + ) + const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + expect(parent.info.role).toBe("assistant") + if (parent.info.role !== "assistant") return + const total = deltas.reduce((a, b) => a + b, 0) + expect(parent.info.cost).toBeCloseTo(total, 6) + }), + ), + ) + + it.live("is a no-op when amount is non-positive", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + yield* KiloCostPropagation.propagate(sessions, chat.id, assistant.id, 0) + yield* KiloCostPropagation.propagate(sessions, chat.id, assistant.id, -1.5) + const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + if (parent.info.role !== "assistant") return + expect(parent.info.cost).toBe(0) + }), + ), + ) +}) diff --git a/packages/opencode/test/kilocode/custom-provider-delete.test.ts b/packages/opencode/test/kilocode/custom-provider-delete.test.ts index 7b19c8431a3..c6ae0f27720 100644 --- a/packages/opencode/test/kilocode/custom-provider-delete.test.ts +++ b/packages/opencode/test/kilocode/custom-provider-delete.test.ts @@ -14,7 +14,7 @@ import { KilocodeConfig } from "../../src/kilocode/config/config" describe("Config.Info — null sentinels for custom provider deletes", () => { it("accepts a null model value inside a provider", () => { - const parsed = Config.Info.safeParse({ + const parsed = Config.Info.zod.safeParse({ provider: { myprovider: { name: "My Provider", @@ -28,7 +28,7 @@ describe("Config.Info — null sentinels for custom provider deletes", () => { }) it("accepts a null variant value inside a model", () => { - const parsed = Config.Info.safeParse({ + const parsed = Config.Info.zod.safeParse({ provider: { myprovider: { name: "My Provider", diff --git a/packages/opencode/test/kilocode/external-directory-boundary.test.ts b/packages/opencode/test/kilocode/external-directory-boundary.test.ts new file mode 100644 index 00000000000..ea04fd71d53 --- /dev/null +++ b/packages/opencode/test/kilocode/external-directory-boundary.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import path from "path" +import type { Permission } from "../../src/permission" +import { Instance } from "../../src/project/instance" +import { SessionID, MessageID } from "../../src/session/schema" +import { assertExternalDirectory } from "../../src/tool/external-directory" +import type { Tool } from "../../src/tool" +import { Filesystem } from "../../src/util" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { tmpdir } from "../fixture/fixture" + +const base: Omit = { + sessionID: SessionID.make("ses_test-boundary-session"), + messageID: MessageID.make(""), + callID: "", + agent: "code", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, +} + +const glob = (p: string) => + process.platform === "win32" ? AppFileSystem.normalizePathPattern(p) : p.replaceAll("\\", "/") + +const asks = () => { + const items: Array> = [] + const ctx: Tool.Context = { + ...base, + ask: (req) => + Effect.sync(() => { + items.push(req) + }), + } + return { items, ctx } +} + +describe("kilocode external directory boundaries", () => { + test("asks before accessing outside a repo-root session", async () => { + await using repo = await tmpdir({ git: true }) + await using outer = await tmpdir() + const file = path.join(outer.path, "outside.txt") + const { items, ctx } = asks() + + await Instance.provide({ + directory: repo.path, + fn: async () => { + try { + await assertExternalDirectory(ctx, file) + } finally { + await Instance.dispose() + } + }, + }) + + const ext = items.find((item) => item.permission === "external_directory") + expect(ext).toBeDefined() + expect(ext!.patterns).toEqual([glob(path.join(outer.path, "*"))]) + expect(ext!.always).toEqual([glob(path.join(outer.path, "*"))]) + expect(ext!.metadata).toMatchObject({ filepath: file, parentDir: outer.path }) + }) + + test("asks when the instance directory is a filesystem root", async () => { + await using outer = await tmpdir() + const root = path.parse(outer.path).root + const file = path.join(outer.path, "outside-root.txt") + const { items, ctx } = asks() + + await Instance.provide({ + directory: root, + fn: async () => { + try { + await assertExternalDirectory(ctx, file) + } finally { + await Instance.dispose() + } + }, + }) + + const ext = items.find((item) => item.permission === "external_directory") + expect(ext).toBeDefined() + expect(ext!.patterns).toEqual([glob(path.join(outer.path, "*"))]) + expect(ext!.metadata).toMatchObject({ filepath: file, parentDir: outer.path }) + }) + + test("contains helpers keep dot-prefixed child names internal", () => { + expect(Filesystem.contains("/project", "/project/..cache/file")).toBe(true) + expect(AppFileSystem.contains("/a/b", "/a/b/..cache/file")).toBe(true) + }) + + test("AppFileSystem.contains rejects cross-drive paths on Windows", () => { + if (process.platform !== "win32") return + expect(AppFileSystem.contains("C:\\repo", "D:\\outside\\file.txt")).toBe(false) + }) +}) diff --git a/packages/opencode/test/kilocode/indexing-feature.test.ts b/packages/opencode/test/kilocode/indexing-feature.test.ts new file mode 100644 index 00000000000..52dbbfe6a15 --- /dev/null +++ b/packages/opencode/test/kilocode/indexing-feature.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test" +import { + ensureIndexingPlugin, + indexingEnabled, + INDEXING_PLUGIN, + resolveIndexingPlugin, +} from "../../src/kilocode/indexing-feature" + +describe("indexing plugin helpers", () => { + test("detects plugin-enabled configs", () => { + expect(indexingEnabled({ plugin: ["global-plugin"] })).toBe(false) + expect(indexingEnabled({ plugin: [INDEXING_PLUGIN] })).toBe(false) + expect(indexingEnabled({ plugin: [INDEXING_PLUGIN], experimental: { semantic_indexing: false } })).toBe(false) + expect(indexingEnabled({ plugin: [INDEXING_PLUGIN], experimental: { semantic_indexing: true } })).toBe(true) + expect(indexingEnabled({ plugin: ["@kilocode/kilo-indexing@1.0.0"], experimental: { semantic_indexing: true } })).toBe( + true, + ) + }) + + test("adds indexing plugin when present but missing from config", () => { + const list = ensureIndexingPlugin(["global-plugin"], INDEXING_PLUGIN) + expect(list).toContain("global-plugin") + expect(list).toContain(INDEXING_PLUGIN) + }) + + test("does not add duplicate indexing plugin", () => { + const list = ensureIndexingPlugin(["@kilocode/kilo-indexing@1.0.0"], INDEXING_PLUGIN) + expect(list).toEqual(["@kilocode/kilo-indexing@1.0.0"]) + }) + + test("skips hard-enable when plugin package is unavailable", () => { + const list = ensureIndexingPlugin(["global-plugin"], undefined) + expect(list).toEqual(["global-plugin"]) + }) + + test("falls back to package marker when resolver fails", () => { + const plugin = resolveIndexingPlugin({ + resolve() { + throw new Error("missing") + }, + }) + expect(plugin).toBe(INDEXING_PLUGIN) + }) +}) diff --git a/packages/opencode/test/kilocode/indexing-label.test.ts b/packages/opencode/test/kilocode/indexing-label.test.ts new file mode 100644 index 00000000000..b07efb4cc98 --- /dev/null +++ b/packages/opencode/test/kilocode/indexing-label.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test" +import { formatIndexingLabel } from "../../src/kilocode/indexing-label" + +describe("indexing label", () => { + test("formats in-progress status with counts", () => { + expect( + formatIndexingLabel({ + state: "In Progress", + message: "", + processedFiles: 21, + totalFiles: 50, + percent: 42, + }), + ).toBe("IDX 42% 21/50") + }) + + test("formats indeterminate in-progress status without 0/0 counts", () => { + expect( + formatIndexingLabel({ + state: "In Progress", + message: "", + processedFiles: 0, + totalFiles: 0, + percent: 0, + }), + ).toBe("IDX In Progress") + }) + + test("formats error status with backend message", () => { + expect( + formatIndexingLabel({ + state: "Error", + message: "Indexing failed.", + processedFiles: 0, + totalFiles: 0, + percent: 0, + }), + ).toBe("IDX Indexing failed.") + }) + + test("formats stable states", () => { + expect( + formatIndexingLabel({ + state: "Complete", + message: "", + processedFiles: 1, + totalFiles: 1, + percent: 100, + }), + ).toBe("IDX Complete") + expect( + formatIndexingLabel({ + state: "Disabled", + message: "Indexing disabled.", + processedFiles: 0, + totalFiles: 0, + percent: 0, + }), + ).toBe("IDX Disabled") + expect( + formatIndexingLabel({ + state: "Standby", + message: "Ready.", + processedFiles: 0, + totalFiles: 0, + percent: 0, + }), + ).toBe("IDX Standby") + }) +}) diff --git a/packages/opencode/test/kilocode/indexing-startup.test.ts b/packages/opencode/test/kilocode/indexing-startup.test.ts new file mode 100644 index 00000000000..c2ace8ace79 --- /dev/null +++ b/packages/opencode/test/kilocode/indexing-startup.test.ts @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test" +import { CodeIndexManager } from "@kilocode/kilo-indexing/engine" +import { Hono } from "hono" +import type { Config } from "../../src/config" +import { KiloIndexing } from "../../src/kilocode/indexing" +import { Instance } from "../../src/project/instance" +import { IndexingRoutes } from "../../src/kilocode/server/routes/indexing" +import { Log } from "../../src/util" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +const cfg: Partial = { + plugin: ["@kilocode/kilo-indexing"], + experimental: { + semantic_indexing: true, + }, + indexing: { + enabled: true, + provider: "ollama", + vectorStore: "qdrant", + ollama: { + baseUrl: "http://127.0.0.1:1", + }, + }, +} + +const off: Partial = { + plugin: ["@kilocode/kilo-indexing"], + experimental: { + semantic_indexing: false, + }, + indexing: { + enabled: true, + provider: "ollama", + vectorStore: "qdrant", + ollama: { + baseUrl: "http://127.0.0.1:1", + }, + }, +} +const configDir = process.env["KILO_CONFIG_DIR"] +const error = new Error("test indexing initialization failed") + +afterEach(async () => { + if (configDir === undefined) delete process.env["KILO_CONFIG_DIR"] + else process.env["KILO_CONFIG_DIR"] = configDir + await Instance.disposeAll() +}) + +describe("indexing startup degradation", () => { + test("keeps server routes alive when indexing initialization fails", async () => { + const init = spyOn(CodeIndexManager.prototype, "initialize").mockRejectedValue(error) + + await using tmp = await tmpdir({ git: true, config: cfg }) + process.env["KILO_CONFIG_DIR"] = tmp.path + + try { + const app = new Hono().route("/indexing", IndexingRoutes()) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const status = await app.request("/indexing/status") + expect(status.status).toBe(200) + + const body = await status.json() + expect(body).toMatchObject({ + state: "Error", + }) + expect(body.message).toContain("Failed to initialize: test indexing initialization failed") + }, + }) + } finally { + init.mockRestore() + } + }) + + test("keeps degraded indexing queryable but unavailable", async () => { + const init = spyOn(CodeIndexManager.prototype, "initialize").mockRejectedValue(error) + + await using tmp = await tmpdir({ git: true, config: cfg }) + process.env["KILO_CONFIG_DIR"] = tmp.path + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const status = await KiloIndexing.current() + + expect(status.state).toBe("Error") + expect(status.message).toContain("Failed to initialize: test indexing initialization failed") + expect(await KiloIndexing.available()).toBe(false) + expect(KiloIndexing.ready()).toBe(false) + expect(await KiloIndexing.search("boot failure")).toEqual([]) + }, + }) + } finally { + init.mockRestore() + } + }) + + test("reports not ready while initialization is in flight", async () => { + await using tmp = await tmpdir({ git: true, config: cfg }) + process.env["KILO_CONFIG_DIR"] = tmp.path + const gate = Promise.withResolvers<{ requiresRestart: boolean }>() + const init = spyOn(CodeIndexManager.prototype, "initialize").mockImplementation(() => gate.promise) + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const boot = KiloIndexing.init() + await new Promise((resolve, reject) => { + const start = performance.now() + const poll = () => { + if (init.mock.calls.length > 0) return resolve() + if (performance.now() - start > 5000) return reject(new Error("indexing initialization did not start")) + setTimeout(poll, 10) + } + poll() + }) + + expect(init).toHaveBeenCalled() + expect(KiloIndexing.ready()).toBe(false) + gate.resolve({ requiresRestart: false }) + await boot + }, + }) + } finally { + gate.resolve({ requiresRestart: false }) + init.mockRestore() + } + }) + + test("stays disabled when semantic indexing flag is off", async () => { + await using tmp = await tmpdir({ git: true, config: off }) + process.env["KILO_CONFIG_DIR"] = tmp.path + const init = spyOn(CodeIndexManager.prototype, "initialize") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const status = await KiloIndexing.current() + + expect(status).toMatchObject({ + state: "Disabled", + message: "Semantic indexing is disabled. Enable it in the Experimental settings.", + }) + expect(await KiloIndexing.available()).toBe(false) + expect(KiloIndexing.ready()).toBe(false) + expect(await KiloIndexing.search("flag off")).toEqual([]) + expect(init).not.toHaveBeenCalled() + }, + }) + }) +}) diff --git a/packages/opencode/test/kilocode/indexing-worktree.test.ts b/packages/opencode/test/kilocode/indexing-worktree.test.ts new file mode 100644 index 00000000000..0ad2c1eee25 --- /dev/null +++ b/packages/opencode/test/kilocode/indexing-worktree.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import type { Config } from "../../src/config" +import { AppRuntime } from "../../src/effect/app-runtime" +import { KiloIndexing } from "../../src/kilocode/indexing" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +const cfg: Partial = { + plugin: ["@kilocode/kilo-indexing"], + experimental: { + semantic_indexing: true, + }, + indexing: { + enabled: true, + provider: "ollama", + vectorStore: "qdrant", + ollama: { + baseUrl: "http://127.0.0.1:1", + }, + }, +} + +const configDir = process.env["KILO_CONFIG_DIR"] + +afterEach(async () => { + if (configDir === undefined) delete process.env["KILO_CONFIG_DIR"] + else process.env["KILO_CONFIG_DIR"] = configDir + await Instance.disposeAll() +}) + +describe("indexing worktree disable", () => { + test("returns disabled status in .kilo/worktrees paths", async () => { + await using tmp = await tmpdir({ git: true, config: cfg }) + process.env["KILO_CONFIG_DIR"] = tmp.path + const dir = `${tmp.path}/.kilo/worktrees/feature` + await mkdir(dir, { recursive: true }) + + await Instance.provide({ + directory: dir, + init: () => AppRuntime.runPromise(InstanceBootstrap), + fn: async () => { + const status = await KiloIndexing.current() + + expect(status.state).toBe("Disabled") + expect(status.message).toBe("Indexing is disabled in worktree sessions. Use the main workspace for indexing.") + expect(await KiloIndexing.available()).toBe(false) + expect(KiloIndexing.ready()).toBe(false) + expect(await KiloIndexing.search("worktree")).toEqual([]) + }, + }) + }) + + test("returns disabled status in legacy .kilocode/worktrees paths", async () => { + await using tmp = await tmpdir({ git: true, config: cfg }) + process.env["KILO_CONFIG_DIR"] = tmp.path + const dir = `${tmp.path}/.kilocode/worktrees/feature` + await mkdir(dir, { recursive: true }) + + await Instance.provide({ + directory: dir, + init: () => AppRuntime.runPromise(InstanceBootstrap), + fn: async () => { + const status = await KiloIndexing.current() + + expect(status.state).toBe("Disabled") + expect(status.message).toBe("Indexing is disabled in worktree sessions. Use the main workspace for indexing.") + expect(await KiloIndexing.available()).toBe(false) + expect(KiloIndexing.ready()).toBe(false) + }, + }) + }) +}) diff --git a/packages/opencode/test/kilocode/kilo-ripgrep-stream.test.ts b/packages/opencode/test/kilocode/kilo-ripgrep-stream.test.ts deleted file mode 100644 index 6df605cb9bc..00000000000 --- a/packages/opencode/test/kilocode/kilo-ripgrep-stream.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { KiloRipgrepStream } from "../../src/kilocode/kilo-ripgrep-stream" - -describe("KiloRipgrepStream", () => { - test("drains lines without splitting UTF-8 characters", () => { - const icon = "\u{1f600}" - const bytes = Buffer.from(`src/${icon}.ts\nnext.ts\n`) - const decoder = KiloRipgrepStream.decoder() - const lines: string[] = [] - - const first = KiloRipgrepStream.drain(decoder, "", bytes.subarray(0, 5), (line) => lines.push(line)) - const rest = KiloRipgrepStream.drain(decoder, first, bytes.subarray(5), (line) => lines.push(line)) + decoder.end() - - if (rest) lines.push(rest) - - expect(lines).toEqual([`src/${icon}.ts`, "next.ts"]) - }) -}) diff --git a/packages/opencode/test/kilocode/lancedb-runtime.test.ts b/packages/opencode/test/kilocode/lancedb-runtime.test.ts new file mode 100644 index 00000000000..edbc8954c45 --- /dev/null +++ b/packages/opencode/test/kilocode/lancedb-runtime.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test" + +const entry = "file:///tmp/kilo-cache/node_modules/@lancedb/lancedb/dist/index.js" +const add = mock(async () => ({ directory: "/tmp/kilo-cache", entrypoint: entry })) +const real = await import("../../src/npm/index") + +mock.module("../../src/npm/index", () => ({ + ...real, + Npm: { + ...real.Npm, + add, + }, +})) + +const env = "KILO_LANCEDB_PATH" +const prev = process.env[env] + +describe("LanceDBRuntime", () => { + beforeEach(async () => { + const { LanceDBRuntime } = await import("../../src/kilocode/lancedb") + LanceDBRuntime.clear() + add.mockClear() + add.mockImplementation(async () => ({ directory: "/tmp/kilo-cache", entrypoint: entry })) + }) + + afterEach(async () => { + const { LanceDBRuntime } = await import("../../src/kilocode/lancedb") + LanceDBRuntime.clear() + if (prev === undefined) delete process.env[env] + if (prev !== undefined) process.env[env] = prev + }) + + test("skips installation for non-lancedb backends", async () => { + const { LanceDBRuntime } = await import("../../src/kilocode/lancedb") + + await LanceDBRuntime.ensure("qdrant") + + expect(add).not.toHaveBeenCalled() + expect(process.env[env]).toBeUndefined() + }) + + test("installs the pinned package and exports a file URL for lancedb", async () => { + const { LanceDBRuntime } = await import("../../src/kilocode/lancedb") + + await LanceDBRuntime.ensure("lancedb") + + expect(add).toHaveBeenCalledWith("@lancedb/lancedb@0.26.2") + expect(process.env[env]).toBe(entry) + }) + + test("exposes every LanceDB package that must stay external to bun compile", async () => { + const { LanceDBRuntime } = await import("../../src/kilocode/lancedb") + + expect(LanceDBRuntime.external).toEqual([ + "@lancedb/lancedb", + "@lancedb/lancedb-darwin-arm64", + "@lancedb/lancedb-linux-arm64-gnu", + "@lancedb/lancedb-linux-arm64-musl", + "@lancedb/lancedb-linux-x64-gnu", + "@lancedb/lancedb-linux-x64-musl", + "@lancedb/lancedb-win32-arm64-msvc", + "@lancedb/lancedb-win32-x64-msvc", + ]) + }) + + test("skips install when runtime path is already set", async () => { + const { LanceDBRuntime } = await import("../../src/kilocode/lancedb") + process.env[env] = "file:///already/set.js" + + await LanceDBRuntime.ensure("lancedb") + + expect(add).not.toHaveBeenCalled() + expect(process.env[env]).toBe("file:///already/set.js") + }) + + test("dedupes concurrent ensure calls", async () => { + const { LanceDBRuntime } = await import("../../src/kilocode/lancedb") + + await Promise.all([ + LanceDBRuntime.ensure("lancedb"), + LanceDBRuntime.ensure("lancedb"), + LanceDBRuntime.ensure("lancedb"), + ]) + + expect(add).toHaveBeenCalledTimes(1) + expect(process.env[env]).toBe(entry) + }) + + test("surfaces install failures without swallowing them", async () => { + const { LanceDBRuntime } = await import("../../src/kilocode/lancedb") + add.mockImplementationOnce(async () => { + throw new Error("registry unavailable") + }) + + await expect(LanceDBRuntime.ensure("lancedb")).rejects.toThrow("registry unavailable") + expect(process.env[env]).toBeUndefined() + }) + + test("retries after a failed install", async () => { + const { LanceDBRuntime } = await import("../../src/kilocode/lancedb") + add + .mockImplementationOnce(async () => { + throw new Error("install failed") + }) + .mockImplementationOnce(async () => ({ directory: "/tmp/kilo-cache", entrypoint: entry })) + + await expect(LanceDBRuntime.ensure("lancedb")).rejects.toThrow("install failed") + expect(process.env[env]).toBeUndefined() + expect(add).toHaveBeenCalledTimes(1) + + await LanceDBRuntime.ensure("lancedb") + + expect(add).toHaveBeenCalledTimes(2) + expect(process.env[env]).toBe(entry) + }) +}) diff --git a/packages/opencode/test/kilocode/patch.test.ts b/packages/opencode/test/kilocode/patch.test.ts new file mode 100644 index 00000000000..8dc13ab8713 --- /dev/null +++ b/packages/opencode/test/kilocode/patch.test.ts @@ -0,0 +1,172 @@ +// Tests the kilocode-specific patch module guarantees: +// - Files retain their original encoding after an update (UTF-8 BOM, UTF-16, +// legacy single-byte, CJK). +// - Plain UTF-8 files do not gain a spurious BOM. +// - Moved files keep the original encoding at the new path. +// These round-trip through Patch.applyPatch directly so we exercise the +// encoding + BOM integration in patch/index.ts without the tool stack. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { tmpdir } from "os" +import iconv from "iconv-lite" +import { Patch } from "../../src/patch" + +const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]) +const UTF16_LE_BOM = Buffer.from([0xff, 0xfe]) + +describe("Patch encoding preservation", () => { + let dir: string + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(tmpdir(), "kilo-patch-")) + }) + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + test("preserves UTF-8 BOM through update", async () => { + const file = path.join(dir, "doc.txt") + await fs.writeFile(file, Buffer.concat([UTF8_BOM, Buffer.from("line 1\nline 2\n", "utf-8")])) + + const patch = `*** Begin Patch +*** Update File: ${file} +@@ + line 1 +-line 2 ++line 2 updated +*** End Patch` + + await Patch.applyPatch(patch) + + const bytes = await fs.readFile(file) + expect(bytes.subarray(0, 3).equals(UTF8_BOM)).toBe(true) + expect(bytes.subarray(3).toString("utf-8")).toBe("line 1\nline 2 updated\n") + }) + + test("does not introduce BOM for plain UTF-8 files", async () => { + const file = path.join(dir, "plain.txt") + await fs.writeFile(file, "line 1\nline 2\n", "utf-8") + + const patch = `*** Begin Patch +*** Update File: ${file} +@@ + line 1 +-line 2 ++line 2 updated +*** End Patch` + + await Patch.applyPatch(patch) + + const bytes = await fs.readFile(file) + expect(bytes[0]).not.toBe(0xef) + expect(bytes.toString("utf-8")).toBe("line 1\nline 2 updated\n") + }) + + test("preserves UTF-16 LE encoding through update", async () => { + const file = path.join(dir, "utf16.txt") + await fs.writeFile(file, Buffer.concat([UTF16_LE_BOM, iconv.encode("line 1\nline 2\n", "utf-16le")])) + + const patch = `*** Begin Patch +*** Update File: ${file} +@@ + line 1 +-line 2 ++line 2 updated +*** End Patch` + + await Patch.applyPatch(patch) + + const bytes = await fs.readFile(file) + expect(bytes.subarray(0, 2).equals(UTF16_LE_BOM)).toBe(true) + expect(iconv.decode(bytes.subarray(2), "utf-16le")).toBe("line 1\nline 2 updated\n") + }) + + test("preserves iso-8859-1 encoding through update", async () => { + const file = path.join(dir, "latin1.txt") + await fs.writeFile(file, iconv.encode("café\nñandú\n", "iso-8859-1")) + + const patch = `*** Begin Patch +*** Update File: ${file} +@@ + café +-ñandú ++águila +*** End Patch` + + await Patch.applyPatch(patch) + + const bytes = await fs.readFile(file) + expect(iconv.decode(bytes, "iso-8859-1")).toBe("café\náguila\n") + // á and ñ are two bytes in UTF-8, one byte in ISO-8859-1. If the file had + // been silently re-encoded as UTF-8 the byte length would differ. + expect(bytes.length).toBe("café\náguila\n".length) + }) + + test("preserves Shift_JIS encoding through update", async () => { + const file = path.join(dir, "jp.txt") + // jschardet needs enough characteristic bytes to identify Shift_JIS. A + // single 19-byte phrase looks like windows-1252, so the sample is padded + // to match the body of Japanese text the tool tests already rely on. + const sample = "こんにちは、世界!日本語のテストです。" + await fs.writeFile(file, iconv.encode(`line1\n${sample}\nline3\n`, "Shift_JIS")) + + const patch = `*** Begin Patch +*** Update File: ${file} +@@ + line1 +-${sample} ++さようなら、世界! + line3 +*** End Patch` + + await Patch.applyPatch(patch) + + const bytes = await fs.readFile(file) + expect(iconv.decode(bytes, "Shift_JIS")).toBe("line1\nさようなら、世界!\nline3\n") + const utf8Rendered = Buffer.from("line1\nさようなら、世界!\nline3\n", "utf-8") + expect(bytes.equals(utf8Rendered)).toBe(false) + }) + + test("preserves UTF-8 BOM when file is moved", async () => { + const from = path.join(dir, "old.txt") + const to = path.join(dir, "new.txt") + await fs.writeFile(from, Buffer.concat([UTF8_BOM, Buffer.from("original\n", "utf-8")])) + + const patch = `*** Begin Patch +*** Update File: ${from} +*** Move to: ${to} +@@ +-original ++updated +*** End Patch` + + await Patch.applyPatch(patch) + + const moved = await fs.readFile(to) + expect(moved.subarray(0, 3).equals(UTF8_BOM)).toBe(true) + expect(moved.subarray(3).toString("utf-8")).toBe("updated\n") + + const oldExists = await fs + .access(from) + .then(() => true) + .catch(() => false) + expect(oldExists).toBe(false) + }) + + test("new files added via patch are written as plain UTF-8", async () => { + const file = path.join(dir, "new.txt") + const patch = `*** Begin Patch +*** Add File: ${file} ++hello world +*** End Patch` + + await Patch.applyPatch(patch) + + const bytes = await fs.readFile(file) + expect(bytes[0]).not.toBe(0xef) + expect(bytes.toString("utf-8")).toBe("hello world") + }) +}) diff --git a/packages/opencode/test/kilocode/semantic-search.test.ts b/packages/opencode/test/kilocode/semantic-search.test.ts new file mode 100644 index 00000000000..f535ad988bb --- /dev/null +++ b/packages/opencode/test/kilocode/semantic-search.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, test, spyOn } from "bun:test" +import path from "path" +import { Effect, Layer, ManagedRuntime } from "effect" +import { Agent } from "../../src/agent/agent" +import { SemanticSearchTool } from "../../src/kilocode/tool/semantic-search" +import { KiloIndexing } from "../../src/kilocode/indexing" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import type { Permission } from "../../src/permission" +import { SessionID, MessageID } from "../../src/session/schema" +import { Tool, Truncate } from "../../src/tool" + +const rt = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer)) + +async function initTool() { + return rt.runPromise( + Effect.gen(function* () { + const info = yield* SemanticSearchTool + return yield* Tool.init(info) + }), + ) +} + +const baseCtx = { + sessionID: SessionID.make("ses_test-semantic-search"), + messageID: MessageID.make(""), + callID: "", + agent: "code", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} satisfies Tool.Context + +describe("tool.semantic_search", () => { + test("throws when query is empty", async () => { + const tool = await initTool() + expect(rt.runPromise(tool.execute({ query: "" }, baseCtx))).rejects.toThrow("query is required") + }) + + test("asks permission and forwards normalized relative path to indexing search", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const requests: Array> = [] + const search = spyOn(KiloIndexing, "search").mockResolvedValue([]) + + try { + const tool = await initTool() + const result = await rt.runPromise( + tool.execute( + { + query: "authentication middleware", + path: "./src/../src/tool", + }, + { + ...baseCtx, + ask: (req: Omit) => { + requests.push(req) + return Effect.void + }, + }, + ), + ) + + expect(requests).toHaveLength(1) + expect(requests[0]?.permission).toBe("semantic_search") + expect(requests[0]?.metadata).toEqual({ + query: "authentication middleware", + path: "./src/../src/tool", + }) + expect(search).toHaveBeenCalledWith("authentication middleware", path.normalize("src/tool")) + expect(result.output).toBe('No relevant code found for "authentication middleware" in src/tool.') + } finally { + search.mockRestore() + } + }, + }) + }) + + test("searches entire workspace when path is omitted", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const search = spyOn(KiloIndexing, "search").mockResolvedValue([]) + + try { + const tool = await initTool() + const result = await rt.runPromise(tool.execute({ query: "database connection" }, baseCtx)) + + expect(search).toHaveBeenCalledWith("database connection", undefined) + expect(result.output).toBe('No relevant code found for "database connection".') + expect(result.metadata.results).toEqual([]) + } finally { + search.mockRestore() + } + }, + }) + }) + + test("formats and normalizes search results", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const search = spyOn(KiloIndexing, "search").mockResolvedValue([ + { + id: "1", + score: 0.812345, + payload: { + filePath: "src\\auth\\index.ts", + codeChunk: "export const verify = () => true", + startLine: 10, + endLine: 18, + }, + }, + { + id: "2", + score: 0.7, + payload: { + filePath: "src/invalid.ts", + codeChunk: 123, + startLine: 1, + endLine: 2, + }, + }, + { + id: "3", + score: 0.6, + payload: null, + }, + ] as never) + + try { + const tool = await initTool() + const result = await rt.runPromise(tool.execute({ query: "verify token" }, baseCtx)) + + expect(result.metadata.results).toEqual([ + { + filePath: "src/auth/index.ts", + score: 0.812345, + startLine: 10, + endLine: 18, + codeChunk: "export const verify = () => true", + }, + ]) + expect(result.output).toContain('Found 1 result for "verify token".') + expect(result.output).toContain("1. src/auth/index.ts:10-18 (score 0.8123)") + expect(result.output).toContain("export const verify = () => true") + } finally { + search.mockRestore() + } + }, + }) + }) + + test("rejects paths outside the workspace", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const search = spyOn(KiloIndexing, "search").mockResolvedValue([]) + + try { + const tool = await initTool() + expect(rt.runPromise(tool.execute({ query: "auth", path: "../outside" }, baseCtx))).rejects.toThrow( + "path must be within the current workspace: ../outside", + ) + expect(search).not.toHaveBeenCalled() + } finally { + search.mockRestore() + } + }, + }) + }) +}) diff --git a/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts b/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts index b2e5b8e48e0..276edaefa40 100644 --- a/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts +++ b/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts @@ -183,6 +183,77 @@ describe("session processor empty tool-calls", () => { ), ) + it.live("ignores deleted session during cost reconciliation", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const test = yield* TestLLM + const processors = yield* SessionProcessor.Service + const session = yield* Session.Service + + yield* test.reply( + { type: "start" }, + { type: "start-step" } as LLM.Event, + { + type: "finish-step", + finishReason: "stop", + usage: usage(), + providerMetadata: undefined, + } as LLM.Event, + { type: "finish" } as LLM.Event, + ) + + const chat = yield* session.create({}) + const parent = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: chat.id, + agent: "code", + model: ref, + time: { created: Date.now() }, + }) + const msg: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID: chat.id, + parentID: parent.id, + mode: "code", + agent: "code", + path: { cwd: path.resolve(dir), root: path.resolve(dir) }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + } + yield* session.updateMessage(msg) + + const mdl = model() + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + yield* session.remove(chat.id) + + const input: LLM.StreamInput = { + user: parent as MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: { name: "code", mode: "primary", permission: [], options: {} } as any, + system: [], + messages: [], + tools: {}, + } + + const result = yield* handle.process(input) + expect(result).toBe("continue") + expect(handle.message.error).toBeUndefined() + }), + { git: true }, + ), + ) + it.live("preserves tool-calls finish when tool parts exist", () => provideTmpdirInstance( (dir) => diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 954f77234df..865b5749a3d 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -63,6 +63,21 @@ function hasText(msg: Awaited>, text: st return msg.parts.some((part) => part.type === "text" && part.text.includes(text)) } +// Find the last non-system message in an OpenAI-compatible request body. Kept +// tolerant: we only care about role invariants, not the exact content shape, +// because providers may serialize `content` as a string or as a parts array. +function lastConversational(body: Record): { role: string; content: unknown } | undefined { + const msgs = Array.isArray(body.messages) ? (body.messages as Array>) : [] + for (let i = msgs.length - 1; i >= 0; i--) { + const msg = msgs[i] + if (!msg || typeof msg !== "object") continue + const role = typeof msg.role === "string" ? msg.role : undefined + if (!role || role === "system") continue + return { role, content: msg.content } + } + return undefined +} + function user(sessionID: SessionID, id: MessageID): MessageV2.WithParts { return { info: { @@ -303,18 +318,21 @@ describe("session prompt queue", () => { const ready = Promise.withResolvers() const injected = Promise.withResolvers() const calls: number[] = [] + const bodies: Array> = [] const server = Bun.serve({ port: 0, - fetch(req) { + async fetch(req) { const url = new URL(req.url) if (!url.pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 }) + const body = (await req.json().catch(() => ({}))) as Record + bodies.push(body) calls.push(Date.now()) - const body = + const stream = calls.length === 1 ? reply({ text: "first reply", ready: ready.resolve }) : reply({ text: "second reply", ready: injected.resolve }) - return new Response(body, { + return new Response(stream, { status: 200, headers: { "Content-Type": "text/event-stream" }, }) @@ -408,6 +426,18 @@ describe("session prompt queue", () => { } expect(firstReply.info.parentID).toBe(firstUser.info.id) expect(secondReply.info.parentID).toBe(secondUser.info.id) + + // Regression for #9492: the second LLM request must end with the + // queued user prompt, not an assistant tail from the prior turn. + // Anthropic's API rejects requests whose final message is assistant + // (prefill), and scope() is supposed to partition the queued target + // turn to the end before the model request is built. + expect(bodies).toHaveLength(2) + const second2 = bodies[1] + expect(JSON.stringify(second2)).toContain("second prompt") + const tail = lastConversational(second2) + expect(tail?.role).toBe("user") + expect(JSON.stringify(tail?.content)).toContain("second prompt") }, }) } finally { diff --git a/packages/opencode/test/kilocode/stats-subagent-cost.test.ts b/packages/opencode/test/kilocode/stats-subagent-cost.test.ts new file mode 100644 index 00000000000..f4e5d311006 --- /dev/null +++ b/packages/opencode/test/kilocode/stats-subagent-cost.test.ts @@ -0,0 +1,123 @@ +// Verifies `kilo stats` does not double-count subagent cost while still +// including child-session messages, tokens, tools, and model usage. The task +// tool propagates each child session's total cost up to the parent's +// tool-wrapper assistant message (#6321). + +import { afterEach, describe, expect, test } from "bun:test" +import { aggregateSessionStats } from "../../src/cli/cmd/stats" +import { MessageV2 } from "../../src/session/message-v2" +import { Instance } from "../../src/project/instance" +import { ProviderID, ModelID } from "../../src/provider/schema" +import { Session } from "../../src/session" +import { MessageID, PartID } from "../../src/session/schema" +import { Log } from "../../src/util" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +afterEach(async () => { + await Instance.disposeAll() +}) + +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), +} + +function assistant(sessionID: string, parentID: string, cost: number): MessageV2.Assistant { + return { + id: MessageID.ascending(), + role: "assistant", + parentID: parentID as any, + sessionID: sessionID as any, + mode: "build", + agent: "build", + cost, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + } +} + +async function step(sessionID: string, messageID: string, cost: number) { + await Session.updatePart({ + id: PartID.ascending(), + messageID: messageID as any, + sessionID: sessionID as any, + type: "step-finish", + reason: "stop", + cost, + tokens: { total: 15, input: 10, output: 5, reasoning: 0, cache: { read: 0, write: 0 } }, + }) +} + +async function tool(sessionID: string, messageID: string) { + const time = Date.now() + await Session.updatePart({ + id: PartID.ascending(), + messageID: messageID as any, + sessionID: sessionID as any, + type: "tool", + callID: "call_1", + tool: "bash", + state: { + status: "completed", + input: {}, + output: "ok", + title: "bash", + metadata: {}, + time: { start: time, end: time }, + }, + }) +} + +describe("stats subagent cost", () => { + test("counts child usage without double-counting propagated cost", async () => { + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const parent = await Session.create({ title: "root" }) + const child = await Session.create({ parentID: parent.id, title: "subagent" }) + + const userMsg = await Session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: parent.id, + agent: "build", + model: ref, + time: { created: Date.now() }, + } as any) + const parentMsg = await Session.updateMessage(assistant(parent.id, userMsg.id, 1.5)) + await step(parent.id, parentMsg.id, 1) + + const childUser = await Session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: child.id, + agent: "general", + model: ref, + time: { created: Date.now() }, + } as any) + const childMsg = await Session.updateMessage(assistant(child.id, childUser.id, 0.5)) + await step(child.id, childMsg.id, 0.5) + await tool(child.id, childMsg.id) + + const stats = await aggregateSessionStats() + const model = stats.modelUsage["test/test-model"]! + expect(stats.totalCost).toBeCloseTo(1.5, 6) + expect(stats.totalSessions).toBe(2) + expect(stats.totalMessages).toBe(4) + expect(stats.totalTokens.input).toBe(20) + expect(stats.totalTokens.output).toBe(10) + expect(stats.toolUsage.bash).toBe(1) + expect(model.messages).toBe(2) + expect(model.tokens.input).toBe(20) + expect(model.tokens.output).toBe(10) + expect(model.cost).toBeCloseTo(1.5, 6) + }, + }) + }) +}) diff --git a/packages/opencode/test/kilocode/summary-file-diff.test.ts b/packages/opencode/test/kilocode/summary-file-diff.test.ts new file mode 100644 index 00000000000..c481716553c --- /dev/null +++ b/packages/opencode/test/kilocode/summary-file-diff.test.ts @@ -0,0 +1,28 @@ +import { test, expect } from "bun:test" +import { Snapshot } from "../../src/snapshot" + +test("SummaryFileDiff does not contain the `patch` field", () => { + const keys = Object.keys(Snapshot.SummaryFileDiff.fields) + expect(keys).not.toContain("patch") + expect(keys.sort()).toEqual(["additions", "deletions", "file", "status"]) +}) + +test("SummaryFileDiff parse strips `patch` when present on input", () => { + const full = { + file: "a.txt", + patch: "@@ -1 +1 @@\n-old\n+new\n", + additions: 1, + deletions: 1, + status: "modified" as const, + } + const parsed = Snapshot.SummaryFileDiff.zod.parse(full) + expect(parsed).not.toHaveProperty("patch") + expect(parsed).toEqual({ file: "a.txt", additions: 1, deletions: 1, status: "modified" }) +}) + +test("SummaryFileDiff differs from FileDiff by exactly `patch`", () => { + const full = new Set(Object.keys(Snapshot.FileDiff.fields)) + const summary = new Set(Object.keys(Snapshot.SummaryFileDiff.fields)) + expect([...full].filter((k) => !summary.has(k))).toEqual(["patch"]) + expect([...summary].filter((k) => !full.has(k))).toEqual([]) +}) diff --git a/packages/opencode/test/kilocode/tool-registry-indexing.test.ts b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts new file mode 100644 index 00000000000..d81e02758ce --- /dev/null +++ b/packages/opencode/test/kilocode/tool-registry-indexing.test.ts @@ -0,0 +1,56 @@ +import { afterEach, describe, expect, spyOn } from "bun:test" +import { Effect, Layer } from "effect" +import { KiloIndexing } from "../../src/kilocode/indexing" +import { ToolRegistry } from "../../src/tool" +import { Instance } from "../../src/project/instance" +import { provideTmpdirInstance } from "../fixture/fixture" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { testEffect } from "../lib/effect" + +const node = CrossSpawnSpawner.defaultLayer +const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) + +afterEach(async () => { + await Instance.disposeAll() +}) + +describe("kilocode tool registry indexing", () => { + it.live("omits semantic_search without waiting for slow indexing startup", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const avail = spyOn(KiloIndexing, "available").mockImplementation(() => new Promise(() => {})) + + try { + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + + expect(ids).not.toContain("semantic_search") + expect(avail).not.toHaveBeenCalled() + } finally { + avail.mockRestore() + } + }), + { git: true }, + ), + ) + + it.live("registers semantic_search when indexing is ready", () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const ready = spyOn(KiloIndexing, "ready").mockReturnValue(true) + + try { + const registry = yield* ToolRegistry.Service + const ids = yield* registry.ids() + + expect(ids).toContain("semantic_search") + } finally { + ready.mockRestore() + } + }), + { git: true }, + ), + ) +}) diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index d6eaa317f94..4862f683949 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -1,11 +1,12 @@ -import { describe, expect, test, beforeEach } from "bun:test" +import { beforeEach, describe, expect, test } from "bun:test" import path from "path" +import { pathToFileURL } from "url" +import { tmpdir } from "../fixture/fixture" import { LSPClient } from "../../src/lsp" import { LSPServer } from "../../src/lsp" import { Instance } from "../../src/project/instance" import { Log } from "../../src/util" -// Minimal fake LSP server that speaks JSON-RPC over stdio function spawnFakeServer() { const { spawn } = require("child_process") const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") @@ -39,10 +40,8 @@ describe("LSPClient interop", () => { method: "workspace/workspaceFolders", }) - await new Promise((r) => setTimeout(r, 100)) - + await new Promise((resolve) => setTimeout(resolve, 100)) expect(client.connection).toBeDefined() - await client.shutdown() }) @@ -64,10 +63,8 @@ describe("LSPClient interop", () => { method: "client/registerCapability", }) - await new Promise((r) => setTimeout(r, 100)) - + await new Promise((resolve) => setTimeout(resolve, 100)) expect(client.connection).toBeDefined() - await client.shutdown() }) @@ -89,10 +86,397 @@ describe("LSPClient interop", () => { method: "client/unregisterCapability", }) - await new Promise((r) => setTimeout(r, 100)) - + await new Promise((resolve) => setTimeout(resolve, 100)) expect(client.connection).toBeDefined() + await client.shutdown() + }) + + test("initialize does not overclaim unsupported diagnostics capabilities", async () => { + const handle = spawnFakeServer() as any + + const client = await Instance.provide({ + directory: process.cwd(), + fn: () => + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + directory: process.cwd(), + }), + }) + + const params = await client.connection.sendRequest("test/get-initialize-params", {}) + expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false) + expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false) await client.shutdown() }) + + test("workspace/configuration returns one result per requested item", async () => { + const handle = spawnFakeServer() as any + const initialization = { + alpha: { + beta: 1, + }, + gamma: true, + } + + const client = await Instance.provide({ + directory: process.cwd(), + fn: () => + LSPClient.create({ + serverID: "fake", + server: { + ...(handle as unknown as LSPServer.Handle), + initialization, + }, + root: process.cwd(), + directory: process.cwd(), + }), + }) + + const response = await client.connection.sendRequest("test/request-configuration", { + items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}], + }) + + expect(response).toEqual([{ beta: 1 }, 1, null, initialization]) + + await client.shutdown() + }) + + test("sends ranged didChange for incremental sync servers", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.ts") + await Bun.write(file, "first\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + await client.notify.open({ path: file }) + await Bun.write(file, "second\nthird\n") + await client.notify.open({ path: file }) + + const change = await client.connection.sendRequest<{ + textDocument: { version: number } + contentChanges: { + range?: { start: { line: number; character: number }; end: { line: number; character: number } } + text: string + }[] + }>("test/get-last-change", {}) + expect(change.textDocument.version).toBe(1) + expect(change.contentChanges).toEqual([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 0 }, + }, + text: "second\nthird\n", + }, + ]) + + await client.shutdown() + }, + }) + }) + + test("document mode falls back to push diagnostics", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.ts") + await Bun.write(file, "const x = 1\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + const version = await client.notify.open({ path: file }) + const wait = client.waitForDiagnostics({ path: file, version, mode: "document" }) + await client.connection.sendNotification("test/publish-diagnostics", { + uri: pathToFileURL(file).href, + version, + diagnostics: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "push diagnostic", + severity: 1, + }, + ], + }) + await wait + + const diagnostics = client.diagnostics.get(file) ?? [] + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]?.message).toBe("push diagnostic") + + const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {}) + expect(count).toBe(0) + + await client.shutdown() + }, + }) + }) + + test("document mode accepts matching push diagnostics published before waiting", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.ts") + await Bun.write(file, "const x = 1\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + const version = await client.notify.open({ path: file }) + await client.connection.sendNotification("test/publish-diagnostics", { + uri: pathToFileURL(file).href, + version, + diagnostics: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "push diagnostic", + severity: 1, + }, + ], + }) + + for (let i = 0; i < 20 && (client.diagnostics.get(file)?.length ?? 0) === 0; i++) { + await new Promise((resolve) => setTimeout(resolve, 25)) + } + + expect(client.diagnostics.get(file)?.[0]?.message).toBe("push diagnostic") + + const started = Date.now() + await client.waitForDiagnostics({ path: file, version, mode: "document" }) + expect(Date.now() - started).toBeLessThan(1_000) + + await client.shutdown() + }, + }) + }) + + test("document mode waits for pull diagnostics", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + await Bun.write(file, "class C {}\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registerOn: "didOpen", + registrations: [{ identifier: "DocumentCompilerSemantic" }], + documentDiagnosticsByIdentifier: { + DocumentCompilerSemantic: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "pull diagnostic", + severity: 1, + }, + ], + }, + }) + + const version = await client.notify.open({ path: file }) + await client.waitForDiagnostics({ path: file, version, mode: "document" }) + + const diagnostics = client.diagnostics.get(file) ?? [] + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]?.message).toBe("pull diagnostic") + + const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {}) + expect(count).toBeGreaterThan(0) + + await client.shutdown() + }, + }) + }) + + test("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + await Bun.write(file, "class C {}\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registrations: [{ identifier: "fast" }, { identifier: "slow" }], + documentDiagnosticsByIdentifier: { + fast: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "fast diagnostic", + severity: 1, + }, + ], + slow: [], + }, + documentDelayMsByIdentifier: { + slow: 2_500, + }, + }) + + const version = await client.notify.open({ path: file }) + await client.connection.sendRequest("test/register-configured-pull-diagnostics", {}) + await new Promise((resolve) => setTimeout(resolve, 100)) + const started = Date.now() + await client.waitForDiagnostics({ path: file, version, mode: "document" }) + + expect(Date.now() - started).toBeLessThan(1_000) + expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic") + expect(await client.connection.sendRequest("test/get-diagnostic-request-count", {})).toBeGreaterThan(1) + + await client.shutdown() + }, + }) + }) + + test("full mode includes workspace pull diagnostics", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + const related = path.join(tmp.path, "other.cs") + await Bun.write(file, "class C {}\n") + await Bun.write(related, "class D {}\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registerOn: "didOpen", + registrations: [ + { identifier: "DocumentCompilerSemantic" }, + { identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }, + ], + documentDiagnosticsByIdentifier: { + DocumentCompilerSemantic: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "current file", + severity: 1, + }, + ], + }, + workspaceDiagnosticsByIdentifier: { + WorkspaceDocumentsAndProject: [ + { + uri: pathToFileURL(related).href, + items: [ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + message: "workspace file", + severity: 1, + }, + ], + }, + ], + }, + }) + + const version = await client.notify.open({ path: file }) + await client.waitForDiagnostics({ path: file, version, mode: "full" }) + + expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file") + expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file") + + await client.shutdown() + }, + }) + }) + + test("full mode treats an empty workspace pull response as handled", async () => { + const handle = spawnFakeServer() as any + await using tmp = await tmpdir() + const file = path.join(tmp.path, "client.cs") + await Bun.write(file, "class C {}\n") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const client = await LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: tmp.path, + directory: tmp.path, + }) + + await client.connection.sendRequest("test/configure-pull-diagnostics", { + registerOn: "didOpen", + registrations: [{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }], + workspaceDiagnosticsByIdentifier: { + WorkspaceDocumentsAndProject: [], + }, + }) + + const version = await client.notify.open({ path: file }) + const started = Date.now() + await client.waitForDiagnostics({ path: file, version, mode: "full" }) + + expect(Date.now() - started).toBeLessThan(1_000) + + await client.shutdown() + }, + }) + }) }) diff --git a/packages/opencode/test/npm.test.ts b/packages/opencode/test/npm.test.ts index 61e3ca6ddf0..a8ec92c2a7b 100644 --- a/packages/opencode/test/npm.test.ts +++ b/packages/opencode/test/npm.test.ts @@ -1,7 +1,18 @@ +import fs from "fs/promises" +import path from "path" import { describe, expect, test } from "bun:test" import { Npm } from "../src/npm" +import { tmpdir } from "./fixture/fixture" const win = process.platform === "win32" +const writePackage = (dir: string, pkg: Record) => + Bun.write( + path.join(dir, "package.json"), + JSON.stringify({ + version: "1.0.0", + ...pkg, + }), + ) describe("Npm.sanitize", () => { test("keeps normal scoped package specs unchanged", () => { @@ -16,3 +27,29 @@ describe("Npm.sanitize", () => { expect(Npm.sanitize(spec)).toBe(expected) }) }) + +describe("Npm.install", () => { + test("respects omit from project .npmrc", async () => { + await using tmp = await tmpdir() + + await writePackage(tmp.path, { + name: "fixture", + dependencies: { + "prod-pkg": "file:./prod-pkg", + }, + devDependencies: { + "dev-pkg": "file:./dev-pkg", + }, + }) + await Bun.write(path.join(tmp.path, ".npmrc"), "omit=dev\n") + await fs.mkdir(path.join(tmp.path, "prod-pkg")) + await fs.mkdir(path.join(tmp.path, "dev-pkg")) + await writePackage(path.join(tmp.path, "prod-pkg"), { name: "prod-pkg" }) + await writePackage(path.join(tmp.path, "dev-pkg"), { name: "dev-pkg" }) + + await Npm.install(tmp.path) + + await expect(fs.stat(path.join(tmp.path, "node_modules", "prod-pkg"))).resolves.toBeDefined() + await expect(fs.stat(path.join(tmp.path, "node_modules", "dev-pkg"))).rejects.toThrow() + }) +}) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 9eaef2f77f4..ce8faa9cc66 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -142,6 +142,67 @@ test("fromConfig - does not expand tilde in middle of path", () => { expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }]) }) +// Top-level wildcard-vs-specific precedence semantics. +// +// fromConfig sorts top-level keys so wildcard permissions (containing "*") +// come before specific permissions. Combined with `findLast` in evaluate(), +// this gives the intuitive semantic "specific tool rules override the `*` +// fallback", regardless of the order the user wrote the keys in their JSON. +// +// Sub-pattern order inside a single permission key (e.g. `bash: { "*": "allow", "rm": "deny" }`) +// still depends on insertion order — only top-level keys are sorted. + +test("fromConfig - specific key beats wildcard regardless of JSON key order", () => { + const wildcardFirst = Permission.fromConfig({ "*": "deny", bash: "allow" }) + const specificFirst = Permission.fromConfig({ bash: "allow", "*": "deny" }) + + // Both orderings produce the same ruleset + expect(wildcardFirst).toEqual(specificFirst) + + // And both evaluate bash → allow (bash rule wins over * fallback) + expect(Permission.evaluate("bash", "ls", wildcardFirst).action).toBe("allow") + expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("allow") +}) + +test("fromConfig - wildcard acts as fallback for permissions with no specific rule", () => { + const ruleset = Permission.fromConfig({ bash: "allow", "*": "ask" }) + expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("ask") + expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow") +}) + +test("fromConfig - top-level ordering: wildcards first, specifics after", () => { + const ruleset = Permission.fromConfig({ + bash: "allow", + "*": "ask", + edit: "deny", + "mcp_*": "allow", + }) + // wildcards (* and mcp_*) come before specifics (bash, edit) + const permissions = ruleset.map((r) => r.permission) + expect(permissions.slice(0, 2).sort()).toEqual(["*", "mcp_*"]) + expect(permissions.slice(2)).toEqual(["bash", "edit"]) +}) + +test("fromConfig - sub-pattern insertion order inside a tool key is preserved (only top-level sorts)", () => { + // Sub-patterns within a single tool key use the documented "`*` first, + // specific patterns after" convention (findLast picks specifics). The + // top-level sort must not touch sub-pattern ordering. + const ruleset = Permission.fromConfig({ bash: { "*": "deny", "git *": "allow" } }) + expect(ruleset.map((r) => r.pattern)).toEqual(["*", "git *"]) + // * fallback for unknown commands + expect(Permission.evaluate("bash", "rm foo", ruleset).action).toBe("deny") + // specific pattern wins for git commands (it's last, findLast picks it) + expect(Permission.evaluate("bash", "git status", ruleset).action).toBe("allow") +}) + +test("fromConfig - canonical documented example unchanged", () => { + // Regression guard for the example in docs/permissions.mdx + const ruleset = Permission.fromConfig({ "*": "ask", bash: "allow", edit: "deny" }) + expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow") + expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("deny") + expect(Permission.evaluate("read", "foo.ts", ruleset).action).toBe("ask") +}) + test("fromConfig - expands exact tilde to home directory", () => { const result = Permission.fromConfig({ external_directory: { "~": "allow" } }) expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }]) @@ -436,9 +497,9 @@ test("disabled - disables tool when denied", () => { expect(result.has("read")).toBe(false) }) -test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () => { +test("disabled - disables edit/write/apply_patch when edit denied", () => { const result = Permission.disabled( - ["edit", "write", "apply_patch", "multiedit", "bash"], + ["edit", "write", "apply_patch", "bash"], [ { permission: "*", pattern: "*", action: "allow" }, { permission: "edit", pattern: "*", action: "deny" }, @@ -447,7 +508,6 @@ test("disabled - disables edit/write/apply_patch/multiedit when edit denied", () expect(result.has("edit")).toBe(true) expect(result.has("write")).toBe(true) expect(result.has("apply_patch")).toBe(true) - expect(result.has("multiedit")).toBe(true) expect(result.has("bash")).toBe(false) }) diff --git a/packages/opencode/test/plugin/meta.test.ts b/packages/opencode/test/plugin/meta.test.ts index d83c59cf80d..afa3dfd91bc 100644 --- a/packages/opencode/test/plugin/meta.test.ts +++ b/packages/opencode/test/plugin/meta.test.ts @@ -50,9 +50,12 @@ describe("plugin.meta", () => { expect(two.state).toBe("same") expect(two.entry.load_count).toBe(2) + // kilocode_change start + // WORKAROUND: bun 1.3.11 fs.utimes produces 32-bit overflow for current-era timestamps. + // Use a real write after a brief sleep so the OS assigns a naturally different mtime. + await Bun.sleep(1100) + // kilocode_change end await Bun.write(tmp.extra.file, "export default async () => ({ ok: true })\n") - const stamp = new Date(Date.now() + 10_000) - await fs.utimes(tmp.extra.file, stamp, stamp) const three = await PluginMeta.touch(spec, spec, "demo.file") expect(three.state).toBe("updated") diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index f1ca5664e64..44b66e08b12 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -278,6 +278,31 @@ describe("Project.discover", () => { expect(updated).toBeDefined() expect(updated!.icon).toBeUndefined() }) + + test("should not discover favicon when override is set", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + + await run((svc) => + svc.update({ + projectID: project.id, + icon: { override: "data:image/png;base64,override" }, + }), + ) + + const updatedProject = await run((svc) => svc.get(project.id)) + if (!updatedProject) throw new Error("Project not found") + + const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + await Bun.write(path.join(tmp.path, "favicon.png"), pngData) + + await run((svc) => svc.discover(updatedProject)) + + const updated = Project.get(project.id) + expect(updated).toBeDefined() + expect(updated!.icon?.override).toBe("data:image/png;base64,override") + expect(updated!.icon?.url).toBeUndefined() + }) }) describe("Project.update", () => { @@ -332,6 +357,23 @@ describe("Project.update", () => { expect(fromDb?.icon?.color).toBe("#ff0000") }) + test("should update icon override", async () => { + await using tmp = await tmpdir({ git: true }) + const { project } = await run((svc) => svc.fromDirectory(tmp.path)) + + const updated = await run((svc) => + svc.update({ + projectID: project.id, + icon: { override: "data:image/png;base64,abc123" }, + }), + ) + + expect(updated.icon?.override).toBe("data:image/png;base64,abc123") + + const fromDb = Project.get(project.id) + expect(fromDb?.icon?.override).toBe("data:image/png;base64,abc123") + }) + test("should update commands", async () => { await using tmp = await tmpdir({ git: true }) const { project } = await run((svc) => svc.fromDirectory(tmp.path)) @@ -389,13 +431,14 @@ describe("Project.update", () => { svc.update({ projectID: project.id, name: "Multi Update", - icon: { url: "https://example.com/favicon.ico", color: "#00ff00" }, + icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" }, commands: { start: "make start" }, }), ) expect(updated.name).toBe("Multi Update") expect(updated.icon?.url).toBe("https://example.com/favicon.ico") + expect(updated.icon?.override).toBe("data:image/png;base64,abc123") expect(updated.icon?.color).toBe("#00ff00") expect(updated.commands?.start).toBe("make start") }) @@ -472,3 +515,89 @@ describe("Project.addSandbox and Project.removeSandbox", () => { expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true) }) }) + +describe("Project.fromDirectory with bare repos", () => { + test("worktree from bare repo should cache in bare repo, not parent", async () => { + await using tmp = await tmpdir({ git: true }) + + const parentDir = path.dirname(tmp.path) + const barePath = path.join(parentDir, `bare-${Date.now()}.git`) + const worktreePath = path.join(parentDir, `worktree-${Date.now()}`) + + try { + await $`git clone --bare ${tmp.path} ${barePath}`.quiet() + await $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet() + + const { project } = await run((svc) => svc.fromDirectory(worktreePath)) + + expect(project.id).not.toBe(ProjectID.global) + expect(project.worktree).toBe(barePath) + + const correctCache = path.join(barePath, "kilo") // kilocode_change + const wrongCache = path.join(parentDir, ".git", "kilo") // kilocode_change + + expect(await Bun.file(correctCache).exists()).toBe(true) + expect(await Bun.file(wrongCache).exists()).toBe(false) + } finally { + await $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow() + } + }) + + test("different bare repos under same parent should not share project ID", async () => { + await using tmp1 = await tmpdir({ git: true }) + await using tmp2 = await tmpdir({ git: true }) + + const parentDir = path.dirname(tmp1.path) + const bareA = path.join(parentDir, `bare-a-${Date.now()}.git`) + const bareB = path.join(parentDir, `bare-b-${Date.now()}.git`) + const worktreeA = path.join(parentDir, `wt-a-${Date.now()}`) + const worktreeB = path.join(parentDir, `wt-b-${Date.now()}`) + + try { + await $`git clone --bare ${tmp1.path} ${bareA}`.quiet() + await $`git clone --bare ${tmp2.path} ${bareB}`.quiet() + await $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet() + await $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet() + + const { project: projA } = await run((svc) => svc.fromDirectory(worktreeA)) + const { project: projB } = await run((svc) => svc.fromDirectory(worktreeB)) + + expect(projA.id).not.toBe(projB.id) + + // kilocode_change start + const cacheA = path.join(bareA, "kilo") + const cacheB = path.join(bareB, "kilo") + const wrongCache = path.join(parentDir, ".git", "kilo") + // kilocode_change end + + expect(await Bun.file(cacheA).exists()).toBe(true) + expect(await Bun.file(cacheB).exists()).toBe(true) + expect(await Bun.file(wrongCache).exists()).toBe(false) + } finally { + await $`rm -rf ${bareA} ${bareB} ${worktreeA} ${worktreeB}`.quiet().nothrow() + } + }) + + test("bare repo without .git suffix is still detected via core.bare", async () => { + await using tmp = await tmpdir({ git: true }) + + const parentDir = path.dirname(tmp.path) + const barePath = path.join(parentDir, `bare-no-suffix-${Date.now()}`) + const worktreePath = path.join(parentDir, `worktree-${Date.now()}`) + + try { + await $`git clone --bare ${tmp.path} ${barePath}`.quiet() + await $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet() + + const { project } = await run((svc) => svc.fromDirectory(worktreePath)) + + expect(project.id).not.toBe(ProjectID.global) + expect(project.worktree).toBe(barePath) + + const correctCache = path.join(barePath, "kilo") // kilocode_change + expect(await Bun.file(correctCache).exists()).toBe(true) + } finally { + await $`rm -rf ${barePath} ${worktreePath}`.quiet().nothrow() + } + }) +}) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 812b5514fbc..060943f9bf3 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -14,6 +14,7 @@ import { Env } from "../../src/env" import { Effect } from "effect" import { AppRuntime } from "../../src/effect/app-runtime" import { makeRuntime } from "../../src/effect/run-service" +import { Auth } from "../../src/auth" // kilocode_change const env = makeRuntime(Env.Service, Env.defaultLayer) const set = (k: string, v: string) => env.runSync((svc) => svc.set(k, v)) @@ -88,6 +89,50 @@ test("provider loaded from env variable", async () => { }) }) +// kilocode_change start +test("provider OAuth auth overrides inherited env variable", async () => { + await Auth.remove("openai") + await Auth.set("openai", { + type: "oauth", + refresh: "test-refresh-token", + access: "test-access-token", + expires: Date.now() + 60_000, + }) + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://app.kilo.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + set("OPENAI_API_KEY", "test-openai-key") + }, + fn: async () => { + const providers = await list() + const provider = providers[ProviderID.openai] + expect(provider).toBeDefined() + if (!provider) throw new Error("Expected OpenAI provider") + expect(provider.source).toBe("custom") + expect(provider.key).toBeUndefined() + expect(Object.values(provider.models).every((model) => model.cost.input === 0 && model.cost.output === 0)).toBe( + true, + ) + }, + }) + } finally { + await Auth.remove("openai") + } +}) +// kilocode_change end + test("provider loaded from config with apiKey option", async () => { await using tmp = await tmpdir({ init: async (dir) => { @@ -2485,7 +2530,12 @@ test("plugin config providers persist after instance dispose", async () => { expect(first[ProviderID.make("demo")]).toBeDefined() expect(first[ProviderID.make("demo")].models[ModelID.make("chat")]).toBeDefined() - await Instance.disposeAll() + // kilocode_change start + await Instance.provide({ + directory: tmp.path, + fn: () => Instance.dispose(), + }) + // kilocode_change end const second = await Instance.provide({ directory: tmp.path, diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index ccaf1460427..cbd7ba4c519 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2114,7 +2114,24 @@ describe("ProviderTransform.variants", () => { expect(result.low).toEqual({ reasoningEffort: "low" }) }) - test("mistral returns empty object", () => { + test("mistral with reasoning returns variants", () => { + const model = createMockModel({ + id: "mistral/mistral-small-latest", + providerID: "mistral", + api: { + id: "mistral-small-latest", + url: "https://api.mistral.com", + npm: "@ai-sdk/mistral", + }, + capabilities: { reasoning: true }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({ + high: { reasoningEffort: "high" }, + }) + }) + + test("mistral without reasoning returns empty object", () => { const model = createMockModel({ id: "mistral/mistral-large", providerID: "mistral", @@ -2123,6 +2140,22 @@ describe("ProviderTransform.variants", () => { url: "https://api.mistral.com", npm: "@ai-sdk/mistral", }, + capabilities: { reasoning: false }, + }) + const result = ProviderTransform.variants(model) + expect(result).toEqual({}) + }) + + test("mistral large with reasoning returns empty object (only small supports reasoning)", () => { + const model = createMockModel({ + id: "mistral/mistral-large", + providerID: "mistral", + api: { + id: "mistral-large-latest", + url: "https://api.mistral.com", + npm: "@ai-sdk/mistral", + }, + capabilities: { reasoning: true }, }) const result = ProviderTransform.variants(model) expect(result).toEqual({}) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 423409fc3dc..a0a8c226c70 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -143,6 +143,45 @@ async function assistant(sessionID: SessionID, parentID: MessageID, root: string return msg } +async function summaryAssistant(sessionID: SessionID, parentID: MessageID, root: string, text: string) { + const msg: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID, + mode: "compaction", + agent: "compaction", + path: { cwd: root, root }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID, + summary: true, + time: { created: Date.now() }, + finish: "end_turn", + } + await svc.updateMessage(msg) + await svc.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg +} + +async function lastCompactionPart(sessionID: SessionID) { + return (await svc.messages({ sessionID })) + .at(-2) + ?.parts.find((item): item is MessageV2.CompactionPart => item.type === "compaction") +} + function fake( input: Parameters[0], result: "continue" | "compact", @@ -167,7 +206,19 @@ function layer(result: "continue" | "compact") { ) } -function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, provider = ProviderTest.fake()) { +function cfg(compaction?: Config.Info["compaction"]) { + const base = Config.Info.zod.parse({}) + return Layer.mock(Config.Service)({ + get: () => Effect.succeed({ ...base, compaction }), + }) +} + +function runtime( + result: "continue" | "compact", + plugin = Plugin.defaultLayer, + provider = ProviderTest.fake(), + config = Config.defaultLayer, +) { const bus = Bus.layer return ManagedRuntime.make( Layer.mergeAll(SessionCompaction.layer, bus).pipe( @@ -177,7 +228,7 @@ function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, p Layer.provide(Agent.defaultLayer), Layer.provide(plugin), Layer.provide(bus), - Layer.provide(Config.defaultLayer), + Layer.provide(config), ), ) } @@ -222,7 +273,7 @@ function llm() { } } -function liveRuntime(layer: Layer.Layer, provider = ProviderTest.fake()) { +function liveRuntime(layer: Layer.Layer, provider = ProviderTest.fake(), config = Config.defaultLayer) { const bus = Bus.layer const status = SessionStatus.layer.pipe(Layer.provide(bus)) const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary)) @@ -237,11 +288,66 @@ function liveRuntime(layer: Layer.Layer, provider = ProviderTest.fa Layer.provide(Plugin.defaultLayer), Layer.provide(status), Layer.provide(bus), - Layer.provide(Config.defaultLayer), + Layer.provide(config), ), ) } +function reply( + text: string, + capture?: (input: LLM.StreamInput) => void, +): (input: LLM.StreamInput) => Stream.Stream { + return (input) => { + capture?.(input) + return Stream.make( + { type: "start" } satisfies LLM.Event, + { type: "text-start", id: "txt-0" } satisfies LLM.Event, + { type: "text-delta", id: "txt-0", delta: text, text } as LLM.Event, + { type: "text-end", id: "txt-0" } satisfies LLM.Event, + { + type: "finish-step", + finishReason: "stop", + rawFinishReason: "stop", + response: { id: "res", modelId: "test-model", timestamp: new Date() }, + providerMetadata: undefined, + usage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, + }, + } satisfies LLM.Event, + { + type: "finish", + finishReason: "stop", + rawFinishReason: "stop", + totalUsage: { + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { + textTokens: undefined, + reasoningTokens: undefined, + }, + }, + } satisfies LLM.Event, + ) + } +} + function wait(ms = 50) { return new Promise((resolve) => setTimeout(resolve, ms)) } @@ -498,65 +604,13 @@ describe("session.compaction.create", () => { describe("session.compaction.prune", () => { it.live( "compacts old completed tool output", - provideTmpdirInstance((dir) => - Effect.gen(function* () { - const compact = yield* SessionCompaction.Service - const ssn = yield* SessionNs.Service - const info = yield* ssn.create({}) - const a = yield* ssn.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: info.id, - agent: "build", - model: ref, - time: { created: Date.now() }, - }) - yield* ssn.updatePart({ - id: PartID.ascending(), - messageID: a.id, - sessionID: info.id, - type: "text", - text: "first", - }) - const b: MessageV2.Assistant = { - id: MessageID.ascending(), - role: "assistant", - sessionID: info.id, - mode: "build", - agent: "build", - path: { cwd: dir, root: dir }, - cost: 0, - tokens: { - output: 0, - input: 0, - reasoning: 0, - cache: { read: 0, write: 0 }, - }, - modelID: ref.modelID, - providerID: ref.providerID, - parentID: a.id, - time: { created: Date.now() }, - finish: "end_turn", - } - yield* ssn.updateMessage(b) - yield* ssn.updatePart({ - id: PartID.ascending(), - messageID: b.id, - sessionID: info.id, - type: "tool", - callID: crypto.randomUUID(), - tool: "bash", - state: { - status: "completed", - input: {}, - output: "x".repeat(200_000), - title: "done", - metadata: {}, - time: { start: Date.now(), end: Date.now() }, - }, - }) - for (const text of ["second", "third"]) { - const msg = yield* ssn.updateMessage({ + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const compact = yield* SessionCompaction.Service + const ssn = yield* SessionNs.Service + const info = yield* ssn.create({}) + const a = yield* ssn.updateMessage({ id: MessageID.ascending(), role: "user", sessionID: info.id, @@ -566,23 +620,82 @@ describe("session.compaction.prune", () => { }) yield* ssn.updatePart({ id: PartID.ascending(), - messageID: msg.id, + messageID: a.id, sessionID: info.id, type: "text", - text, + text: "first", }) - } + const b: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + sessionID: info.id, + mode: "build", + agent: "build", + path: { cwd: dir, root: dir }, + cost: 0, + tokens: { + output: 0, + input: 0, + reasoning: 0, + cache: { read: 0, write: 0 }, + }, + modelID: ref.modelID, + providerID: ref.providerID, + parentID: a.id, + time: { created: Date.now() }, + finish: "end_turn", + } + yield* ssn.updateMessage(b) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: b.id, + sessionID: info.id, + type: "tool", + callID: crypto.randomUUID(), + tool: "bash", + state: { + status: "completed", + input: {}, + output: "x".repeat(200_000), + title: "done", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + for (const text of ["second", "third"]) { + const msg = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: info.id, + agent: "build", + model: ref, + time: { created: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID: info.id, + type: "text", + text, + }) + } - yield* compact.prune({ sessionID: info.id }) + yield* compact.prune({ sessionID: info.id }) - const msgs = yield* ssn.messages({ sessionID: info.id }) - const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool") - expect(part?.type).toBe("tool") - expect(part?.state.status).toBe("completed") - if (part?.type === "tool" && part.state.status === "completed") { - expect(part.state.time.compacted).toBeNumber() - } - }), + const msgs = yield* ssn.messages({ sessionID: info.id }) + const part = msgs.flatMap((msg) => msg.parts).find((part) => part.type === "tool") + expect(part?.type).toBe("tool") + expect(part?.state.status).toBe("completed") + if (part?.type === "tool" && part.state.status === "completed") { + expect(part.state.time.compacted).toBeNumber() + } + }), + + { + config: { + compaction: { prune: true }, + }, + }, ), ) @@ -836,6 +949,273 @@ describe("session.compaction.process", () => { }) }) + test("persists tail_start_id for retained recent turns", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "first") + const keep = await user(session.id, "second") + await user(session.id, "third") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = runtime( + "continue", + Plugin.defaultLayer, + wide(), + cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }), + ) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const part = await lastCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + } finally { + await rt.dispose() + } + }, + }) + }) + + test("shrinks retained tail to fit preserve token budget", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "first") + await user(session.id, "x".repeat(2_000)) + const keep = await user(session.id, "tiny") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 100 })) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const part = await lastCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + } finally { + await rt.dispose() + } + }, + }) + }) + + test("falls back to full summary when even one recent turn exceeds preserve token budget", async () => { + await using tmp = await tmpdir({ git: true }) + const stub = llm() + let captured = "" + stub.push( + reply("summary", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "first") + await user(session.id, "y".repeat(2_000)) + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 20 })) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const part = await lastCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBeUndefined() + expect(captured).toContain("yyyy") + } finally { + await rt.dispose() + } + }, + }) + }) + + test("falls back to full summary when retained tail media exceeds preserve token budget", async () => { + await using tmp = await tmpdir({ git: true }) + const stub = llm() + let captured = "" + stub.push( + reply("summary", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "older") + const recent = await user(session.id, "recent image turn") + await svc.updatePart({ + id: PartID.ascending(), + messageID: recent.id, + sessionID: session.id, + type: "file", + mime: "image/png", + filename: "big.png", + url: `data:image/png;base64,${"a".repeat(4_000)}`, + }) + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 100 })) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const part = await lastCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBeUndefined() + expect(captured).toContain("recent image turn") + expect(captured).toContain("Attached image/png: big.png") + } finally { + await rt.dispose() + } + }, + }) + }) + + test("retains a split turn suffix when a later message fits the preserve token budget", async () => { + await using tmp = await tmpdir({ git: true }) + const stub = llm() + let captured = "" + stub.push( + reply("summary", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "older") + const recent = await user(session.id, "recent turn") + const large = await assistant(session.id, recent.id, tmp.path) + await svc.updatePart({ + id: PartID.ascending(), + messageID: large.id, + sessionID: session.id, + type: "text", + text: "z".repeat(2_000), + }) + const keep = await assistant(session.id, recent.id, tmp.path) + await svc.updatePart({ + id: PartID.ascending(), + messageID: keep.id, + sessionID: session.id, + type: "text", + text: "keep tail", + }) + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 1, preserve_recent_tokens: 100 })) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const part = await lastCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + expect(captured).toContain("zzzz") + expect(captured).not.toContain("keep tail") + + const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) + expect(filtered[0]?.info.id).toBe(keep.id) + expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id) + } finally { + await rt.dispose() + } + }, + }) + }) + test("allows plugins to disable synthetic continue prompt", async () => { await using tmp = await tmpdir() await Instance.provide({ @@ -1199,6 +1579,276 @@ describe("session.compaction.process", () => { }, }) }) + + test("summarizes only the head while keeping recent tail out of summary input", async () => { + const stub = llm() + let captured = "" + stub.push( + reply("summary", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "older context") + await user(session.id, "keep this turn") + await user(session.id, "and this one too") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = liveRuntime(stub.layer, wide()) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + expect(captured).toContain("older context") + expect(captured).not.toContain("keep this turn") + expect(captured).not.toContain("and this one too") + expect(captured).not.toContain("What did we do so far?") + } finally { + await rt.dispose() + } + }, + }) + }) + + test("anchors repeated compactions with the previous summary", async () => { + const stub = llm() + let captured = "" + stub.push(reply("summary one")) + stub.push( + reply("summary two", (input) => { + captured = JSON.stringify(input.messages) + }), + ) + + await using tmp = await tmpdir({ git: true }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "older context") + await user(session.id, "keep this turn") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = liveRuntime(stub.layer, wide()) + try { + let msgs = await svc.messages({ sessionID: session.id }) + let parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + await user(session.id, "latest turn") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) + parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + expect(captured).toContain("") + expect(captured).toContain("summary one") + expect(captured.match(/summary one/g)?.length).toBe(1) + expect(captured).toContain("## Constraints & Preferences") + expect(captured).toContain("## Progress") + } finally { + await rt.dispose() + } + }, + }) + }) + + test("keeps recent pre-compaction turns across repeated compactions", async () => { + const stub = llm() + stub.push(reply("summary one")) + stub.push(reply("summary two")) + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + const u1 = await user(session.id, "one") + const u2 = await user(session.id, "two") + const u3 = await user(session.id, "three") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = liveRuntime(stub.layer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 })) + try { + let msgs = await svc.messages({ sessionID: session.id }) + let parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const u4 = await user(session.id, "four") + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + msgs = MessageV2.filterCompacted(MessageV2.stream(session.id)) + parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id)) + const ids = filtered.map((msg) => msg.info.id) + + expect(ids).not.toContain(u1.id) + expect(ids).not.toContain(u2.id) + expect(ids).toContain(u3.id) + expect(ids).toContain(u4.id) + expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true) + expect( + filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")), + ).toBe(true) + } finally { + await rt.dispose() + } + }, + }) + }) + + test("ignores previous summaries when sizing the retained tail", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + await user(session.id, "older") + const keep = await user(session.id, "keep this turn") + const keepReply = await assistant(session.id, keep.id, tmp.path) + await svc.updatePart({ + id: PartID.ascending(), + messageID: keepReply.id, + sessionID: session.id, + type: "text", + text: "keep reply", + }) + + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + const firstCompaction = (await svc.messages({ sessionID: session.id })).at(-1)?.info.id + expect(firstCompaction).toBeTruthy() + await summaryAssistant(session.id, firstCompaction!, tmp.path, "summary ".repeat(800)) + + const recent = await user(session.id, "recent turn") + const recentReply = await assistant(session.id, recent.id, tmp.path) + await svc.updatePart({ + id: PartID.ascending(), + messageID: recentReply.id, + sessionID: session.id, + type: "text", + text: "recent reply", + }) + + await SessionCompaction.create({ + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }) + + const rt = runtime("continue", Plugin.defaultLayer, wide(), cfg({ tail_turns: 2, preserve_recent_tokens: 500 })) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + const part = await lastCompactionPart(session.id) + expect(part?.type).toBe("compaction") + expect(part?.tail_start_id).toBe(keep.id) + } finally { + await rt.dispose() + } + }, + }) + }) }) describe("util.token.estimate", () => { diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index a3137ff01d0..0db4d685ba1 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -585,6 +585,76 @@ describe("session.message-v2.toModelMessage", () => { ]) }) + test("truncates tool output when requested", async () => { + const userID = "m-user" + const assistantID = "m-assistant" + + const input: MessageV2.WithParts[] = [ + { + info: userInfo(userID), + parts: [ + { + ...basePart(userID, "u1"), + type: "text", + text: "run tool", + }, + ] as MessageV2.Part[], + }, + { + info: assistantInfo(assistantID, userID), + parts: [ + { + ...basePart(assistantID, "a1"), + type: "tool", + callID: "call-1", + tool: "bash", + state: { + status: "completed", + input: { cmd: "ls" }, + output: "abcdefghij", + title: "Bash", + metadata: {}, + time: { start: 0, end: 1 }, + }, + }, + ] as MessageV2.Part[], + }, + ] + + expect(await MessageV2.toModelMessages(input, model, { toolOutputMaxChars: 4 })).toStrictEqual([ + { + role: "user", + content: [{ type: "text", text: "run tool" }], + }, + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call-1", + toolName: "bash", + input: { cmd: "ls" }, + providerExecuted: undefined, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "bash", + output: { + type: "text", + value: "abcd\n[Tool output truncated for compaction: omitted 6 chars]", + }, + }, + ], + }, + ]) + }) + test("converts assistant tool error into error-text tool result", async () => { const userID = "m-user" const assistantID = "m-assistant" diff --git a/packages/opencode/test/session/messages-pagination.test.ts b/packages/opencode/test/session/messages-pagination.test.ts index 40ccacc584b..df2d18b9f12 100644 --- a/packages/opencode/test/session/messages-pagination.test.ts +++ b/packages/opencode/test/session/messages-pagination.test.ts @@ -107,13 +107,14 @@ async function addAssistant( return id } -async function addCompactionPart(sessionID: SessionID, messageID: MessageID) { +async function addCompactionPart(sessionID: SessionID, messageID: MessageID, tailStartID?: MessageID) { await svc.updatePart({ id: PartID.ascending(), sessionID, messageID, type: "compaction", auto: true, + tail_start_id: tailStartID, } as any) } @@ -780,6 +781,203 @@ describe("MessageV2.filterCompacted", () => { }) }) + test("retains original tail when compaction stores tail_start_id", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await svc.create({}) + + const u1 = await addUser(session.id, "first") + const a1 = await addAssistant(session.id, u1, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a1, + type: "text", + text: "first reply", + }) + + const u2 = await addUser(session.id, "second") + const a2 = await addAssistant(session.id, u2, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a2, + type: "text", + text: "second reply", + }) + + const c1 = await addUser(session.id) + await addCompactionPart(session.id, c1, u2) + const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: s1, + type: "text", + text: "summary", + }) + + const u3 = await addUser(session.id, "third") + const a3 = await addAssistant(session.id, u3, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a3, + type: "text", + text: "third reply", + }) + + const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) + + expect(result.map((item) => item.info.id)).toEqual([u2, a2, c1, s1, u3, a3]) + + await svc.remove(session.id) + }, + }) + }) + + test("retains an assistant tail when compaction starts inside a turn", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await svc.create({}) + + const u1 = await addUser(session.id, "first") + const a1 = await addAssistant(session.id, u1, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a1, + type: "text", + text: "first reply", + }) + + const u2 = await addUser(session.id, "second") + const a2 = await addAssistant(session.id, u2, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a2, + type: "text", + text: "second reply", + }) + const a3 = await addAssistant(session.id, u2, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a3, + type: "text", + text: "tail reply", + }) + + const c1 = await addUser(session.id) + await addCompactionPart(session.id, c1, a3) + const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: s1, + type: "text", + text: "summary", + }) + + const u3 = await addUser(session.id, "third") + const a4 = await addAssistant(session.id, u3, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a4, + type: "text", + text: "third reply", + }) + + const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) + + expect(result.map((item) => item.info.id)).toEqual([a3, c1, s1, u3, a4]) + + await svc.remove(session.id) + }, + }) + }) + + test("prefers latest compaction boundary when repeated compactions exist", async () => { + await Instance.provide({ + directory: root, + fn: async () => { + const session = await svc.create({}) + + const u1 = await addUser(session.id, "first") + const a1 = await addAssistant(session.id, u1, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a1, + type: "text", + text: "first reply", + }) + + const u2 = await addUser(session.id, "second") + const a2 = await addAssistant(session.id, u2, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a2, + type: "text", + text: "second reply", + }) + + const c1 = await addUser(session.id) + await addCompactionPart(session.id, c1, u2) + const s1 = await addAssistant(session.id, c1, { summary: true, finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: s1, + type: "text", + text: "summary one", + }) + + const u3 = await addUser(session.id, "third") + const a3 = await addAssistant(session.id, u3, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a3, + type: "text", + text: "third reply", + }) + + const c2 = await addUser(session.id) + await addCompactionPart(session.id, c2, u3) + const s2 = await addAssistant(session.id, c2, { summary: true, finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: s2, + type: "text", + text: "summary two", + }) + + const u4 = await addUser(session.id, "fourth") + const a4 = await addAssistant(session.id, u4, { finish: "end_turn" }) + await svc.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: a4, + type: "text", + text: "fourth reply", + }) + + const result = MessageV2.filterCompacted(MessageV2.stream(session.id)) + + expect(result.map((item) => item.info.id)).toEqual([u3, a3, c2, s2, u4, a4]) + + await svc.remove(session.id) + }, + }) + }) + test("works with array input", () => { // filterCompacted accepts any Iterable, not just generators const id = MessageID.ascending() diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts deleted file mode 100644 index bee736c7dd4..00000000000 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ /dev/null @@ -1,1863 +0,0 @@ -// kilocode_change - all agent: "build" references renamed to agent: "code" -import { NodeFileSystem } from "@effect/platform-node" -import { FetchHttpClient } from "effect/unstable/http" -import { expect } from "bun:test" -import { Cause, Effect, Exit, Fiber, Layer } from "effect" -import path from "path" -import { Agent as AgentSvc } from "../../src/agent/agent" -import { Bus } from "../../src/bus" -import { Command } from "../../src/command" -import { Config } from "../../src/config" -import { LSP } from "../../src/lsp" -import { MCP } from "../../src/mcp" -import { Permission } from "../../src/permission" -import { Plugin } from "../../src/plugin" -import { Provider as ProviderSvc } from "../../src/provider" -import { Env } from "../../src/env" -import { ModelID, ProviderID } from "../../src/provider/schema" -import { Question } from "../../src/question" -import { Todo } from "../../src/session/todo" -import { Session } from "../../src/session" -import { LLM } from "../../src/session/llm" -import { MessageV2 } from "../../src/session/message-v2" -import { AppFileSystem } from "@opencode-ai/shared/filesystem" -import { SessionCompaction } from "../../src/session/compaction" -import { SessionSummary } from "../../src/session/summary" -import { Instruction } from "../../src/session/instruction" -import { SessionProcessor } from "../../src/session/processor" -import { SessionPrompt } from "../../src/session/prompt" -import { SessionRevert } from "../../src/session/revert" -import { SessionRunState } from "../../src/session/run-state" -import { MessageID, PartID, SessionID } from "../../src/session/schema" -import { SessionStatus } from "../../src/session/status" -import { Skill } from "../../src/skill" -import { SystemPrompt } from "../../src/session/system" -import { Shell } from "../../src/shell/shell" -import { Snapshot } from "../../src/snapshot" -import { ToolRegistry } from "../../src/tool" -import { Truncate } from "../../src/tool" -import { Log } from "../../src/util" -import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" -import { Ripgrep } from "../../src/file/ripgrep" -import { Format } from "../../src/format" -import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" -import { testEffect } from "../lib/effect" -import { reply, TestLLMServer } from "../lib/llm-server" - -void Log.init({ print: false }) - -const summary = Layer.succeed( - SessionSummary.Service, - SessionSummary.Service.of({ - summarize: () => Effect.void, - diff: () => Effect.succeed([]), - computeDiff: () => Effect.succeed([]), - }), -) - -const ref = { - providerID: ProviderID.make("test"), - modelID: ModelID.make("test-model"), -} - -function defer() { - let resolve!: (value: T | PromiseLike) => void - const promise = new Promise((done) => { - resolve = done - }) - return { promise, resolve } -} - -function withSh(fx: () => Effect.Effect) { - return Effect.acquireUseRelease( - Effect.sync(() => { - const prev = process.env.SHELL - process.env.SHELL = "/bin/sh" - Shell.preferred.reset() - return prev - }), - () => fx(), - (prev) => - Effect.sync(() => { - if (prev === undefined) delete process.env.SHELL - else process.env.SHELL = prev - Shell.preferred.reset() - }), - ) -} - -function toolPart(parts: MessageV2.Part[]) { - return parts.find((part): part is MessageV2.ToolPart => part.type === "tool") -} - -type CompletedToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted } -type ErrorToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateError } - -function completedTool(parts: MessageV2.Part[]) { - const part = toolPart(parts) - expect(part?.state.status).toBe("completed") - return part?.state.status === "completed" ? (part as CompletedToolPart) : undefined -} - -function errorTool(parts: MessageV2.Part[]) { - const part = toolPart(parts) - expect(part?.state.status).toBe("error") - return part?.state.status === "error" ? (part as ErrorToolPart) : undefined -} - -// kilocode_change start - helpers for Ask/Plan guard regression tests -function names(input: Record | undefined) { - const tools = input?.tools - if (!Array.isArray(tools)) return [] - return tools.flatMap((item) => { - if (!item || typeof item !== "object") return [] - const obj = item as Record - const fn = obj.function - if (fn && typeof fn === "object") { - const name = (fn as Record).name - if (typeof name === "string") return [name] - } - if (typeof obj.name === "string") return [obj.name] - return [] - }) -} - -const waitQuestion = Effect.fn("test.waitQuestion")(function* (sessionID: SessionID) { - for (let i = 0; i < 100; i++) { - const list = yield* Effect.promise(() => Question.list()) - const item = list.find((q) => q.sessionID === sessionID) - if (item) return item - yield* Effect.sleep("10 millis") - } -}) -// kilocode_change end - -const mcp = Layer.succeed( - MCP.Service, - MCP.Service.of({ - status: () => Effect.succeed({}), - clients: () => Effect.succeed({}), - tools: () => Effect.succeed({}), - prompts: () => Effect.succeed({}), - resources: () => Effect.succeed({}), - add: () => Effect.succeed({ status: { status: "disabled" as const } }), - connect: () => Effect.void, - disconnect: () => Effect.void, - getPrompt: () => Effect.succeed(undefined), - readResource: () => Effect.succeed(undefined), - startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), - authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"), - finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), - removeAuth: () => Effect.void, - supportsOAuth: () => Effect.succeed(false), - hasStoredTokens: () => Effect.succeed(false), - getAuthStatus: () => Effect.succeed("not_authenticated" as const), - }), -) - -const lsp = Layer.succeed( - LSP.Service, - LSP.Service.of({ - init: () => Effect.void, - status: () => Effect.succeed([]), - hasClients: () => Effect.succeed(false), - touchFile: () => Effect.void, - diagnostics: () => Effect.succeed({}), - hover: () => Effect.succeed(undefined), - definition: () => Effect.succeed([]), - references: () => Effect.succeed([]), - implementation: () => Effect.succeed([]), - documentSymbol: () => Effect.succeed([]), - workspaceSymbol: () => Effect.succeed([]), - prepareCallHierarchy: () => Effect.succeed([]), - incomingCalls: () => Effect.succeed([]), - outgoingCalls: () => Effect.succeed([]), - }), -) - -const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) -const run = SessionRunState.layer.pipe(Layer.provide(status)) -const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) -function makeHttp() { - const deps = Layer.mergeAll( - Session.defaultLayer, - Snapshot.defaultLayer, - LLM.defaultLayer, - Env.defaultLayer, - AgentSvc.defaultLayer, - Command.defaultLayer, - Permission.defaultLayer, - Plugin.defaultLayer, - Config.defaultLayer, - ProviderSvc.defaultLayer, - lsp, - mcp, - AppFileSystem.defaultLayer, - status, - ).pipe(Layer.provideMerge(infra)) - const question = Question.layer.pipe(Layer.provideMerge(deps)) - const todo = Todo.layer.pipe(Layer.provideMerge(deps)) - const registry = ToolRegistry.layer.pipe( - Layer.provide(Skill.defaultLayer), - Layer.provide(FetchHttpClient.layer), - Layer.provide(CrossSpawnSpawner.defaultLayer), - Layer.provide(Ripgrep.defaultLayer), - Layer.provide(Format.defaultLayer), - Layer.provideMerge(todo), - Layer.provideMerge(question), - Layer.provideMerge(deps), - ) - const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)) - const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) - return Layer.mergeAll( - TestLLMServer.layer, - SessionPrompt.layer.pipe( - Layer.provide(SessionRevert.defaultLayer), - Layer.provide(summary), - Layer.provideMerge(run), - Layer.provideMerge(compact), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provide(Instruction.defaultLayer), - Layer.provide(SystemPrompt.defaultLayer), - Layer.provideMerge(deps), - ), - ).pipe(Layer.provide(summary)) -} - -const it = testEffect(makeHttp()) -const unix = process.platform !== "win32" ? it.live : it.live.skip -const unixSkip = it.live.skip // kilocode_change - TODO(#8990): skip flaky cancel tests on Linux CI - -// Config that registers a custom "test" provider with a "test-model" model -// so provider model lookup succeeds inside the loop. -const cfg = { - provider: { - test: { - name: "Test", - id: "test", - env: [], - npm: "@ai-sdk/openai-compatible", - models: { - "test-model": { - id: "test-model", - name: "Test Model", - attachment: false, - reasoning: false, - temperature: false, - tool_call: true, - release_date: "2025-01-01", - limit: { context: 100000, output: 10000 }, - cost: { input: 0, output: 0 }, - options: {}, - }, - }, - options: { - apiKey: "test-key", - baseURL: "http://localhost:1/v1", - }, - }, - }, -} - -function providerCfg(url: string) { - return { - ...cfg, - provider: { - ...cfg.provider, - test: { - ...cfg.provider.test, - options: { - ...cfg.provider.test.options, - baseURL: url, - }, - }, - }, - } -} - -const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: string) { - const session = yield* Session.Service - const msg = yield* session.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID, - agent: "code", - model: ref, - time: { created: Date.now() }, - }) - yield* session.updatePart({ - id: PartID.ascending(), - messageID: msg.id, - sessionID, - type: "text", - text, - }) - return msg -}) - -const seed = Effect.fn("test.seed")(function* (sessionID: SessionID, opts?: { finish?: string }) { - const session = yield* Session.Service - const msg = yield* user(sessionID, "hello") - const assistant: MessageV2.Assistant = { - id: MessageID.ascending(), - role: "assistant", - parentID: msg.id, - sessionID, - mode: "build", - agent: "code", - cost: 0, - path: { cwd: "/tmp", root: "/tmp" }, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - modelID: ref.modelID, - providerID: ref.providerID, - time: { created: Date.now() }, - ...(opts?.finish ? { finish: opts.finish } : {}), - } - yield* session.updateMessage(assistant) - yield* session.updatePart({ - id: PartID.ascending(), - messageID: assistant.id, - sessionID, - type: "text", - text: "hi there", - }) - return { user: msg, assistant } -}) - -const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) => - Effect.gen(function* () { - const session = yield* Session.Service - yield* session.updatePart({ - id: PartID.ascending(), - messageID, - sessionID, - type: "subtask", - prompt: "look into the cache key path", - description: "inspect bug", - agent: "general", - model, - }) - }) - -const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) { - const prompt = yield* SessionPrompt.Service - const run = yield* SessionRunState.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create(input ?? { title: "Pinned" }) - return { prompt, run, sessions, chat } -}) - -// Loop semantics - -it.live("loop exits immediately when last assistant has stop finish", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* seed(chat.id, { finish: "stop" }) - - const result = yield* prompt.loop({ sessionID: chat.id }) - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") expect(result.info.finish).toBe("stop") - expect(yield* llm.calls).toBe(0) - }), - { git: true, config: providerCfg }, - ), -) - -it.live("loop calls LLM and returns assistant message", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ - title: "Pinned", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - yield* prompt.prompt({ - sessionID: chat.id, - agent: "code", - noReply: true, - parts: [{ type: "text", text: "hello" }], - }) - yield* llm.text("world") - - const result = yield* prompt.loop({ sessionID: chat.id }) - expect(result.info.role).toBe("assistant") - const parts = result.parts.filter((p) => p.type === "text") - expect(parts.some((p) => p.type === "text" && p.text === "world")).toBe(true) - expect(yield* llm.hits).toHaveLength(1) - }), - { git: true, config: providerCfg }, - ), -) - -it.live("static loop returns assistant text through local provider", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ - title: "Prompt provider", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - - yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - parts: [{ type: "text", text: "hello" }], - }) - - yield* llm.text("world") - - const result = yield* prompt.loop({ sessionID: session.id }) - expect(result.info.role).toBe("assistant") - expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true) - expect(yield* llm.hits).toHaveLength(1) - expect(yield* llm.pending).toBe(0) - }), - { git: true, config: providerCfg }, - ), -) - -it.live("static loop consumes queued replies across turns", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ - title: "Prompt provider turns", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - - yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - parts: [{ type: "text", text: "hello one" }], - }) - - yield* llm.text("world one") - - const first = yield* prompt.loop({ sessionID: session.id }) - expect(first.info.role).toBe("assistant") - expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true) - - yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - parts: [{ type: "text", text: "hello two" }], - }) - - yield* llm.text("world two") - - const second = yield* prompt.loop({ sessionID: session.id }) - expect(second.info.role).toBe("assistant") - expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true) - - expect(yield* llm.hits).toHaveLength(2) - expect(yield* llm.pending).toBe(0) - }), - { git: true, config: providerCfg }, - ), -) - -it.live("loop continues when finish is tool-calls", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ - title: "Pinned", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - yield* prompt.prompt({ - sessionID: session.id, - agent: "code", - noReply: true, - parts: [{ type: "text", text: "hello" }], - }) - yield* llm.tool("first", { value: "first" }) - yield* llm.text("second") - - const result = yield* prompt.loop({ sessionID: session.id }) - expect(yield* llm.calls).toBe(2) - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") { - expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) - expect(result.info.finish).toBe("stop") - } - }), - { git: true, config: providerCfg }, - ), -) - -it.live("glob tool keeps instance context during prompt runs", () => - provideTmpdirServer( - ({ dir, llm }) => - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ - title: "Glob context", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - const file = path.join(dir, "probe.txt") - yield* Effect.promise(() => Bun.write(file, "probe")) - - yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - parts: [{ type: "text", text: "find text files" }], - }) - yield* llm.tool("glob", { pattern: "**/*.txt" }) - yield* llm.text("done") - - const result = yield* prompt.loop({ sessionID: session.id }) - expect(result.info.role).toBe("assistant") - - const msgs = yield* MessageV2.filterCompactedEffect(session.id) - const tool = msgs - .flatMap((msg) => msg.parts) - .find( - (part): part is CompletedToolPart => - part.type === "tool" && part.tool === "glob" && part.state.status === "completed", - ) - if (!tool) return - - expect(tool.state.output).toContain(file) - expect(tool.state.output).not.toContain("No context found for instance") - expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true) - }), - { git: true, config: providerCfg }, - ), -) - -// kilocode_change start - assistant tool parts should continue loop despite stop finish -it.live("loop continues when finish is stop but assistant has tool parts", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ - title: "Pinned", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - yield* prompt.prompt({ - sessionID: session.id, - agent: "code", - noReply: true, - parts: [{ type: "text", text: "hello" }], - }) - yield* llm.push(reply().tool("first", { value: "first" }).stop()) - yield* llm.text("second") - - const result = yield* prompt.loop({ sessionID: session.id }) - expect(yield* llm.calls).toBe(2) - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") { - expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) - expect(result.info.finish).toBe("stop") - } - }), - { git: true, config: providerCfg }, - ), -) -// kilocode_change end - -// kilocode_change start - Ask mode guard coverage -unix("ask agent denies bash redirection during prompt loop", () => - provideTmpdirServer( - ({ dir, llm }) => - withSh(() => - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "Ask bash guard" }) - const file = path.join(dir, "ask-bug.txt") - - yield* prompt.prompt({ - sessionID: session.id, - agent: "ask", - noReply: true, - parts: [{ type: "text", text: "Explain the repo" }], - }) - yield* llm.tool("bash", { command: "echo bug > ask-bug.txt", description: "write ask bug" }) - yield* llm.text("done") - - yield* prompt.loop({ sessionID: session.id }) - expect(yield* Effect.promise(() => Bun.file(file).exists())).toBe(false) - - const msgs = yield* MessageV2.filterCompactedEffect(session.id) - const tool = msgs - .flatMap((msg) => msg.parts) - .find((part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "bash") - expect(tool?.state.status).toBe("error") - }), - ), - { git: true, config: providerCfg }, - ), -) - -it.live("ask agent hides edit tools despite session allow-all", () => - provideTmpdirServer( - ({ dir, llm }) => - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ - title: "Ask session guard", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - const file = path.join(dir, "ask-write-bug.txt") - - yield* prompt.prompt({ - sessionID: session.id, - agent: "ask", - noReply: true, - parts: [{ type: "text", text: "Explain the repo" }], - }) - yield* llm.tool("write", { filePath: file, content: "bug\n" }) - yield* llm.text("done") - - yield* prompt.loop({ sessionID: session.id }) - const inputs = yield* llm.inputs - expect(names(inputs[0])).not.toContain("write") - expect(names(inputs[0])).not.toContain("edit") - expect(yield* Effect.promise(() => Bun.file(file).exists())).toBe(false) - }), - { git: true, config: providerCfg }, - ), -) -// kilocode_change end - -// kilocode_change start - saved permissions must not bypass Ask/Plan guards -unix("ask agent denies bash redirection despite global allow-everything", () => - provideTmpdirServer( - ({ dir, llm }) => - withSh(() => - Effect.gen(function* () { - const permission = yield* Permission.Service - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "Ask global guard" }) - const file = path.join(dir, "ask-global-bug.txt") - - yield* permission.allowEverything({ enable: true }) - yield* prompt.prompt({ - sessionID: session.id, - agent: "ask", - noReply: true, - parts: [{ type: "text", text: "Explain the repo" }], - }) - yield* llm.tool("bash", { command: "echo bug > ask-global-bug.txt", description: "write ask bug" }) - yield* llm.text("done") - - yield* prompt.loop({ sessionID: session.id }) - yield* permission.allowEverything({ enable: false }) - expect(yield* Effect.promise(() => Bun.file(file).exists())).toBe(false) - - const msgs = yield* MessageV2.filterCompactedEffect(session.id) - const tool = msgs - .flatMap((msg) => msg.parts) - .find((part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "bash") - expect(tool?.state.status).toBe("error") - }), - ), - { git: true, config: providerCfg }, - ), -) - -unix("ask agent denies bash redirection despite session allow-everything", () => - provideTmpdirServer( - ({ dir, llm }) => - withSh(() => - Effect.gen(function* () { - const permission = yield* Permission.Service - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ - title: "Ask local guard", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - const file = path.join(dir, "ask-session-bug.txt") - - yield* permission.allowEverything({ enable: true, sessionID: session.id }) - yield* prompt.prompt({ - sessionID: session.id, - agent: "ask", - noReply: true, - parts: [{ type: "text", text: "Explain the repo" }], - }) - yield* llm.tool("bash", { command: "echo bug > ask-session-bug.txt", description: "write ask bug" }) - yield* llm.text("done") - - yield* prompt.loop({ sessionID: session.id }) - yield* permission.allowEverything({ enable: false, sessionID: session.id }) - expect(yield* Effect.promise(() => Bun.file(file).exists())).toBe(false) - - const msgs = yield* MessageV2.filterCompactedEffect(session.id) - const tool = msgs - .flatMap((msg) => msg.parts) - .find((part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "bash") - expect(tool?.state.status).toBe("error") - }), - ), - { git: true, config: providerCfg }, - ), -) - -it.live("plan agent denies non-plan write despite global allow-everything", () => - provideTmpdirServer( - ({ dir, llm }) => - Effect.gen(function* () { - const permission = yield* Permission.Service - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "Plan global guard" }) - const plan = Session.plan(session) - const file = path.join(dir, "plan-global-bug.txt") - - yield* permission.allowEverything({ enable: true }) - yield* prompt.prompt({ - sessionID: session.id, - agent: "plan", - noReply: true, - parts: [{ type: "text", text: "Create a plan only" }], - }) - yield* llm.tool("write", { filePath: plan, content: "# Plan\n\n- Stay in plan mode.\n" }) - yield* llm.tool("write", { filePath: file, content: "bug\n" }) - yield* llm.text("done") - - yield* prompt.loop({ sessionID: session.id }) - yield* permission.allowEverything({ enable: false }) - expect(yield* Effect.promise(() => Bun.file(plan).exists())).toBe(true) - expect(yield* Effect.promise(() => Bun.file(file).exists())).toBe(false) - - const msgs = yield* MessageV2.filterCompactedEffect(session.id) - const tools = msgs - .flatMap((msg) => msg.parts) - .filter((part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "write") - const good = tools.find((part) => (part.state.input as { filePath?: string } | undefined)?.filePath === plan) - const bad = tools.find((part) => (part.state.input as { filePath?: string } | undefined)?.filePath === file) - expect(good?.state.status).toBe("completed") - expect(bad?.state.status).toBe("error") - }), - { git: true, config: providerCfg }, - ), -) - -it.live("plan agent denies non-plan write despite session allow-everything", () => - provideTmpdirServer( - ({ dir, llm }) => - Effect.gen(function* () { - const permission = yield* Permission.Service - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ - title: "Plan local guard", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - const plan = Session.plan(session) - const file = path.join(dir, "plan-session-bug.txt") - - yield* permission.allowEverything({ enable: true, sessionID: session.id }) - yield* prompt.prompt({ - sessionID: session.id, - agent: "plan", - noReply: true, - parts: [{ type: "text", text: "Create a plan only" }], - }) - yield* llm.tool("write", { filePath: plan, content: "# Plan\n\n- Stay in plan mode.\n" }) - yield* llm.tool("write", { filePath: file, content: "bug\n" }) - yield* llm.text("done") - - yield* prompt.loop({ sessionID: session.id }) - yield* permission.allowEverything({ enable: false, sessionID: session.id }) - expect(yield* Effect.promise(() => Bun.file(plan).exists())).toBe(true) - expect(yield* Effect.promise(() => Bun.file(file).exists())).toBe(false) - - const msgs = yield* MessageV2.filterCompactedEffect(session.id) - const tools = msgs - .flatMap((msg) => msg.parts) - .filter((part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "write") - const good = tools.find((part) => (part.state.input as { filePath?: string } | undefined)?.filePath === plan) - const bad = tools.find((part) => (part.state.input as { filePath?: string } | undefined)?.filePath === file) - expect(good?.state.status).toBe("completed") - expect(bad?.state.status).toBe("error") - }), - { git: true, config: providerCfg }, - ), -) -// kilocode_change end - -unix("plan follow-up stops before queued mutations after plan_exit", () => - provideTmpdirServer( - ({ dir, llm }) => - withSh(() => - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "Plan hard stop" }) - const plan = Session.plan(session) - const file = path.join(dir, "plan-after-exit.txt") - - yield* prompt.prompt({ - sessionID: session.id, - agent: "plan", - noReply: true, - parts: [{ type: "text", text: "Create a plan only" }], - }) - yield* llm.tool("write", { filePath: plan, content: "# Plan\n\n- Stop after planning.\n" }) - yield* llm.tool("plan_exit", {}) - yield* llm.tool("bash", { command: "echo bug > plan-after-exit.txt", description: "write plan bug" }) - - const fiber = yield* prompt.loop({ sessionID: session.id }).pipe(Effect.forkChild) - const question = yield* waitQuestion(session.id) - if (!question) { - yield* Fiber.interrupt(fiber) - expect(question).toBeDefined() - return - } - - const calls = yield* llm.calls - const pending = yield* llm.pending - const mutated = yield* Effect.promise(() => Bun.file(file).exists()) - const planned = yield* Effect.promise(() => Bun.file(plan).exists()) - yield* Effect.promise(() => Question.reject(question.id)) - const exit = yield* Fiber.await(fiber) - - expect(Exit.isSuccess(exit)).toBe(true) - expect(calls).toBe(2) - expect(pending).toBe(1) - expect(mutated).toBe(false) - expect(planned).toBe(true) - }), - ), - { git: true, config: providerCfg }, - ), -) - -it.live("failed subtask preserves metadata on error tool state", () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* llm.tool("task", { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - }) - yield* llm.text("done") - const msg = yield* user(chat.id, "hello") - yield* addSubtask(chat.id, msg.id) - - const result = yield* prompt.loop({ sessionID: chat.id }) - expect(result.info.role).toBe("assistant") - expect(yield* llm.calls).toBe(2) - - const msgs = yield* MessageV2.filterCompactedEffect(chat.id) - const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") - expect(taskMsg?.info.role).toBe("assistant") - if (!taskMsg || taskMsg.info.role !== "assistant") return - - const tool = errorTool(taskMsg.parts) - if (!tool) return - - expect(tool.state.error).toContain("Tool execution failed") - expect(tool.state.metadata).toBeDefined() - expect(tool.state.metadata?.sessionId).toBeDefined() - expect(tool.state.metadata?.model).toEqual({ - providerID: ProviderID.make("test"), - modelID: ModelID.make("missing-model"), - }) - }), - { - git: true, - config: (url) => ({ - ...providerCfg(url), - agent: { - general: { - model: "test/missing-model", - }, - }, - }), - }, - ), -) - -it.live( - "running subtask preserves metadata after tool-call transition", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* llm.hang - const msg = yield* user(chat.id, "hello") - yield* addSubtask(chat.id, msg.id) - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - - const tool = yield* Effect.promise(async () => { - const end = Date.now() + 5_000 - while (Date.now() < end) { - const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id)) - const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") - const tool = taskMsg?.parts.find((part): part is MessageV2.ToolPart => part.type === "tool") - if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool - await new Promise((done) => setTimeout(done, 20)) - } - throw new Error("timed out waiting for running subtask metadata") - }) - - if (tool.state.status !== "running") return - expect(typeof tool.state.metadata?.sessionId).toBe("string") - expect(tool.state.title).toBeDefined() - expect(tool.state.metadata?.model).toBeDefined() - - yield* prompt.cancel(chat.id) - yield* Fiber.await(fiber) - }), - { git: true, config: providerCfg }, - ), - 5_000, -) - -it.live( - "running task tool preserves metadata after tool-call transition", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ - title: "Pinned", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - yield* llm.tool("task", { - description: "inspect bug", - prompt: "look into the cache key path", - subagent_type: "general", - }) - yield* llm.hang - yield* user(chat.id, "hello") - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - - const tool = yield* Effect.promise(async () => { - const end = Date.now() + 5_000 - while (Date.now() < end) { - const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id)) - const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "code") // kilocode_change - const tool = assistant?.parts.find( - (part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "task", - ) - if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool - await new Promise((done) => setTimeout(done, 20)) - } - throw new Error("timed out waiting for running task metadata") - }) - - if (tool.state.status !== "running") return - expect(typeof tool.state.metadata?.sessionId).toBe("string") - expect(tool.state.title).toBe("inspect bug") - expect(tool.state.metadata?.model).toBeDefined() - - yield* prompt.cancel(chat.id) - yield* Fiber.await(fiber) - }), - { git: true, config: providerCfg }, - ), - 10_000, -) - -it.live( - "loop sets status to busy then idle", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const status = yield* SessionStatus.Service - - yield* llm.hang - - const chat = yield* sessions.create({}) - yield* user(chat.id, "hi") - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - expect((yield* status.get(chat.id)).type).toBe("busy") - yield* prompt.cancel(chat.id) - yield* Fiber.await(fiber) - expect((yield* status.get(chat.id)).type).toBe("idle") - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -// Cancel semantics - -it.live( - "cancel interrupts loop and resolves with an assistant message", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* seed(chat.id) - - yield* llm.hang - - yield* user(chat.id, "more") - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - yield* prompt.cancel(chat.id) - const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value.info.role).toBe("assistant") - } - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -it.live( - "cancel records MessageAbortedError on interrupted process", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* llm.hang - yield* user(chat.id, "hello") - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - yield* prompt.cancel(chat.id) - const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - const info = exit.value.info - if (info.role === "assistant") { - expect(info.error?.name).toBe("MessageAbortedError") - } - } - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -it.live( - "cancel finalizes subtask tool state", - () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const ready = defer() - const aborted = defer() - const registry = yield* ToolRegistry.Service - const { task } = yield* registry.named() - const original = task.execute - task.execute = (_args, ctx) => - Effect.callback((_resume) => { - ready.resolve() - ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) - return Effect.sync(() => aborted.resolve()) - }) - yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original))) - - const { prompt, chat } = yield* boot() - const msg = yield* user(chat.id, "hello") - yield* addSubtask(chat.id, msg.id) - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.promise(() => ready.promise) - yield* prompt.cancel(chat.id) - yield* Effect.promise(() => aborted.promise) - - const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - - const msgs = yield* MessageV2.filterCompactedEffect(chat.id) - const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") - expect(taskMsg?.info.role).toBe("assistant") - if (!taskMsg || taskMsg.info.role !== "assistant") return - - const tool = toolPart(taskMsg.parts) - expect(tool?.type).toBe("tool") - if (!tool) return - - expect(tool.state.status).not.toBe("running") - expect(taskMsg.info.time.completed).toBeDefined() - expect(taskMsg.info.finish).toBeDefined() - }), - { git: true, config: cfg }, - ), - 30_000, -) - -it.live( - "cancel with queued callers resolves all cleanly", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* llm.hang - yield* user(chat.id, "hello") - - const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.sleep(50) - - yield* prompt.cancel(chat.id) - const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) - expect(Exit.isSuccess(exitA)).toBe(true) - expect(Exit.isSuccess(exitB)).toBe(true) - if (Exit.isSuccess(exitA) && Exit.isSuccess(exitB)) { - expect(exitA.value.info.id).toBe(exitB.value.info.id) - } - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -// Queue semantics - -it.live("concurrent loop callers get same result", () => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const { prompt, run, chat } = yield* boot() - yield* seed(chat.id, { finish: "stop" }) - - const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { - concurrency: "unbounded", - }) - - expect(a.info.id).toBe(b.info.id) - expect(a.info.role).toBe("assistant") - yield* run.assertNotBusy(chat.id) - }), - { git: true }, - ), -) - -it.live( - "concurrent loop callers all receive same error result", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - - yield* llm.fail("boom") - yield* user(chat.id, "hello") - - const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { - concurrency: "unbounded", - }) - expect(a.info.id).toBe(b.info.id) - expect(a.info.role).toBe("assistant") - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -// kilocode_change start - skip flaky test, tracked in #8990 -it.live.skip( - "prompt submitted during an active run is included in the next LLM input", - // kilocode_change end - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const gate = defer() - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - - yield* llm.hold("first", gate.promise) - yield* llm.text("second") - - const a = yield* prompt - .prompt({ - sessionID: chat.id, - agent: "code", - model: ref, - parts: [{ type: "text", text: "first" }], - }) - .pipe(Effect.forkChild) - - yield* llm.wait(1) - - const id = MessageID.ascending() - const b = yield* prompt - .prompt({ - sessionID: chat.id, - messageID: id, - agent: "code", - model: ref, - parts: [{ type: "text", text: "second" }], - }) - .pipe(Effect.forkChild) - - yield* Effect.promise(async () => { - const end = Date.now() + 5000 - while (Date.now() < end) { - const msgs = await Effect.runPromise(sessions.messages({ sessionID: chat.id })) - if (msgs.some((msg) => msg.info.role === "user" && msg.info.id === id)) return - await new Promise((done) => setTimeout(done, 20)) - } - throw new Error("timed out waiting for second prompt to save") - }) - - gate.resolve() - - const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) - expect(Exit.isSuccess(ea)).toBe(true) - expect(Exit.isSuccess(eb)).toBe(true) - expect(yield* llm.calls).toBe(2) - - const msgs = yield* sessions.messages({ sessionID: chat.id }) - const assistants = msgs.filter((msg) => msg.info.role === "assistant") - expect(assistants).toHaveLength(2) - const last = assistants.at(-1) - if (!last || last.info.role !== "assistant") throw new Error("expected second assistant") - expect(last.info.parentID).toBe(id) - expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) - - const inputs = yield* llm.inputs - expect(inputs).toHaveLength(2) - expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second") - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -it.live( - "assertNotBusy throws BusyError when loop running", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const run = yield* SessionRunState.Service - const sessions = yield* Session.Service - yield* llm.hang - - const chat = yield* sessions.create({}) - yield* user(chat.id, "hi") - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - - const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) - } - - yield* prompt.cancel(chat.id) - yield* Fiber.await(fiber) - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -it.live("assertNotBusy succeeds when idle", () => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const run = yield* SessionRunState.Service - const sessions = yield* Session.Service - - const chat = yield* sessions.create({}) - const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) - expect(Exit.isSuccess(exit)).toBe(true) - }), - { git: true }, - ), -) - -// Shell semantics - -it.live( - "shell rejects with BusyError when loop running", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Pinned" }) - yield* llm.hang - yield* user(chat.id, "hi") - - const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - - const exit = yield* prompt.shell({ sessionID: chat.id, agent: "code", command: "echo hi" }).pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) - } - - yield* prompt.cancel(chat.id) - yield* Fiber.await(fiber) - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -unix("shell captures stdout and stderr in completed tool output", () => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const { prompt, run, chat } = yield* boot() - const result = yield* prompt.shell({ - sessionID: chat.id, - agent: "code", - command: "printf out && printf err >&2", - }) - - expect(result.info.role).toBe("assistant") - const tool = completedTool(result.parts) - if (!tool) return - - expect(tool.state.output).toContain("out") - expect(tool.state.output).toContain("err") - expect(tool.state.metadata.output).toContain("out") - expect(tool.state.metadata.output).toContain("err") - yield* run.assertNotBusy(chat.id) - }), - { git: true, config: cfg }, - ), -) - -unix("shell completes a fast command on the preferred shell", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const { prompt, run, chat } = yield* boot() - const result = yield* prompt.shell({ - sessionID: chat.id, - agent: "code", - command: "pwd", - }) - - expect(result.info.role).toBe("assistant") - const tool = completedTool(result.parts) - if (!tool) return - - expect(tool.state.input.command).toBe("pwd") - expect(tool.state.output).toContain(dir) - expect(tool.state.metadata.output).toContain(dir) - yield* run.assertNotBusy(chat.id) - }), - { git: true, config: cfg }, - ), -) - -// kilocode_change start - port anomalyco/opencode#24215 cover shell cwd changes (agent "build" → "code" for our fork) -unix("shell commands can change directory after startup", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const { prompt, run, chat } = yield* boot() - const parent = path.dirname(dir) - const result = yield* prompt.shell({ - sessionID: chat.id, - agent: "code", - command: "cd .. && pwd", - }) - - expect(result.info.role).toBe("assistant") - const tool = completedTool(result.parts) - if (!tool) return - - expect(tool.state.output).toContain(parent) - expect(tool.state.metadata.output).toContain(parent) - yield* run.assertNotBusy(chat.id) - }), - { git: true, config: cfg }, - ), -) -// kilocode_change end - -unix("shell lists files from the project directory", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const { prompt, run, chat } = yield* boot() - yield* Effect.promise(() => Bun.write(path.join(dir, "README.md"), "# e2e\n")) - - const result = yield* prompt.shell({ - sessionID: chat.id, - agent: "code", - command: "command ls", - }) - - expect(result.info.role).toBe("assistant") - const tool = completedTool(result.parts) - if (!tool) return - - expect(tool.state.input.command).toBe("command ls") - expect(tool.state.output).toContain("README.md") - expect(tool.state.metadata.output).toContain("README.md") - yield* run.assertNotBusy(chat.id) - }), - { git: true, config: cfg }, - ), -) - -unix("shell captures stderr from a failing command", () => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const { prompt, run, chat } = yield* boot() - const result = yield* prompt.shell({ - sessionID: chat.id, - agent: "code", - command: "command -v __nonexistent_cmd_e2e__ || echo 'not found' >&2; exit 1", - }) - - expect(result.info.role).toBe("assistant") - const tool = completedTool(result.parts) - if (!tool) return - - expect(tool.state.output).toContain("not found") - expect(tool.state.metadata.output).toContain("not found") - yield* run.assertNotBusy(chat.id) - }), - { git: true, config: cfg }, - ), -) - -unix( - "shell updates running metadata before process exit", - () => - withSh(() => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const { prompt, chat } = yield* boot() - - const fiber = yield* prompt - .shell({ sessionID: chat.id, agent: "code", command: "printf first && sleep 0.2 && printf second" }) - .pipe(Effect.forkChild) - - yield* Effect.promise(async () => { - const start = Date.now() - while (Date.now() - start < 5000) { - const msgs = await MessageV2.filterCompacted(MessageV2.stream(chat.id)) - const taskMsg = msgs.find((item) => item.info.role === "assistant") - const tool = taskMsg ? toolPart(taskMsg.parts) : undefined - if (tool?.state.status === "running" && tool.state.metadata?.output.includes("first")) return - await new Promise((done) => setTimeout(done, 20)) - } - throw new Error("timed out waiting for running shell metadata") - }) - - const exit = yield* Fiber.await(fiber) - expect(Exit.isSuccess(exit)).toBe(true) - }), - { git: true, config: cfg }, - ), - ), - 30_000, -) - -it.live( - "loop waits while shell runs and starts after shell exits", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ - title: "Pinned", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - yield* llm.text("after-shell") - - const sh = yield* prompt - .shell({ sessionID: chat.id, agent: "code", command: "sleep 0.2" }) - .pipe(Effect.forkChild) - yield* Effect.sleep(50) - - const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.sleep(50) - - expect(yield* llm.calls).toBe(0) - - yield* Fiber.await(sh) - const exit = yield* Fiber.await(loop) - - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value.info.role).toBe("assistant") - expect(exit.value.parts.some((part) => part.type === "text" && part.text === "after-shell")).toBe(true) - } - expect(yield* llm.calls).toBe(1) - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -// kilocode_change start - shell process timing is unreliable on Windows CI; -// aligns with every other shell-* test in this file that uses `unix(...)`. -unix( - // kilocode_change end - "shell completion resumes queued loop callers", - () => - provideTmpdirServer( - Effect.fnUntraced(function* ({ llm }) { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ - title: "Pinned", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - yield* llm.text("done") - - const sh = yield* prompt - .shell({ sessionID: chat.id, agent: "code", command: "sleep 0.2" }) - .pipe(Effect.forkChild) - yield* Effect.sleep(50) - - const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.sleep(50) - - expect(yield* llm.calls).toBe(0) - - yield* Fiber.await(sh) - const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) - - expect(Exit.isSuccess(ea)).toBe(true) - expect(Exit.isSuccess(eb)).toBe(true) - if (Exit.isSuccess(ea) && Exit.isSuccess(eb)) { - expect(ea.value.info.id).toBe(eb.value.info.id) - expect(ea.value.info.role).toBe("assistant") - } - expect(yield* llm.calls).toBe(1) - }), - { git: true, config: providerCfg }, - ), - 10_000, // kilocode_change -) - -// kilocode_change start - TODO(#8990): flaky on Linux CI -unixSkip( - "cancel interrupts shell and resolves cleanly", - () => - withSh(() => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const { prompt, run, chat } = yield* boot() - - const sh = yield* prompt - .shell({ sessionID: chat.id, agent: "code", command: "sleep 30" }) - .pipe(Effect.forkChild) - yield* Effect.sleep(50) - - yield* prompt.cancel(chat.id) - - const status = yield* SessionStatus.Service - expect((yield* status.get(chat.id)).type).toBe("idle") - const busy = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) - expect(Exit.isSuccess(busy)).toBe(true) - - const exit = yield* Fiber.await(sh) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value.info.role).toBe("assistant") - const tool = completedTool(exit.value.parts) - if (tool) { - expect(tool.state.output).toContain("User aborted the command") - } - } - }), - { git: true, config: cfg }, - ), - ), - 30_000, -) -// kilocode_change end - -// kilocode_change start - TODO(#8990): flaky on Linux CI -unixSkip( - "cancel persists aborted shell result when shell ignores TERM", - () => - withSh(() => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const { prompt, chat } = yield* boot() - - const sh = yield* prompt - .shell({ sessionID: chat.id, agent: "code", command: "trap '' TERM; sleep 30" }) - .pipe(Effect.forkChild) - yield* Effect.sleep(50) - - yield* prompt.cancel(chat.id) - - const exit = yield* Fiber.await(sh) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isSuccess(exit)) { - expect(exit.value.info.role).toBe("assistant") - const tool = completedTool(exit.value.parts) - if (tool) { - expect(tool.state.output).toContain("User aborted the command") - } - } - }), - { git: true, config: cfg }, - ), - ), - 30_000, -) -// kilocode_change end - -unix( - "cancel finalizes interrupted bash tool output through normal truncation", - () => - provideTmpdirServer( - ({ dir, llm }) => - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ - title: "Interrupted bash truncation", - permission: [{ permission: "*", pattern: "*", action: "allow" }], - }) - - yield* prompt.prompt({ - sessionID: chat.id, - agent: "build", - noReply: true, - parts: [{ type: "text", text: "run bash" }], - }) - - yield* llm.tool("bash", { - command: - 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; sleep 30', - description: "Print many lines", - timeout: 30_000, - workdir: path.resolve(dir), - }) - - const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* llm.wait(1) - yield* Effect.sleep(150) - yield* prompt.cancel(chat.id) - - const exit = yield* Fiber.await(run) - expect(Exit.isSuccess(exit)).toBe(true) - if (Exit.isFailure(exit)) return - - const tool = completedTool(exit.value.parts) - if (!tool) return - - expect(tool.state.metadata.truncated).toBe(true) - expect(typeof tool.state.metadata.outputPath).toBe("string") - expect(tool.state.output).toMatch(/\.\.\.output truncated\.\.\./) - expect(tool.state.output).toMatch(/Full output saved to:\s+\S+/) - expect(tool.state.output).not.toContain("Tool execution aborted") - }), - { git: true, config: providerCfg }, - ), - 30_000, -) - -// kilocode_change start - TODO(#8990): flaky on Linux CI -unixSkip( - "cancel interrupts loop queued behind shell", - () => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const { prompt, chat } = yield* boot() - - const sh = yield* prompt - .shell({ sessionID: chat.id, agent: "code", command: "sleep 30" }) - .pipe(Effect.forkChild) - yield* Effect.sleep(50) - - const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - yield* Effect.sleep(50) - - yield* prompt.cancel(chat.id) - - const exit = yield* Fiber.await(loop) - expect(Exit.isSuccess(exit)).toBe(true) - - yield* Fiber.await(sh) - }), - { git: true, config: cfg }, - ), - 30_000, -) -// kilocode_change end - -unix( - "shell rejects when another shell is already running", - () => - withSh(() => - provideTmpdirInstance( - (_dir) => - Effect.gen(function* () { - const { prompt, chat } = yield* boot() - - const a = yield* prompt - .shell({ sessionID: chat.id, agent: "code", command: "sleep 30" }) - .pipe(Effect.forkChild) - yield* Effect.sleep(50) - - const exit = yield* prompt - .shell({ sessionID: chat.id, agent: "code", command: "echo hi" }) - .pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - if (Exit.isFailure(exit)) { - expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) - } - - yield* prompt.cancel(chat.id) - yield* Fiber.await(a) - }), - { git: true, config: cfg }, - ), - ), - 30_000, -) - -// Abort signal propagation tests for inline tool execution - -/** Override a tool's execute to hang until aborted. Returns ready/aborted defers and a finalizer. */ -function hangUntilAborted(tool: { execute: (...args: any[]) => any }) { - const ready = defer() - const aborted = defer() - const original = tool.execute - tool.execute = (_args: any, ctx: any) => { - ready.resolve() - ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) - return Effect.callback(() => {}) - } - const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original))) - return { ready, aborted, restore } -} - -it.live( - "interrupt propagates abort signal to read tool via file part (text/plain)", - () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const { read } = yield* registry.named() - const { ready, aborted, restore } = hangUntilAborted(read) - yield* restore - - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Abort Test" }) - - const testFile = path.join(dir, "test.txt") - yield* Effect.promise(() => Bun.write(testFile, "hello world")) - - const fiber = yield* prompt - .prompt({ - sessionID: chat.id, - agent: "build", - parts: [ - { type: "text", text: "read this" }, - { type: "file", url: `file://${testFile}`, filename: "test.txt", mime: "text/plain" }, - ], - }) - .pipe(Effect.forkChild) - - yield* Effect.promise(() => ready.promise) - yield* Fiber.interrupt(fiber) - - yield* Effect.promise(() => - Promise.race([ - aborted.promise, - new Promise((_, reject) => - setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000), - ), - ]), - ) - }), - { git: true, config: cfg }, - ), - 30_000, -) - -it.live( - "interrupt propagates abort signal to read tool via file part (directory)", - () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const { read } = yield* registry.named() - const { ready, aborted, restore } = hangUntilAborted(read) - yield* restore - - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const chat = yield* sessions.create({ title: "Abort Test" }) - - const fiber = yield* prompt - .prompt({ - sessionID: chat.id, - agent: "build", - parts: [ - { type: "text", text: "read this" }, - { type: "file", url: `file://${dir}`, filename: "dir", mime: "application/x-directory" }, - ], - }) - .pipe(Effect.forkChild) - - yield* Effect.promise(() => ready.promise) - yield* Fiber.interrupt(fiber) - - yield* Effect.promise(() => - Promise.race([ - aborted.promise, - new Promise((_, reject) => - setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000), - ), - ]), - ) - }), - { git: true, config: cfg }, - ), - 30_000, -) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 2fc2595cc22..82f4e2c3c99 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1,23 +1,63 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { FetchHttpClient } from "effect/unstable/http" +import { expect } from "bun:test" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" import path from "path" -import fs from "fs/promises" -import { describe, expect, test } from "bun:test" -import { NamedError } from "@opencode-ai/shared/util/error" import { fileURLToPath } from "url" -import { Effect, Layer } from "effect" -import { Instance } from "../../src/project/instance" +import { NamedError } from "@opencode-ai/shared/util/error" +import { Agent as AgentSvc } from "../../src/agent/agent" +import { Bus } from "../../src/bus" +import { Command } from "../../src/command" +import { Config } from "../../src/config" +import { LSP } from "../../src/lsp" +import { MCP } from "../../src/mcp" +import { Permission } from "../../src/permission" +import { Plugin } from "../../src/plugin" +import { Provider as ProviderSvc } from "../../src/provider" +import { Env } from "../../src/env" import { ModelID, ProviderID } from "../../src/provider/schema" +import { Question } from "../../src/question" +import { Todo } from "../../src/session/todo" import { Session } from "../../src/session" +import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import { SessionCompaction } from "../../src/session/compaction" +import { SessionSummary } from "../../src/session/summary" +import { Instruction } from "../../src/session/instruction" +import { SessionProcessor } from "../../src/session/processor" import { SessionPrompt } from "../../src/session/prompt" +import { SessionRevert } from "../../src/session/revert" +import { SessionRunState } from "../../src/session/run-state" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { SessionStatus } from "../../src/session/status" +import { Skill } from "../../src/skill" +import { SystemPrompt } from "../../src/session/system" +import { Shell } from "../../src/shell/shell" +import { Snapshot } from "../../src/snapshot" +import { ToolRegistry } from "../../src/tool" +import { Truncate } from "../../src/tool" import { Log } from "../../src/util" -import { tmpdir } from "../fixture/fixture" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Ripgrep } from "../../src/file/ripgrep" +import { Format } from "../../src/format" +import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { reply, TestLLMServer } from "../lib/llm-server" void Log.init({ print: false }) +const summary = Layer.succeed( + SessionSummary.Service, + SessionSummary.Service.of({ + summarize: () => Effect.void, + diff: () => Effect.succeed([]), + computeDiff: () => Effect.succeed([]), + }), +) -function run(fx: Effect.Effect) { - return Effect.runPromise( - fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))), - ) +const ref = { + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), } function defer() { @@ -28,687 +68,1892 @@ function defer() { return { promise, resolve } } -function chat(text: string) { - const payload = - [ - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: { role: "assistant" } }], - })}`, - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: { content: text } }], - })}`, - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: {}, finish_reason: "stop" }], - })}`, - "data: [DONE]", - ].join("\n\n") + "\n\n" - - const encoder = new TextEncoder() - return new ReadableStream({ - start(ctrl) { - ctrl.enqueue(encoder.encode(payload)) - ctrl.close() - }, - }) +function withSh(fx: () => Effect.Effect) { + return Effect.acquireUseRelease( + Effect.sync(() => { + const prev = process.env.SHELL + process.env.SHELL = "/bin/sh" + Shell.preferred.reset() + return prev + }), + () => fx(), + (prev) => + Effect.sync(() => { + if (prev === undefined) delete process.env.SHELL + else process.env.SHELL = prev + Shell.preferred.reset() + }), + ) } -function hanging(ready: () => void) { - const encoder = new TextEncoder() - let timer: ReturnType | undefined - const first = `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: { role: "assistant" } }], - })}\n\n` - const rest = - [ - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: { content: "late" } }], - })}`, - `data: ${JSON.stringify({ - id: "chatcmpl-1", - object: "chat.completion.chunk", - choices: [{ delta: {}, finish_reason: "stop" }], - })}`, - "data: [DONE]", - ].join("\n\n") + "\n\n" - - return new ReadableStream({ - start(ctrl) { - ctrl.enqueue(encoder.encode(first)) - ready() - timer = setTimeout(() => { - ctrl.enqueue(encoder.encode(rest)) - ctrl.close() - }, 10000) - }, - cancel() { - if (timer) clearTimeout(timer) - }, - }) +function toolPart(parts: MessageV2.Part[]) { + return parts.find((part): part is MessageV2.ToolPart => part.type === "tool") } -describe("session.prompt missing file", () => { - test("does not fail the prompt when a file part is missing", async () => { - await using tmp = await tmpdir({ - git: true, - config: { - agent: { - build: { - model: "openai/gpt-5.2", - }, +type CompletedToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateCompleted } +type ErrorToolPart = MessageV2.ToolPart & { state: MessageV2.ToolStateError } + +function completedTool(parts: MessageV2.Part[]) { + const part = toolPart(parts) + expect(part?.state.status).toBe("completed") + return part?.state.status === "completed" ? (part as CompletedToolPart) : undefined +} + +function errorTool(parts: MessageV2.Part[]) { + const part = toolPart(parts) + expect(part?.state.status).toBe("error") + return part?.state.status === "error" ? (part as ErrorToolPart) : undefined +} + +const mcp = Layer.succeed( + MCP.Service, + MCP.Service.of({ + status: () => Effect.succeed({}), + clients: () => Effect.succeed({}), + tools: () => Effect.succeed({}), + prompts: () => Effect.succeed({}), + resources: () => Effect.succeed({}), + add: () => Effect.succeed({ status: { status: "disabled" as const } }), + connect: () => Effect.void, + disconnect: () => Effect.void, + getPrompt: () => Effect.succeed(undefined), + readResource: () => Effect.succeed(undefined), + startAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + authenticate: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + finishAuth: () => Effect.die("unexpected MCP auth in prompt-effect tests"), + removeAuth: () => Effect.void, + supportsOAuth: () => Effect.succeed(false), + hasStoredTokens: () => Effect.succeed(false), + getAuthStatus: () => Effect.succeed("not_authenticated" as const), + }), +) + +const lsp = Layer.succeed( + LSP.Service, + LSP.Service.of({ + init: () => Effect.void, + status: () => Effect.succeed([]), + hasClients: () => Effect.succeed(false), + touchFile: () => Effect.void, + diagnostics: () => Effect.succeed({}), + hover: () => Effect.succeed(undefined), + definition: () => Effect.succeed([]), + references: () => Effect.succeed([]), + implementation: () => Effect.succeed([]), + documentSymbol: () => Effect.succeed([]), + workspaceSymbol: () => Effect.succeed([]), + prepareCallHierarchy: () => Effect.succeed([]), + incomingCalls: () => Effect.succeed([]), + outgoingCalls: () => Effect.succeed([]), + }), +) + +const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer)) +const run = SessionRunState.layer.pipe(Layer.provide(status)) +const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer) +function makeHttp() { + const deps = Layer.mergeAll( + Session.defaultLayer, + Snapshot.defaultLayer, + LLM.defaultLayer, + Env.defaultLayer, + AgentSvc.defaultLayer, + Command.defaultLayer, + Permission.defaultLayer, + Plugin.defaultLayer, + Config.defaultLayer, + ProviderSvc.defaultLayer, + lsp, + mcp, + AppFileSystem.defaultLayer, + status, + ).pipe(Layer.provideMerge(infra)) + const question = Question.layer.pipe(Layer.provideMerge(deps)) + const todo = Todo.layer.pipe(Layer.provideMerge(deps)) + const registry = ToolRegistry.layer.pipe( + Layer.provide(Skill.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(CrossSpawnSpawner.defaultLayer), + Layer.provide(Ripgrep.defaultLayer), + Layer.provide(Format.defaultLayer), + Layer.provideMerge(todo), + Layer.provideMerge(question), + Layer.provideMerge(deps), + ) + const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)) + const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) + return Layer.mergeAll( + TestLLMServer.layer, + SessionPrompt.layer.pipe( + Layer.provide(SessionRevert.defaultLayer), + Layer.provide(summary), + Layer.provideMerge(run), + Layer.provideMerge(compact), + Layer.provideMerge(proc), + Layer.provideMerge(registry), + Layer.provideMerge(trunc), + Layer.provide(Instruction.defaultLayer), + Layer.provide(SystemPrompt.defaultLayer), + Layer.provideMerge(deps), + ), + ).pipe(Layer.provide(summary)) +} + +const it = testEffect(makeHttp()) +const unix = process.platform !== "win32" ? it.live : it.live.skip +const unixSkip = it.live.skip // kilocode_change - TODO(#8990): skip flaky cancel tests on Linux CI + +// Config that registers a custom "test" provider with a "test-model" model +// so provider model lookup succeeds inside the loop. +const cfg = { + provider: { + test: { + name: "Test", + id: "test", + env: [], + npm: "@ai-sdk/openai-compatible", + models: { + "test-model": { + id: "test-model", + name: "Test Model", + attachment: false, + reasoning: false, + temperature: false, + tool_call: true, + release_date: "2025-01-01", + limit: { context: 100000, output: 10000 }, + cost: { input: 0, output: 0 }, + options: {}, }, }, - }) + options: { + apiKey: "test-key", + baseURL: "http://localhost:1/v1", + }, + }, + }, +} - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) - - const missing = path.join(tmp.path, "does-not-exist.ts") - const msg = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - parts: [ - { type: "text", text: "please review @does-not-exist.ts" }, - { - type: "file", - mime: "text/plain", - url: `file://${missing}`, - filename: "does-not-exist.ts", - }, - ], - }) - - if (msg.info.role !== "user") throw new Error("expected user message") - - const hasFailure = msg.parts.some( - (part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"), - ) - expect(hasFailure).toBe(true) - - yield* sessions.remove(session.id) - }), - ), - }) - }) - - test("keeps stored part order stable when file resolution is async", async () => { - await using tmp = await tmpdir({ - git: true, - config: { - agent: { - build: { - model: "openai/gpt-5.2", - }, +function providerCfg(url: string) { + return { + ...cfg, + provider: { + ...cfg.provider, + test: { + ...cfg.provider.test, + options: { + ...cfg.provider.test.options, + baseURL: url, }, }, - }) + }, + } +} - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) - - const missing = path.join(tmp.path, "still-missing.ts") - const msg = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - parts: [ - { - type: "file", - mime: "text/plain", - url: `file://${missing}`, - filename: "still-missing.ts", - }, - { type: "text", text: "after-file" }, - ], - }) - - if (msg.info.role !== "user") throw new Error("expected user message") - - const stored = MessageV2.get({ - sessionID: session.id, - messageID: msg.info.id, - }) - const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text) - - expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true) - expect(text[1]?.includes("Read tool failed to read")).toBe(true) - expect(text[2]).toBe("after-file") - - yield* sessions.remove(session.id) - }), - ), - }) +const user = Effect.fn("test.user")(function* (sessionID: SessionID, text: string) { + const session = yield* Session.Service + const msg = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID, + agent: "build", + model: ref, + time: { created: Date.now() }, }) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: msg.id, + sessionID, + type: "text", + text, + }) + return msg }) -describe("session.prompt special characters", () => { - test("handles filenames with # character", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Bun.write(path.join(dir, "file#name.txt"), "special content\n") - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) - const template = "Read @file#name.txt" - const parts = yield* prompt.resolvePromptParts(template) - const fileParts = parts.filter((part) => part.type === "file") - - expect(fileParts.length).toBe(1) - expect(fileParts[0].filename).toBe("file#name.txt") - expect(fileParts[0].url).toContain("%23") - - const decodedPath = fileURLToPath(fileParts[0].url) - expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt")) - - const message = yield* prompt.prompt({ - sessionID: session.id, - parts, - noReply: true, - }) - const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id }) - const textParts = stored.parts.filter((part) => part.type === "text") - const hasContent = textParts.some((part) => part.text.includes("special content")) - expect(hasContent).toBe(true) - - yield* sessions.remove(session.id) - }), - ), - }) +const seed = Effect.fn("test.seed")(function* (sessionID: SessionID, opts?: { finish?: string }) { + const session = yield* Session.Service + const msg = yield* user(sessionID, "hello") + const assistant: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: msg.id, + sessionID, + mode: "build", + agent: "build", + cost: 0, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + ...(opts?.finish ? { finish: opts.finish } : {}), + } + yield* session.updateMessage(assistant) + yield* session.updatePart({ + id: PartID.ascending(), + messageID: assistant.id, + sessionID, + type: "text", + text: "hi there", }) + return { user: msg, assistant } }) -describe("session.prompt regression", () => { - test("does not loop empty assistant turns for a simple reply", async () => { - let calls = 0 - const server = Bun.serve({ - port: 0, - fetch(req) { - const url = new URL(req.url) - if (!url.pathname.endsWith("/chat/completions")) { - return new Response("not found", { status: 404 }) - } - calls++ - return new Response(chat("packages/opencode/src/session/processor.ts"), { - status: 200, - headers: { "Content-Type": "text/event-stream" }, +const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) => + Effect.gen(function* () { + const session = yield* Session.Service + yield* session.updatePart({ + id: PartID.ascending(), + messageID, + sessionID, + type: "subtask", + prompt: "look into the cache key path", + description: "inspect bug", + agent: "general", + model, + }) + }) + +const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) { + const prompt = yield* SessionPrompt.Service + const run = yield* SessionRunState.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create(input ?? { title: "Pinned" }) + return { prompt, run, sessions, chat } +}) + +// Loop semantics + +it.live("loop exits immediately when last assistant has stop finish", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* seed(chat.id, { finish: "stop" }) + + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") expect(result.info.finish).toBe("stop") + expect(yield* llm.calls).toBe(0) + }), + { git: true, config: providerCfg }, + ), +) + +it.live("loop calls LLM and returns assistant message", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + yield* llm.text("world") + + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(result.info.role).toBe("assistant") + const parts = result.parts.filter((p) => p.type === "text") + expect(parts.some((p) => p.type === "text" && p.text === "world")).toBe(true) + expect(yield* llm.hits).toHaveLength(1) + }), + { git: true, config: providerCfg }, + ), +) + +it.live("static loop returns assistant text through local provider", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Prompt provider", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + + yield* llm.text("world") + + const result = yield* prompt.loop({ sessionID: session.id }) + expect(result.info.role).toBe("assistant") + expect(result.parts.some((part) => part.type === "text" && part.text === "world")).toBe(true) + expect(yield* llm.hits).toHaveLength(1) + expect(yield* llm.pending).toBe(0) + }), + { git: true, config: providerCfg }, + ), +) + +it.live("static loop consumes queued replies across turns", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Prompt provider turns", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello one" }], + }) + + yield* llm.text("world one") + + const first = yield* prompt.loop({ sessionID: session.id }) + expect(first.info.role).toBe("assistant") + expect(first.parts.some((part) => part.type === "text" && part.text === "world one")).toBe(true) + + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello two" }], + }) + + yield* llm.text("world two") + + const second = yield* prompt.loop({ sessionID: session.id }) + expect(second.info.role).toBe("assistant") + expect(second.parts.some((part) => part.type === "text" && part.text === "world two")).toBe(true) + + expect(yield* llm.hits).toHaveLength(2) + expect(yield* llm.pending).toBe(0) + }), + { git: true, config: providerCfg }, + ), +) + +it.live("loop continues when finish is tool-calls", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + yield* llm.tool("first", { value: "first" }) + yield* llm.text("second") + + const result = yield* prompt.loop({ sessionID: session.id }) + expect(yield* llm.calls).toBe(2) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) + expect(result.info.finish).toBe("stop") + } + }), + { git: true, config: providerCfg }, + ), +) + +// kilocode_change - skipped: tracked in #9958 +it.live.skip("glob tool keeps instance context during prompt runs", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Glob context", + permission: [{ permission: "*", pattern: "*", action: "allow" }], }) - }, - }) + const file = path.join(dir, "probe.txt") + yield* Effect.promise(() => Bun.write(file, "probe")) - try { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - enabled_providers: ["alibaba"], - provider: { - alibaba: { - options: { - apiKey: "test-key", - baseURL: `${server.url.origin}/v1`, - }, - }, - }, - agent: { - build: { - model: "alibaba/qwen-plus", - }, - }, - }), + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "find text files" }], + }) + yield* llm.tool("glob", { pattern: "**/*.txt" }) + yield* llm.text("done") + + const result = yield* prompt.loop({ sessionID: session.id }) + expect(result.info.role).toBe("assistant") + + const msgs = yield* MessageV2.filterCompactedEffect(session.id) + const tool = msgs + .flatMap((msg) => msg.parts) + .find( + (part): part is CompletedToolPart => + part.type === "tool" && part.tool === "glob" && part.state.status === "completed", ) - }, + if (!tool) return + + expect(tool.state.output).toContain(file) + expect(tool.state.output).not.toContain("No context found for instance") + expect(result.parts.some((part) => part.type === "text" && part.text === "done")).toBe(true) + }), + { git: true, config: providerCfg }, + ), +) + +it.live("loop continues when finish is stop but assistant has tool parts", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], }) - - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "Prompt regression" }) - const result = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - parts: [{ type: "text", text: "Where is SessionProcessor?" }], - }) - - expect(result.info.role).toBe("assistant") - expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true) - - const msgs = yield* sessions.messages({ sessionID: session.id }) - expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1) - expect(calls).toBe(1) - }), - ), + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello" }], }) - } finally { - void server.stop(true) - } - }) + yield* llm.push(reply().tool("first", { value: "first" }).stop()) + yield* llm.text("second") - test("records aborted errors when prompt is cancelled mid-stream", async () => { - const ready = defer() - const server = Bun.serve({ - port: 0, - fetch(req) { - const url = new URL(req.url) - if (!url.pathname.endsWith("/chat/completions")) { - return new Response("not found", { status: 404 }) - } - return new Response( - hanging(() => ready.resolve()), - { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }, - ) - }, - }) + const result = yield* prompt.loop({ sessionID: session.id }) + expect(yield* llm.calls).toBe(2) + expect(result.info.role).toBe("assistant") + if (result.info.role === "assistant") { + expect(result.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) + expect(result.info.finish).toBe("stop") + } + }), + { git: true, config: providerCfg }, + ), +) - try { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - enabled_providers: ["alibaba"], - provider: { - alibaba: { - options: { - apiKey: "test-key", - baseURL: `${server.url.origin}/v1`, - }, - }, - }, - agent: { - build: { - model: "alibaba/qwen-plus", - }, - }, - }), - ) - }, +it.live("failed subtask preserves metadata on error tool state", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.tool("task", { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", }) + yield* llm.text("done") + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({ title: "Prompt cancel regression" }) - const task = Effect.runPromise( - prompt.prompt({ - sessionID: session.id, - agent: "build", - parts: [{ type: "text", text: "Cancel me" }], - }), - ) + const result = yield* prompt.loop({ sessionID: chat.id }) + expect(result.info.role).toBe("assistant") + expect(yield* llm.calls).toBe(2) - yield* Effect.promise(() => ready.promise) - yield* prompt.cancel(session.id) + const msgs = yield* MessageV2.filterCompactedEffect(chat.id) + const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + expect(taskMsg?.info.role).toBe("assistant") + if (!taskMsg || taskMsg.info.role !== "assistant") return - const result = yield* Effect.promise(() => - Promise.race([ - task, - new Promise((_, reject) => - setTimeout(() => reject(new Error("timed out waiting for cancel")), 1000), - ), - ]), - ) + const tool = errorTool(taskMsg.parts) + if (!tool) return - expect(result.info.role).toBe("assistant") - if (result.info.role === "assistant") { - expect(result.info.error?.name).toBe("MessageAbortedError") - } - - const msgs = yield* sessions.messages({ sessionID: session.id }) - const last = msgs.findLast((msg) => msg.info.role === "assistant") - expect(last?.info.role).toBe("assistant") - if (last?.info.role === "assistant") { - expect(last.info.error?.name).toBe("MessageAbortedError") - } - }), - ), + expect(tool.state.error).toContain("Tool execution failed") + expect(tool.state.metadata).toBeDefined() + expect(tool.state.metadata?.sessionId).toBeDefined() + expect(tool.state.metadata?.model).toEqual({ + providerID: ProviderID.make("test"), + modelID: ModelID.make("missing-model"), }) - } finally { - void server.stop(true) - } - }) -}) - -describe("session.prompt agent variant", () => { - test("applies agent variant only when using agent model", async () => { - const prev = process.env.OPENAI_API_KEY - process.env.OPENAI_API_KEY = "test-openai-key" - - try { - await using tmp = await tmpdir({ - git: true, - config: { - agent: { - build: { - model: "openai/gpt-5.2", - variant: "xhigh", - }, + }), + { + git: true, + config: (url) => ({ + ...providerCfg(url), + agent: { + general: { + model: "test/missing-model", }, }, - }) + }), + }, + ), +) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) +it.live( + "running subtask preserves metadata after tool-call transition", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) - const other = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") }, - noReply: true, - parts: [{ type: "text", text: "hello" }], - }) - if (other.info.role !== "user") throw new Error("expected user message") - expect(other.info.model.variant).toBeUndefined() + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) - const match = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - parts: [{ type: "text", text: "hello again" }], - }) - if (match.info.role !== "user") throw new Error("expected user message") - expect(match.info.model).toEqual({ - providerID: ProviderID.make("openai"), - modelID: ModelID.make("gpt-5.2"), - variant: "xhigh", - }) - expect(match.info.model.variant).toBe("xhigh") - - const override = yield* prompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - variant: "high", - parts: [{ type: "text", text: "hello third" }], - }) - if (override.info.role !== "user") throw new Error("expected user message") - expect(override.info.model.variant).toBe("high") - - yield* sessions.remove(session.id) - }), - ), - }) - } finally { - if (prev === undefined) delete process.env.OPENAI_API_KEY - else process.env.OPENAI_API_KEY = prev - } - }) -}) - -// kilocode_change start -function deferred() { - const result = {} as { promise: Promise; resolve: (value: T) => void } - result.promise = new Promise((resolve) => { - result.resolve = resolve - }) - return result -} - -describe("session.prompt abort", () => { - test("returns the interrupted assistant turn when the current prompt is cancelled", async () => { - const dir = path.dirname(fileURLToPath(import.meta.url)) - const fixtures = (await Bun.file(path.join(dir, "../tool/fixtures/models-api.json")).json()) as Record< - string, - { models: Record } & Record - > - const model = fixtures.openai.models["gpt-5.2"] - const started = deferred() - const payload = new TextEncoder().encode( - [ - `data: ${JSON.stringify({ - type: "response.created", - response: { - id: "resp-1", - created_at: Math.floor(Date.now() / 1000), - model: model.id, - service_tier: null, - }, - })}`, - "", - ].join("\n\n"), - ) - const server = Bun.serve({ - port: 0, - fetch(req: Request) { - const url = new URL(req.url) - if (!url.pathname.endsWith("/responses")) { - return new Response("unexpected request", { status: 404 }) - } - started.resolve() - return new Response( - new ReadableStream({ - start(controller) { - controller.enqueue(payload) - }, - }), - { - status: 200, - headers: { "Content-Type": "text/event-stream" }, - }, - ) - }, - }) - - try { - await using tmp = await tmpdir({ - git: true, - init: async (root) => { - // kilocode_change start — project config must be at root, not in .opencode/ subdirectory - await Bun.write( - path.join(root, "opencode.json"), - JSON.stringify({ - $schema: "https://app.kilo.ai/config.json", - enabled_providers: ["openai"], - provider: { - openai: { - name: "OpenAI", - env: ["OPENAI_API_KEY"], - npm: "@ai-sdk/openai", - api: "https://api.openai.com/v1", - models: { - [model.id]: model, - }, - options: { - apiKey: "test-openai-key", - baseURL: `${server.url.origin}/v1`, - }, - }, - }, - }), - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const session = await Session.create({}) - const run = SessionPrompt.prompt({ - sessionID: session.id, - model: { - providerID: ProviderID.make("openai"), - modelID: ModelID.make(model.id), - }, - parts: [{ type: "text", text: "say hello" }], - }) - - await started.promise - SessionPrompt.cancel(session.id) - - const result = await run - expect(result.info.role).toBe("assistant") - if (result.info.role !== "assistant") throw new Error("expected assistant message") - - // kilocode_change start — re-read from DB; the abort error is set asynchronously by the processor - const messages = await Session.messages({ sessionID: session.id }) - const assistant = messages.find((item) => item.info.role === "assistant") - expect(assistant).toBeDefined() - expect(assistant?.info.id).toBe(result.info.id) - if (assistant?.info.role === "assistant" && assistant.info.error) { - expect(assistant.info.error.name).toBe("MessageAbortedError") + const tool = yield* Effect.promise(async () => { + const end = Date.now() + 5_000 + while (Date.now() < end) { + const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id)) + const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + const tool = taskMsg?.parts.find((part): part is MessageV2.ToolPart => part.type === "tool") + if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool + await new Promise((done) => setTimeout(done, 20)) } - // kilocode_change end + throw new Error("timed out waiting for running subtask metadata") + }) - await Session.remove(session.id) - }, - }) - } finally { - server.stop(true) - } - }, 15000) -}) + if (tool.state.status !== "running") return + expect(typeof tool.state.metadata?.sessionId).toBe("string") + expect(tool.state.title).toBeDefined() + expect(tool.state.metadata?.model).toBeDefined() + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: providerCfg }, + ), + 5_000, +) + +it.live( + "running task tool preserves metadata after tool-call transition", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* llm.tool("task", { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }) + yield* llm.hang + yield* user(chat.id, "hello") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + + const tool = yield* Effect.promise(async () => { + const end = Date.now() + 5_000 + while (Date.now() < end) { + const msgs = await Effect.runPromise(MessageV2.filterCompactedEffect(chat.id)) + const assistant = msgs.findLast((item) => item.info.role === "assistant" && item.info.agent === "code") // kilocode_change + const tool = assistant?.parts.find( + (part): part is MessageV2.ToolPart => part.type === "tool" && part.tool === "task", + ) + if (tool?.state.status === "running" && tool.state.metadata?.sessionId) return tool + await new Promise((done) => setTimeout(done, 20)) + } + throw new Error("timed out waiting for running task metadata") + }) + + if (tool.state.status !== "running") return + expect(typeof tool.state.metadata?.sessionId).toBe("string") + expect(tool.state.title).toBe("inspect bug") + expect(tool.state.metadata?.model).toBeDefined() + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: providerCfg }, + ), + 10_000, +) + +it.live( + "loop sets status to busy then idle", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const status = yield* SessionStatus.Service + + yield* llm.hang + + const chat = yield* sessions.create({}) + yield* user(chat.id, "hi") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + expect((yield* status.get(chat.id)).type).toBe("busy") + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + expect((yield* status.get(chat.id)).type).toBe("idle") + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +// Cancel semantics + +it.live( + "cancel interrupts loop and resolves with an assistant message", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* seed(chat.id) + + yield* llm.hang + + yield* user(chat.id, "more") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* prompt.cancel(chat.id) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + } + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +it.live( + "cancel records MessageAbortedError on interrupted process", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + yield* user(chat.id, "hello") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* prompt.cancel(chat.id) + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + const info = exit.value.info + if (info.role === "assistant") { + expect(info.error?.name).toBe("MessageAbortedError") + } + } + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +it.live( + "cancel finalizes subtask tool state", + () => + provideTmpdirInstance( + () => + Effect.gen(function* () { + const ready = defer() + const aborted = defer() + const registry = yield* ToolRegistry.Service + const { task } = yield* registry.named() + const original = task.execute + task.execute = (_args, ctx) => + Effect.callback((_resume) => { + ready.resolve() + ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) + return Effect.sync(() => aborted.resolve()) + }) + yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original))) + + const { prompt, chat } = yield* boot() + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.promise(() => ready.promise) + yield* prompt.cancel(chat.id) + yield* Effect.promise(() => aborted.promise) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + + const msgs = yield* MessageV2.filterCompactedEffect(chat.id) + const taskMsg = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + expect(taskMsg?.info.role).toBe("assistant") + if (!taskMsg || taskMsg.info.role !== "assistant") return + + const tool = toolPart(taskMsg.parts) + expect(tool?.type).toBe("tool") + if (!tool) return + + expect(tool.state.status).not.toBe("running") + expect(taskMsg.info.time.completed).toBeDefined() + expect(taskMsg.info.finish).toBeDefined() + }), + { git: true, config: cfg }, + ), + 30_000, +) + +// kilocode_change start - handleSubtask propagates child session cost to wrapper (#6321) +it.live( + "handleSubtask propagates subagent cost to wrapper message", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const registry = yield* ToolRegistry.Service + const { task } = yield* registry.named() + const original = task.execute + // Simulate task tool: create a child session, persist an assistant with cost, return metadata. + task.execute = (_args, ctx) => + Effect.gen(function* () { + const child = yield* sessions.create({ parentID: ctx.sessionID, title: "subagent" }) + const childAssistant: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: ctx.messageID, + sessionID: child.id, + mode: "general", + agent: "general", + cost: 0.42, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now(), completed: Date.now() }, + finish: "stop", + } + yield* sessions.updateMessage(childAssistant) + yield* ctx.metadata({ + title: "done", + metadata: { sessionId: child.id, model: ref, variant: undefined }, + }) + return { title: "done", metadata: { sessionId: child.id, model: ref, variant: undefined }, output: "done" } + }) + yield* Effect.addFinalizer(() => Effect.sync(() => void (task.execute = original))) + + const chat = yield* sessions.create({ title: "Pinned" }) + const msg = yield* user(chat.id, "hello") + yield* addSubtask(chat.id, msg.id) + // The loop continues past handleSubtask into a normal LLM step; provide one response to exit. + yield* llm.text("wrapped") + + yield* prompt.loop({ sessionID: chat.id }) + + const msgs = yield* MessageV2.filterCompactedEffect(chat.id) + const wrapper = msgs.find((item) => item.info.role === "assistant" && item.info.agent === "general") + expect(wrapper?.info.role).toBe("assistant") + if (!wrapper || wrapper.info.role !== "assistant") return + expect(wrapper.info.cost).toBeCloseTo(0.42, 6) + }), + { git: true, config: providerCfg }, + ), + 30_000, +) // kilocode_change end -describe("session.agent-resolution", () => { - test("unknown agent throws typed error", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( +it.live( + "cancel with queued callers resolves all cleanly", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + yield* user(chat.id, "hello") + + const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.sleep(50) + + yield* prompt.cancel(chat.id) + const [exitA, exitB] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + expect(Exit.isSuccess(exitA)).toBe(true) + expect(Exit.isSuccess(exitB)).toBe(true) + if (Exit.isSuccess(exitA) && Exit.isSuccess(exitB)) { + expect(exitA.value.info.id).toBe(exitB.value.info.id) + } + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +// Queue semantics + +it.live("concurrent loop callers get same result", () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const { prompt, run, chat } = yield* boot() + yield* seed(chat.id, { finish: "stop" }) + + const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { + concurrency: "unbounded", + }) + + expect(a.info.id).toBe(b.info.id) + expect(a.info.role).toBe("assistant") + yield* run.assertNotBusy(chat.id) + }), + { git: true }, + ), +) + +it.live( + "concurrent loop callers all receive same error result", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + yield* llm.fail("boom") + yield* user(chat.id, "hello") + + const [a, b] = yield* Effect.all([prompt.loop({ sessionID: chat.id }), prompt.loop({ sessionID: chat.id })], { + concurrency: "unbounded", + }) + expect(a.info.id).toBe(b.info.id) + expect(a.info.role).toBe("assistant") + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +// kilocode_change start - #9492: the upstream fork-based shape of this test +// loses Instance AsyncLocalStorage context in the second forked prompt, which +// surfaces as a "No context found for instance" die before the queue behavior +// can be exercised. The Kilo queue semantics (in-flight stream drains, second +// LLM request ends with the queued user message) are covered end-to-end in +// packages/opencode/test/kilocode/session-prompt-queue.test.ts — keep this +// upstream scaffold skipped so future OpenCode merges remain friction-free. +it.live.skip( + "prompt submitted during an active run is included in the next LLM input", + // kilocode_change end + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const gate = defer() + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + + yield* llm.hold("first", gate.promise) + yield* llm.text("second") + + const a = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "first" }], + }) + .pipe(Effect.forkChild) + + yield* llm.wait(1) + + const id = MessageID.ascending() + const b = yield* prompt + .prompt({ + sessionID: chat.id, + messageID: id, + agent: "build", + model: ref, + parts: [{ type: "text", text: "second" }], + }) + .pipe(Effect.forkChild) + + yield* Effect.promise(async () => { + const end = Date.now() + 5000 + while (Date.now() < end) { + const msgs = await Effect.runPromise(sessions.messages({ sessionID: chat.id })) + if (msgs.some((msg) => msg.info.role === "user" && msg.info.id === id)) return + await new Promise((done) => setTimeout(done, 20)) + } + throw new Error("timed out waiting for second prompt to save") + }) + + gate.resolve() + + const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + expect(Exit.isSuccess(ea)).toBe(true) + expect(Exit.isSuccess(eb)).toBe(true) + expect(yield* llm.calls).toBe(2) + + const msgs = yield* sessions.messages({ sessionID: chat.id }) + const assistants = msgs.filter((msg) => msg.info.role === "assistant") + expect(assistants).toHaveLength(2) + const last = assistants.at(-1) + if (!last || last.info.role !== "assistant") throw new Error("expected second assistant") + expect(last.info.parentID).toBe(id) + expect(last.parts.some((part) => part.type === "text" && part.text === "second")).toBe(true) + + const inputs = yield* llm.inputs + expect(inputs).toHaveLength(2) + expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("second") + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +it.live( + "assertNotBusy throws BusyError when loop running", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const run = yield* SessionRunState.Service + const sessions = yield* Session.Service + yield* llm.hang + + const chat = yield* sessions.create({}) + yield* user(chat.id, "hi") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + + const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) + } + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +it.live("assertNotBusy succeeds when idle", () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const run = yield* SessionRunState.Service + const sessions = yield* Session.Service + + const chat = yield* sessions.create({}) + const exit = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isSuccess(exit)).toBe(true) + }), + { git: true }, + ), +) + +// Shell semantics + +it.live( + "shell rejects with BusyError when loop running", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Pinned" }) + yield* llm.hang + yield* user(chat.id, "hi") + + const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + + const exit = yield* prompt.shell({ sessionID: chat.id, agent: "build", command: "echo hi" }).pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) + } + + yield* prompt.cancel(chat.id) + yield* Fiber.await(fiber) + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +unix("shell captures stdout and stderr in completed tool output", () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const { prompt, run, chat } = yield* boot() + const result = yield* prompt.shell({ + sessionID: chat.id, + agent: "build", + command: "printf out && printf err >&2", + }) + + expect(result.info.role).toBe("assistant") + const tool = completedTool(result.parts) + if (!tool) return + + expect(tool.state.output).toContain("out") + expect(tool.state.output).toContain("err") + expect(tool.state.metadata.output).toContain("out") + expect(tool.state.metadata.output).toContain("err") + yield* run.assertNotBusy(chat.id) + }), + { git: true, config: cfg }, + ), +) + +unix("shell completes a fast command on the preferred shell", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const { prompt, run, chat } = yield* boot() + const result = yield* prompt.shell({ + sessionID: chat.id, + agent: "build", + command: "pwd", + }) + + expect(result.info.role).toBe("assistant") + const tool = completedTool(result.parts) + if (!tool) return + + expect(tool.state.input.command).toBe("pwd") + expect(tool.state.output).toContain(dir) + expect(tool.state.metadata.output).toContain(dir) + yield* run.assertNotBusy(chat.id) + }), + { git: true, config: cfg }, + ), +) + +unix("shell lists files from the project directory", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const { prompt, run, chat } = yield* boot() + yield* Effect.promise(() => Bun.write(path.join(dir, "README.md"), "# e2e\n")) + + const result = yield* prompt.shell({ + sessionID: chat.id, + agent: "build", + command: "command ls", + }) + + expect(result.info.role).toBe("assistant") + const tool = completedTool(result.parts) + if (!tool) return + + expect(tool.state.input.command).toBe("command ls") + expect(tool.state.output).toContain("README.md") + expect(tool.state.metadata.output).toContain("README.md") + yield* run.assertNotBusy(chat.id) + }), + { git: true, config: cfg }, + ), +) + +unix("shell captures stderr from a failing command", () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const { prompt, run, chat } = yield* boot() + const result = yield* prompt.shell({ + sessionID: chat.id, + agent: "build", + command: "command -v __nonexistent_cmd_e2e__ || echo 'not found' >&2; exit 1", + }) + + expect(result.info.role).toBe("assistant") + const tool = completedTool(result.parts) + if (!tool) return + + expect(tool.state.output).toContain("not found") + expect(tool.state.metadata.output).toContain("not found") + yield* run.assertNotBusy(chat.id) + }), + { git: true, config: cfg }, + ), +) + +unix( + "shell updates running metadata before process exit", + () => + withSh(() => + provideTmpdirInstance( + (_dir) => Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) - const err = yield* Effect.promise(() => - Effect.runPromise( - prompt.prompt({ - sessionID: session.id, - agent: "nonexistent-agent-xyz", - noReply: true, - parts: [{ type: "text", text: "hello" }], - }), - ).then( - () => undefined, - (e) => e, + const { prompt, chat } = yield* boot() + + const fiber = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "printf first && sleep 0.2 && printf second" }) + .pipe(Effect.forkChild) + + yield* Effect.promise(async () => { + const start = Date.now() + while (Date.now() - start < 5000) { + const msgs = await MessageV2.filterCompacted(MessageV2.stream(chat.id)) + const taskMsg = msgs.find((item) => item.info.role === "assistant") + const tool = taskMsg ? toolPart(taskMsg.parts) : undefined + if (tool?.state.status === "running" && tool.state.metadata?.output.includes("first")) return + await new Promise((done) => setTimeout(done, 20)) + } + throw new Error("timed out waiting for running shell metadata") + }) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + }), + { git: true, config: cfg }, + ), + ), + 30_000, +) + +it.live( + "loop waits while shell runs and starts after shell exits", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* llm.text("after-shell") + + const sh = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" }) + .pipe(Effect.forkChild) + yield* Effect.sleep(50) + + const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.sleep(50) + + expect(yield* llm.calls).toBe(0) + + yield* Fiber.await(sh) + const exit = yield* Fiber.await(loop) + + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + expect(exit.value.parts.some((part) => part.type === "text" && part.text === "after-shell")).toBe(true) + } + expect(yield* llm.calls).toBe(1) + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +it.live( + "shell completion resumes queued loop callers", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Pinned", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + yield* llm.text("done") + + const sh = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 0.2" }) + .pipe(Effect.forkChild) + yield* Effect.sleep(50) + + const a = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + const b = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.sleep(50) + + expect(yield* llm.calls).toBe(0) + + yield* Fiber.await(sh) + const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + + expect(Exit.isSuccess(ea)).toBe(true) + expect(Exit.isSuccess(eb)).toBe(true) + if (Exit.isSuccess(ea) && Exit.isSuccess(eb)) { + expect(ea.value.info.id).toBe(eb.value.info.id) + expect(ea.value.info.role).toBe("assistant") + } + expect(yield* llm.calls).toBe(1) + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +// kilocode_change start - TODO(#8990): flaky on Linux CI +unixSkip( + "cancel interrupts shell and resolves cleanly", + () => + withSh(() => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const { prompt, run, chat } = yield* boot() + + const sh = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) + .pipe(Effect.forkChild) + yield* Effect.sleep(50) + + yield* prompt.cancel(chat.id) + + const status = yield* SessionStatus.Service + expect((yield* status.get(chat.id)).type).toBe("idle") + const busy = yield* run.assertNotBusy(chat.id).pipe(Effect.exit) + expect(Exit.isSuccess(busy)).toBe(true) + + const exit = yield* Fiber.await(sh) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + const tool = completedTool(exit.value.parts) + if (tool) { + expect(tool.state.output).toContain("User aborted the command") + } + } + }), + { git: true, config: cfg }, + ), + ), + 30_000, +) +// kilocode_change end + +// kilocode_change start - TODO(#8990): flaky on Linux CI +unixSkip( + "cancel persists aborted shell result when shell ignores TERM", + () => + withSh(() => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const { prompt, chat } = yield* boot() + + const sh = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "trap '' TERM; sleep 30" }) + .pipe(Effect.forkChild) + yield* Effect.sleep(50) + + yield* prompt.cancel(chat.id) + + const exit = yield* Fiber.await(sh) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + const tool = completedTool(exit.value.parts) + if (tool) { + expect(tool.state.output).toContain("User aborted the command") + } + } + }), + { git: true, config: cfg }, + ), + ), + 30_000, +) +// kilocode_change end + +unix( + "cancel finalizes interrupted bash tool output through normal truncation", + () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ + title: "Interrupted bash truncation", + permission: [{ permission: "*", pattern: "*", action: "allow" }], + }) + + yield* prompt.prompt({ + sessionID: chat.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "run bash" }], + }) + + yield* llm.tool("bash", { + command: + 'i=0; while [ "$i" -lt 4000 ]; do printf "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx %05d\\n" "$i"; i=$((i + 1)); done; sleep 30', + description: "Print many lines", + timeout: 30_000, + workdir: path.resolve(dir), + }) + + const run = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* llm.wait(1) + yield* Effect.sleep(150) + yield* prompt.cancel(chat.id) + + const exit = yield* Fiber.await(run) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isFailure(exit)) return + + const tool = completedTool(exit.value.parts) + if (!tool) return + + expect(tool.state.metadata.truncated).toBe(true) + expect(typeof tool.state.metadata.outputPath).toBe("string") + expect(tool.state.output).toMatch(/\.\.\.output truncated\.\.\./) + expect(tool.state.output).toMatch(/Full output saved to:\s+\S+/) + expect(tool.state.output).not.toContain("Tool execution aborted") + }), + { git: true, config: providerCfg }, + ), + 30_000, +) + +// kilocode_change start - TODO(#8990): flaky on Linux CI +unixSkip( + "cancel interrupts loop queued behind shell", + () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const { prompt, chat } = yield* boot() + + const sh = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) + .pipe(Effect.forkChild) + yield* Effect.sleep(50) + + const loop = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkChild) + yield* Effect.sleep(50) + + yield* prompt.cancel(chat.id) + + const exit = yield* Fiber.await(loop) + expect(Exit.isSuccess(exit)).toBe(true) + + yield* Fiber.await(sh) + }), + { git: true, config: cfg }, + ), + 30_000, +) +// kilocode_change end + +unix( + "shell rejects when another shell is already running", + () => + withSh(() => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const { prompt, chat } = yield* boot() + + const a = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "sleep 30" }) + .pipe(Effect.forkChild) + yield* Effect.sleep(50) + + const exit = yield* prompt + .shell({ sessionID: chat.id, agent: "build", command: "echo hi" }) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + expect(Cause.squash(exit.cause)).toBeInstanceOf(Session.BusyError) + } + + yield* prompt.cancel(chat.id) + yield* Fiber.await(a) + }), + { git: true, config: cfg }, + ), + ), + 30_000, +) + +// Abort signal propagation tests for inline tool execution + +/** Override a tool's execute to hang until aborted. Returns ready/aborted defers and a finalizer. */ +function hangUntilAborted(tool: { execute: (...args: any[]) => any }) { + const ready = defer() + const aborted = defer() + const original = tool.execute + tool.execute = (_args: any, ctx: any) => { + ready.resolve() + ctx.abort.addEventListener("abort", () => aborted.resolve(), { once: true }) + return Effect.callback(() => {}) + } + const restore = Effect.addFinalizer(() => Effect.sync(() => void (tool.execute = original))) + return { ready, aborted, restore } +} + +it.live( + "interrupt propagates abort signal to read tool via file part (text/plain)", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const { read } = yield* registry.named() + const { ready, aborted, restore } = hangUntilAborted(read) + yield* restore + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Abort Test" }) + + const testFile = path.join(dir, "test.txt") + yield* Effect.promise(() => Bun.write(testFile, "hello world")) + + const fiber = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + parts: [ + { type: "text", text: "read this" }, + { type: "file", url: `file://${testFile}`, filename: "test.txt", mime: "text/plain" }, + ], + }) + .pipe(Effect.forkChild) + + yield* Effect.promise(() => ready.promise) + yield* Fiber.interrupt(fiber) + + yield* Effect.promise(() => + Promise.race([ + aborted.promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000), ), - ) - expect(err).toBeDefined() + ]), + ) + }), + { git: true, config: cfg }, + ), + 30_000, +) + +it.live( + "interrupt propagates abort signal to read tool via file part (directory)", + () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const registry = yield* ToolRegistry.Service + const { read } = yield* registry.named() + const { ready, aborted, restore } = hangUntilAborted(read) + yield* restore + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const chat = yield* sessions.create({ title: "Abort Test" }) + + const fiber = yield* prompt + .prompt({ + sessionID: chat.id, + agent: "build", + parts: [ + { type: "text", text: "read this" }, + { type: "file", url: `file://${dir}`, filename: "dir", mime: "application/x-directory" }, + ], + }) + .pipe(Effect.forkChild) + + yield* Effect.promise(() => ready.promise) + yield* Fiber.interrupt(fiber) + + yield* Effect.promise(() => + Promise.race([ + aborted.promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("abort signal not propagated within 2s")), 2_000), + ), + ]), + ) + }), + { git: true, config: cfg }, + ), + 30_000, +) + +// Missing file handling + +it.live("does not fail the prompt when a file part is missing", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + + const missing = path.join(dir, "does-not-exist.ts") + const msg = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [ + { type: "text", text: "please review @does-not-exist.ts" }, + { + type: "file", + mime: "text/plain", + url: `file://${missing}`, + filename: "does-not-exist.ts", + }, + ], + }) + + if (msg.info.role !== "user") throw new Error("expected user message") + const hasFailure = msg.parts.some( + (part) => part.type === "text" && part.synthetic && part.text.includes("Read tool failed to read"), + ) + expect(hasFailure).toBe(true) + + yield* sessions.remove(session.id) + }), + { git: true, config: cfg }, + ), +) + +it.live("keeps stored part order stable when file resolution is async", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + + const missing = path.join(dir, "still-missing.ts") + const msg = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [ + { + type: "file", + mime: "text/plain", + url: `file://${missing}`, + filename: "still-missing.ts", + }, + { type: "text", text: "after-file" }, + ], + }) + + if (msg.info.role !== "user") throw new Error("expected user message") + + const stored = MessageV2.get({ + sessionID: session.id, + messageID: msg.info.id, + }) + const text = stored.parts.filter((part) => part.type === "text").map((part) => part.text) + + expect(text[0]?.startsWith("Called the Read tool with the following input:")).toBe(true) + expect(text[1]?.includes("Read tool failed to read")).toBe(true) + expect(text[2]).toBe("after-file") + + yield* sessions.remove(session.id) + }), + { git: true, config: cfg }, + ), +) + +// Special characters in filenames + +it.live("handles filenames with # character", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + yield* Effect.promise(() => Bun.write(path.join(dir, "file#name.txt"), "special content\n")) + + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + const parts = yield* prompt.resolvePromptParts("Read @file#name.txt") + const fileParts = parts.filter((part) => part.type === "file") + + expect(fileParts.length).toBe(1) + expect(fileParts[0].filename).toBe("file#name.txt") + expect(fileParts[0].url).toContain("%23") + + const decodedPath = fileURLToPath(fileParts[0].url) + expect(decodedPath).toBe(path.join(dir, "file#name.txt")) + + const message = yield* prompt.prompt({ + sessionID: session.id, + parts, + noReply: true, + }) + const stored = MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const textParts = stored.parts.filter((part) => part.type === "text") + const hasContent = textParts.some((part) => part.text.includes("special content")) + expect(hasContent).toBe(true) + + yield* sessions.remove(session.id) + }), + { git: true, config: cfg }, + ), +) + +// Regression: empty assistant turn loop + +it.live("does not loop empty assistant turns for a simple reply", () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Prompt regression" }) + + yield* llm.text("packages/opencode/src/session/processor.ts") + + const result = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Where is SessionProcessor?" }], + }) + + expect(result.info.role).toBe("assistant") + expect(result.parts.some((part) => part.type === "text" && part.text.includes("processor.ts"))).toBe(true) + + const msgs = yield* sessions.messages({ sessionID: session.id }) + expect(msgs.filter((msg) => msg.info.role === "assistant")).toHaveLength(1) + expect(yield* llm.calls).toBe(1) + }), + { git: true, config: providerCfg }, + ), +) + +it.live( + "records aborted errors when prompt is cancelled mid-stream", + () => + provideTmpdirServer( + Effect.fnUntraced(function* ({ llm }) { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({ title: "Prompt cancel regression" }) + + yield* llm.hang + + const fiber = yield* prompt + .prompt({ + sessionID: session.id, + agent: "build", + parts: [{ type: "text", text: "Cancel me" }], + }) + .pipe(Effect.forkChild) + + yield* llm.wait(1) + yield* prompt.cancel(session.id) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isSuccess(exit)).toBe(true) + if (Exit.isSuccess(exit)) { + expect(exit.value.info.role).toBe("assistant") + if (exit.value.info.role === "assistant") { + expect(exit.value.info.error?.name).toBe("MessageAbortedError") + } + } + + const msgs = yield* sessions.messages({ sessionID: session.id }) + const last = msgs.findLast((msg) => msg.info.role === "assistant") + expect(last?.info.role).toBe("assistant") + if (last?.info.role === "assistant") { + expect(last.info.error?.name).toBe("MessageAbortedError") + } + }), + { git: true, config: providerCfg }, + ), + 3_000, +) + +// Agent variant + +it.live("applies agent variant only when using agent model", () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + + const other = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + model: { providerID: ProviderID.make("opencode"), modelID: ModelID.make("kimi-k2.5-free") }, + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + if (other.info.role !== "user") throw new Error("expected user message") + expect(other.info.model.variant).toBeUndefined() + + const match = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello again" }], + }) + if (match.info.role !== "user") throw new Error("expected user message") + expect(match.info.model).toEqual({ + providerID: ProviderID.make("test"), + modelID: ModelID.make("test-model"), + variant: "xhigh", + }) + expect(match.info.model.variant).toBe("xhigh") + + const override = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + variant: "high", + parts: [{ type: "text", text: "hello third" }], + }) + if (override.info.role !== "user") throw new Error("expected user message") + expect(override.info.model.variant).toBe("high") + + yield* sessions.remove(session.id) + }), + { + git: true, + config: { + ...cfg, + provider: { + ...cfg.provider, + test: { + ...cfg.provider.test, + models: { + "test-model": { + ...cfg.provider.test.models["test-model"], + variants: { xhigh: {}, high: {} }, + }, + }, + }, + }, + agent: { + build: { + model: "test/test-model", + variant: "xhigh", + }, + }, + }, + }, + ), +) + +// Agent / command resolution errors + +it.live( + "unknown agent throws typed error", + () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + const exit = yield* prompt + .prompt({ + sessionID: session.id, + agent: "nonexistent-agent-xyz", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) expect(err).not.toBeInstanceOf(TypeError) expect(NamedError.Unknown.isInstance(err)).toBe(true) if (NamedError.Unknown.isInstance(err)) { expect(err.data.message).toContain('Agent not found: "nonexistent-agent-xyz"') } - }), - ), - }) - }, 30000) + } + }), + { git: true }, + ), + 30_000, +) - test("unknown agent error includes available agent names", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) - const err = yield* Effect.promise(() => - Effect.runPromise( - prompt.prompt({ - sessionID: session.id, - agent: "nonexistent-agent-xyz", - noReply: true, - parts: [{ type: "text", text: "hello" }], - }), - ).then( - () => undefined, - (e) => e, - ), - ) +it.live( + "unknown agent error includes available agent names", + () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + const exit = yield* prompt + .prompt({ + sessionID: session.id, + agent: "nonexistent-agent-xyz", + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) expect(NamedError.Unknown.isInstance(err)).toBe(true) if (NamedError.Unknown.isInstance(err)) { expect(err.data.message).toContain("code") // kilocode_change - "build" renamed to "code" } - }), - ), - }) - }, 30000) + } + }), + { git: true }, + ), + 30_000, +) - test("unknown command throws typed error with available names", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: () => - run( - Effect.gen(function* () { - const prompt = yield* SessionPrompt.Service - const sessions = yield* Session.Service - const session = yield* sessions.create({}) - const err = yield* Effect.promise(() => - Effect.runPromise( - prompt.command({ - sessionID: session.id, - command: "nonexistent-command-xyz", - arguments: "", - }), - ).then( - () => undefined, - (e) => e, - ), - ) - expect(err).toBeDefined() +it.live( + "unknown command throws typed error with available names", + () => + provideTmpdirInstance( + (_dir) => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + const exit = yield* prompt + .command({ + sessionID: session.id, + command: "nonexistent-command-xyz", + arguments: "", + }) + .pipe(Effect.exit) + + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) expect(err).not.toBeInstanceOf(TypeError) expect(NamedError.Unknown.isInstance(err)).toBe(true) if (NamedError.Unknown.isInstance(err)) { expect(err.data.message).toContain('Command not found: "nonexistent-command-xyz"') expect(err.data.message).toContain("init") } - }), - ), - }) - }, 30000) -}) + } + }), + { git: true }, + ), + 30_000, +) diff --git a/packages/opencode/test/session/structured-output.test.ts b/packages/opencode/test/session/structured-output.test.ts index 2debfb76d57..c734a182aee 100644 --- a/packages/opencode/test/session/structured-output.test.ts +++ b/packages/opencode/test/session/structured-output.test.ts @@ -5,7 +5,7 @@ import { SessionID, MessageID } from "../../src/session/schema" describe("structured-output.OutputFormat", () => { test("parses text format", () => { - const result = MessageV2.Format.safeParse({ type: "text" }) + const result = MessageV2.Format.zod.safeParse({ type: "text" }) expect(result.success).toBe(true) if (result.success) { expect(result.data.type).toBe("text") @@ -13,7 +13,7 @@ describe("structured-output.OutputFormat", () => { }) test("parses json_schema format with defaults", () => { - const result = MessageV2.Format.safeParse({ + const result = MessageV2.Format.zod.safeParse({ type: "json_schema", schema: { type: "object", properties: { name: { type: "string" } } }, }) @@ -27,7 +27,7 @@ describe("structured-output.OutputFormat", () => { }) test("parses json_schema format with custom retryCount", () => { - const result = MessageV2.Format.safeParse({ + const result = MessageV2.Format.zod.safeParse({ type: "json_schema", schema: { type: "object" }, retryCount: 5, @@ -39,17 +39,17 @@ describe("structured-output.OutputFormat", () => { }) test("rejects invalid type", () => { - const result = MessageV2.Format.safeParse({ type: "invalid" }) + const result = MessageV2.Format.zod.safeParse({ type: "invalid" }) expect(result.success).toBe(false) }) test("rejects json_schema without schema", () => { - const result = MessageV2.Format.safeParse({ type: "json_schema" }) + const result = MessageV2.Format.zod.safeParse({ type: "json_schema" }) expect(result.success).toBe(false) }) test("rejects negative retryCount", () => { - const result = MessageV2.Format.safeParse({ + const result = MessageV2.Format.zod.safeParse({ type: "json_schema", schema: { type: "object" }, retryCount: -1, @@ -95,7 +95,7 @@ describe("structured-output.StructuredOutputError", () => { describe("structured-output.UserMessage", () => { test("user message accepts outputFormat", () => { - const result = MessageV2.User.safeParse({ + const result = MessageV2.User.zod.safeParse({ id: MessageID.ascending(), sessionID: SessionID.descending(), role: "user", @@ -111,7 +111,7 @@ describe("structured-output.UserMessage", () => { }) test("user message works without outputFormat (optional)", () => { - const result = MessageV2.User.safeParse({ + const result = MessageV2.User.zod.safeParse({ id: MessageID.ascending(), sessionID: SessionID.descending(), role: "user", @@ -140,7 +140,7 @@ describe("structured-output.AssistantMessage", () => { } test("assistant message accepts structured", () => { - const result = MessageV2.Assistant.safeParse({ + const result = MessageV2.Assistant.zod.safeParse({ ...baseAssistantMessage, structured: { company: "Anthropic", founded: 2021 }, }) @@ -151,7 +151,7 @@ describe("structured-output.AssistantMessage", () => { }) test("assistant message works without structured_output (optional)", () => { - const result = MessageV2.Assistant.safeParse(baseAssistantMessage) + const result = MessageV2.Assistant.zod.safeParse(baseAssistantMessage) expect(result.success).toBe(true) }) }) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index c653717c881..44636833d8a 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -195,6 +195,35 @@ describe("tool.apply_patch freeform", () => { }) }) + test("does not invent a first-line diff for BOM files", async () => { + await using fixture = await tmpdir() + const { ctx, calls } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const bom = String.fromCharCode(0xfeff) + const target = path.join(fixture.path, "example.cs") + await fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`, "utf-8") + + const patchText = + "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" + + await execute({ patchText }, ctx) + + expect(calls.length).toBe(1) + const shown = calls[0].metadata.files[0]?.patch ?? "" + expect(shown).not.toContain(bom) + expect(shown).not.toContain("-using System;") + expect(shown).not.toContain("+using System;") + + const content = await fs.readFile(target, "utf-8") + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n") + }, + }) + }) + test("inserts lines with insert-only hunk", async () => { await using fixture = await tmpdir() const { ctx } = makeCtx() diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index 4759b8be360..82e1b4a7fd4 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -29,11 +29,6 @@ afterEach(async () => { await Instance.disposeAll() }) -async function touch(file: string, time: number) { - const date = new Date(time) - await fs.utimes(file, date, date) -} - const runtime = ManagedRuntime.make( Layer.mergeAll( LSP.defaultLayer, @@ -101,6 +96,37 @@ describe("tool.edit", () => { }) }) + test("preserves BOM when oldString is empty on existing files", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "existing.cs") + const bom = String.fromCharCode(0xfeff) + await fs.writeFile(filepath, `${bom}using System;\n`, "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await resolve() + const result = await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "", + newString: "using Up;\n", + }, + ctx, + ), + ) + + expect(result.metadata.diff).toContain("-using System;") + expect(result.metadata.diff).toContain("+using Up;") + + const content = await fs.readFile(filepath, "utf-8") + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }, + }) + }) + test("creates new file with nested directories", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nested", "dir", "file.txt") @@ -188,6 +214,38 @@ describe("tool.edit", () => { }) }) + test("replaces the first visible line in BOM files", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "existing.cs") + const bom = String.fromCharCode(0xfeff) + await fs.writeFile(filepath, `${bom}using System;\nclass Test {}\n`, "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await resolve() + const result = await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "using System;", + newString: "using Up;", + }, + ctx, + ), + ) + + expect(result.metadata.diff).toContain("-using System;") + expect(result.metadata.diff).toContain("+using Up;") + expect(result.metadata.diff).not.toContain(bom) + + const content = await fs.readFile(filepath, "utf-8") + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\nclass Test {}\n") + }, + }) + }) + test("throws error when file does not exist", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nonexistent.txt") @@ -639,42 +697,56 @@ describe("tool.edit", () => { }) describe("concurrent editing", () => { - test("serializes concurrent edits to same file", async () => { + test("preserves concurrent edits to different sections of the same file", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "file.txt") - await fs.writeFile(filepath, "0", "utf-8") + await fs.writeFile(filepath, "top = 0\nmiddle = keep\nbottom = 0\n", "utf-8") await Instance.provide({ directory: tmp.path, fn: async () => { const edit = await resolve() + let asks = 0 + const firstAsk = Promise.withResolvers() + const delayedCtx = { + ...ctx, + ask: () => + Effect.gen(function* () { + asks++ + if (asks !== 1) return + firstAsk.resolve() + yield* Effect.promise(() => Bun.sleep(50)) + }), + } - // Two concurrent edits const promise1 = Effect.runPromise( edit.execute( { filePath: filepath, - oldString: "0", - newString: "1", + oldString: "top = 0", + newString: "top = 1", }, - ctx, + delayedCtx, ), ) + await firstAsk.promise + const promise2 = Effect.runPromise( edit.execute( { filePath: filepath, - oldString: "0", - newString: "2", + oldString: "bottom = 0", + newString: "bottom = 2", }, - ctx, + delayedCtx, ), ) - // Both should complete without error (though one might fail due to content mismatch) const results = await Promise.allSettled([promise1, promise2]) - expect(results.some((r) => r.status === "fulfilled")).toBe(true) + expect(results[0]?.status).toBe("fulfilled") + expect(results[1]?.status).toBe("fulfilled") + expect(await fs.readFile(filepath, "utf-8")).toBe("top = 1\nmiddle = keep\nbottom = 2\n") }, }) }) diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts index 330191a8fe0..3f6525e0858 100644 --- a/packages/opencode/test/tool/glob.test.ts +++ b/packages/opencode/test/tool/glob.test.ts @@ -34,8 +34,11 @@ const ctx = { ask: () => Effect.void, } +// kilocode_change - skip on windows: address windows ci failures #9496 +const unix = process.platform !== "win32" ? it.live : it.live.skip + describe("tool.glob", () => { - it.live("matches files from a directory path", () => + unix("matches files from a directory path", () => provideTmpdirInstance((dir) => Effect.gen(function* () { yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n")) @@ -82,7 +85,7 @@ describe("tool.glob", () => { ) // kilocode_change start - absolute glob patterns outside the project - it.live("supports absolute glob patterns outside the project", () => + unix("supports absolute glob patterns outside the project", () => provideTmpdirInstance( (_dir) => Effect.gen(function* () { diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index bae9faeb02e..84a8e2c0836 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -30,8 +30,11 @@ const node = CrossSpawnSpawner.defaultLayer const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) +// kilocode_change - skip on windows: address windows ci failures #9496 +const unix = process.platform !== "win32" ? it.live : it.live.skip + describe("tool.skill", () => { - it.live("execute returns skill content block with files", () => + unix("execute returns skill content block with files", () => provideTmpdirInstance( (dir) => Effect.gen(function* () { diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts index b85279df755..5b264d43eea 100644 --- a/packages/opencode/test/tool/task.test.ts +++ b/packages/opencode/test/tool/task.test.ts @@ -7,7 +7,7 @@ import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" import { MessageV2 } from "../../src/session/message-v2" import type { SessionPrompt } from "../../src/session/prompt" -import { MessageID, PartID } from "../../src/session/schema" +import { MessageID, PartID, SessionID } from "../../src/session/schema" // kilocode_change - SessionID used by cost propagation tests import { ModelID, ProviderID } from "../../src/provider/schema" import { TaskTool, type TaskPromptOps } from "../../src/tool/task" import { Truncate } from "../../src/tool" @@ -64,17 +64,28 @@ const seed = Effect.fn("TaskToolTest.seed")(function* (title = "Pinned") { return { chat, assistant } }) -function stubOps(opts?: { onPrompt?: (input: SessionPrompt.PromptInput) => void; text?: string }): TaskPromptOps { +// kilocode_change start - stub signature + prompt body extended to persist assistant cost for propagation tests +function stubOps(opts?: { + onPrompt?: (input: SessionPrompt.PromptInput) => void + text?: string + sessions?: Session.Interface + childCost?: number +}): TaskPromptOps { return { cancel() {}, resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), prompt: (input) => - Effect.sync(() => { + Effect.gen(function* () { opts?.onPrompt?.(input) - return reply(input, opts?.text ?? "done") + const rep = reply(input, opts?.text ?? "done") + if (opts?.sessions && opts?.childCost != null) { + yield* opts.sessions.updateMessage({ ...rep.info, cost: opts.childCost }) + } + return rep }), } } +// kilocode_change end function reply(input: SessionPrompt.PromptInput, text: string): MessageV2.WithParts { const id = MessageID.ascending() @@ -395,3 +406,214 @@ describe("tool.task", () => { ), ) }) + +// kilocode_change start - subagent cost propagation coverage (#6321) +const assistantCost = Effect.fn("TaskToolTest.assistantCost")(function* (sessionID: string) { + const sessions = yield* Session.Service + const msgs = yield* sessions.messages({ sessionID: SessionID.make(sessionID) }) + return msgs.reduce((sum, m) => sum + (m.info.role === "assistant" ? m.info.cost : 0), 0) +}) + +describe("tool.task cost propagation", () => { + it.live("propagates subagent cost to parent assistant message", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const promptOps = stubOps({ sessions, childCost: 0.25 }) + + yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + expect(parent.info.role).toBe("assistant") + if (parent.info.role !== "assistant") return + expect(parent.info.cost).toBeCloseTo(0.25, 6) + }), + ), + ) + + it.live("propagates recursively through nested subagent costs", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + // Pre-create a child with its own assistant already bearing a grandchild cost. + const child = yield* sessions.create({ parentID: chat.id, title: "grandchild-accumulated" }) + yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + parentID: assistant.id, + sessionID: child.id, + mode: "build", + agent: "general", + cost: 0.4, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + }) + + const tool = yield* TaskTool + const def = yield* tool.init() + // Resuming into the same child via task_id and the stub tacks on another 0.15. + const promptOps = stubOps({ sessions, childCost: 0.15 }) + + yield* def.execute( + { + description: "inspect bug", + prompt: "look into the cache key path", + subagent_type: "general", + task_id: child.id, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + if (parent.info.role !== "assistant") return + // Only the delta since the start of this invocation propagates. + expect(parent.info.cost).toBeCloseTo(0.15, 6) + // Child session keeps the full cumulative total (0.4 pre-existing + 0.15 this run). + expect(yield* assistantCost(child.id)).toBeCloseTo(0.55, 6) + }), + ), + ) + + it.live("resumed task_id only propagates the delta", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const child = yield* sessions.create({ parentID: chat.id, title: "resume target" }) + yield* sessions.updateMessage({ + id: MessageID.ascending(), + role: "assistant", + parentID: assistant.id, + sessionID: child.id, + mode: "build", + agent: "general", + cost: 0.1, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + }) + + const tool = yield* TaskTool + const def = yield* tool.init() + const promptOps = stubOps({ sessions, childCost: 0.05 }) + + yield* def.execute( + { + description: "inspect bug", + prompt: "continue investigation", + subagent_type: "general", + task_id: child.id, + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: new AbortController().signal, + extra: { promptOps }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + if (parent.info.role !== "assistant") return + // Delta-only: only the 0.05 from this run, not 0.15 including the pre-existing 0.10. + expect(parent.info.cost).toBeCloseTo(0.05, 6) + }), + ), + ) + + it.live("propagates partial cost on abort", () => + provideTmpdirInstance(() => + Effect.gen(function* () { + const sessions = yield* Session.Service + const { chat, assistant } = yield* seed() + const tool = yield* TaskTool + const def = yield* tool.init() + const abort = new AbortController() + // Stub that persists a partial cost, then aborts — mimics interrupted run after tokens billed. + const ops: TaskPromptOps = { + cancel() {}, + resolvePromptParts: (template) => Effect.succeed([{ type: "text" as const, text: template }]), + prompt: (input) => + Effect.gen(function* () { + const info: MessageV2.Assistant = { + id: MessageID.ascending(), + role: "assistant", + parentID: input.messageID ?? MessageID.ascending(), + sessionID: input.sessionID, + mode: "general", + agent: "general", + cost: 0.07, + path: { cwd: "/tmp", root: "/tmp" }, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: ref.modelID, + providerID: ref.providerID, + time: { created: Date.now() }, + } + yield* sessions.updateMessage(info) + abort.abort() + return yield* Effect.interrupt + }), + } + + yield* def + .execute( + { + description: "partial", + prompt: "will abort", + subagent_type: "general", + }, + { + sessionID: chat.id, + messageID: assistant.id, + agent: "build", + abort: abort.signal, + extra: { promptOps: ops }, + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, + }, + ) + .pipe(Effect.exit) + + const parent = yield* Effect.sync(() => MessageV2.get({ sessionID: chat.id, messageID: assistant.id })) + if (parent.info.role !== "assistant") return + expect(parent.info.cost).toBeCloseTo(0.07, 6) + }), + ), + ) +}) +// kilocode_change end diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 50d3b57527f..36131f9596a 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -114,6 +114,54 @@ describe("tool.write", () => { ), ) + it.live("preserves BOM when overwriting existing files", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "existing.cs") + const bom = String.fromCharCode(0xfeff) + yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) + + yield* run({ filePath: filepath, content: "using Up;\n" }) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }), + ), + ) + + it.live("restores BOM after formatter strips it", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "formatted.cs") + const bom = String.fromCharCode(0xfeff) + yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) + + yield* run({ filePath: filepath, content: "using Up;\n" }) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }), + { + config: { + formatter: { + stripbom: { + extensions: [".cs"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')", + "$FILE", + ], + }, + }, + }, + }, + ), + ) + it.live("returns diff in metadata for existing files", () => provideTmpdirInstance((dir) => Effect.gen(function* () { diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 003945b4344..70cd8f0e647 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema, SchemaGetter } from "effect" import z from "zod" -import { zod, ZodOverride, ZodPreprocess } from "../../src/util/effect-zod" +import { zod, ZodOverride } from "../../src/util/effect-zod" function json(schema: z.ZodTypeAny) { const { $schema: _, ...rest } = z.toJSONSchema(schema) @@ -751,119 +751,4 @@ describe("util.effect-zod", () => { expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" }) }) }) - - describe("ZodPreprocess annotation", () => { - test("preprocess runs on raw input before the inner schema parses", () => { - // Models the permission.ts __originalKeys pattern: capture the original - // insertion order of a user-provided object BEFORE Schema parsing - // canonicalises the keys. - const preprocess = (val: unknown) => { - if (typeof val === "object" && val !== null && !Array.isArray(val)) { - return { __keys: Object.keys(val), ...(val as Record) } - } - return val - } - const Inner = Schema.Struct({ - __keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), - a: Schema.optional(Schema.String), - b: Schema.optional(Schema.String), - }).annotate({ [ZodPreprocess]: preprocess }) - - const schema = zod(Inner) - const parsed = schema.parse({ b: "1", a: "2" }) as { - __keys?: string[] - a?: string - b?: string - } - expect(parsed.__keys).toEqual(["b", "a"]) - expect(parsed.a).toBe("2") - expect(parsed.b).toBe("1") - }) - - test("preprocess does not transform already-shaped input", () => { - // When the user passes an object that already has __keys, preprocess - // returns it unchanged because spreading preserves any existing key. - const preprocess = (val: unknown) => { - if (typeof val === "object" && val !== null && !("__keys" in val)) { - return { __keys: Object.keys(val), ...(val as Record) } - } - return val - } - const Inner = Schema.Struct({ - __keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), - a: Schema.optional(Schema.String), - }).annotate({ [ZodPreprocess]: preprocess }) - - const schema = zod(Inner) - const parsed = schema.parse({ __keys: ["existing"], a: "hi" }) as { - __keys?: string[] - a?: string - } - expect(parsed.__keys).toEqual(["existing"]) - }) - - test("preprocess composes with a union (either object or string)", () => { - // Mirrors permission.ts exactly: input can be either an object (with - // preprocess injecting metadata) or a plain string action. - const Action = Schema.Literals(["ask", "allow", "deny"]) - const Obj = Schema.Struct({ - __keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), - read: Schema.optional(Action), - write: Schema.optional(Action), - }) - const preprocess = (val: unknown) => { - if (typeof val === "object" && val !== null && !Array.isArray(val)) { - return { __keys: Object.keys(val), ...(val as Record) } - } - return val - } - const Inner = Schema.Union([Obj, Action]).annotate({ [ZodPreprocess]: preprocess }) - const schema = zod(Inner) - - // String branch — passes through preprocess unchanged - expect(schema.parse("allow")).toBe("allow") - - // Object branch — __keys injected, preserves order - const parsed = schema.parse({ write: "allow", read: "deny" }) as { - __keys?: string[] - read?: string - write?: string - } - expect(parsed.__keys).toEqual(["write", "read"]) - expect(parsed.write).toBe("allow") - expect(parsed.read).toBe("deny") - }) - - test("JSON Schema output comes from the inner schema — preprocess is runtime-only", () => { - const Inner = Schema.Struct({ - a: Schema.optional(Schema.String), - b: Schema.optional(Schema.Number), - }).annotate({ [ZodPreprocess]: (v: unknown) => v }) - const shape = json(zod(Inner)) as any - expect(shape.type).toBe("object") - expect(shape.properties.a.type).toBe("string") - expect(shape.properties.b.type).toBe("number") - }) - - test("identifier + description propagate through the preprocess wrapper", () => { - const Inner = Schema.Struct({ - x: Schema.optional(Schema.String), - }).annotate({ - identifier: "WithPreproc", - description: "A schema with preprocess", - [ZodPreprocess]: (v: unknown) => v, - }) - const schema = zod(Inner) - expect(schema.meta()?.ref).toBe("WithPreproc") - expect(schema.meta()?.description).toBe("A schema with preprocess") - }) - - test("preprocess inside a struct field applies only to that field", () => { - const Inner = Schema.String.annotate({ - [ZodPreprocess]: (v: unknown) => (typeof v === "number" ? String(v) : v), - }) - const schema = zod(Schema.Struct({ name: Inner, raw: Schema.Number })) - expect(schema.parse({ name: 42, raw: 7 })).toEqual({ name: "42", raw: 7 }) - }) - }) }) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index a7966fe7129..0f343be39ba 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.2.24", + "version": "7.2.26", "type": "module", "license": "MIT", "scripts": { @@ -34,8 +34,8 @@ } }, "devDependencies": { - "@opentui/core": "catalog:", - "@opentui/solid": "catalog:", + "@opentui/core": "0.1.99", + "@opentui/solid": "0.1.99", "@tsconfig/node22": "catalog:", "@types/node": "catalog:", "typescript": "catalog:", diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 4a483239811..b3c6a9b980a 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -49,7 +49,7 @@ export type WorkspaceAdaptor = { name: string description: string configure(config: WorkspaceInfo): WorkspaceInfo | Promise - create(config: WorkspaceInfo, from?: WorkspaceInfo): Promise + create(config: WorkspaceInfo, env: Record, from?: WorkspaceInfo): Promise remove(config: WorkspaceInfo): Promise target(config: WorkspaceInfo): WorkspaceTarget | Promise } diff --git a/packages/script/package.json b/packages/script/package.json index a9e2925bd70..8f386d2aa89 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.2.24", + "version": "7.2.26", "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 1ae921f6c24..38a9bbb9d53 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.2.24", + "version": "7.2.26", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fa3eed65a62..fd22dd8f65d 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -583,6 +583,23 @@ export type EventTodoUpdated = { } } +export type IndexingStatusState = "Disabled" | "In Progress" | "Complete" | "Error" | "Standby" + +export type IndexingStatus = { + state: IndexingStatusState + message: string + processedFiles: number + totalFiles: number + percent: number +} + +export type EventIndexingStatus = { + type: "indexing.status" + properties: { + status: IndexingStatus + } +} + export type EventVcsBranchUpdated = { type: "vcs.branch.updated" properties: { @@ -1044,6 +1061,7 @@ export type CompactionPart = { type: "compaction" auto: boolean overflow?: boolean + tail_start_id?: string } export type Part = @@ -1078,6 +1096,13 @@ export type EventMessagePartRemoved = { } } +export type SnapshotSummaryFileDiff = { + file: string + additions: number + deletions: number + status?: "added" | "deleted" | "modified" +} + export type PermissionAction = "allow" | "deny" | "ask" export type PermissionRule = { @@ -1099,12 +1124,7 @@ export type Session = { additions: number deletions: number files: number - diffs?: Array<{ - file: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" - }> + diffs?: Array } share?: { url: string @@ -1231,12 +1251,7 @@ export type SyncEventSessionUpdated = { additions: number deletions: number files: number - diffs?: Array<{ - file: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" - }> + diffs?: Array } | null share?: { url: string | null @@ -1315,6 +1330,7 @@ export type GlobalEvent = { | EventSessionStatus | EventSessionIdle | EventTodoUpdated + | EventIndexingStatus | EventVcsBranchUpdated | EventSessionCompacted | EventKiloSessionsRemoteStatusChanged @@ -1375,6 +1391,127 @@ export type ServerConfig = { cors?: Array } +/** + * Codebase indexing configuration + */ +export type IndexingConfig = { + /** + * Enable codebase indexing + */ + enabled?: boolean + /** + * Embedding provider to use for codebase indexing + */ + provider?: + | "openai" + | "ollama" + | "openai-compatible" + | "gemini" + | "mistral" + | "vercel-ai-gateway" + | "bedrock" + | "openrouter" + | "voyage" + /** + * Embedding model ID (uses provider default if omitted) + */ + model?: string + /** + * Override embedding vector dimension (auto-detected from model if omitted) + */ + dimension?: number + /** + * Vector store backend (default: qdrant) + */ + vectorStore?: "lancedb" | "qdrant" + /** + * OpenAI embedding provider options + */ + openai?: { + apiKey?: string + } + /** + * Ollama embedding provider options + */ + ollama?: { + baseUrl?: string + } + /** + * OpenAI-compatible embedding provider options + */ + "openai-compatible"?: { + baseUrl?: string + apiKey?: string + } + /** + * Gemini embedding provider options + */ + gemini?: { + apiKey?: string + } + /** + * Mistral embedding provider options + */ + mistral?: { + apiKey?: string + } + /** + * Vercel AI Gateway embedding provider options + */ + "vercel-ai-gateway"?: { + apiKey?: string + } + /** + * AWS Bedrock embedding provider options + */ + bedrock?: { + region?: string + profile?: string + } + /** + * OpenRouter embedding provider options + */ + openrouter?: { + apiKey?: string + specificProvider?: string + } + /** + * Voyage embedding provider options + */ + voyage?: { + apiKey?: string + } + /** + * Qdrant vector store connection options + */ + qdrant?: { + url?: string + apiKey?: string + } + /** + * LanceDB vector store options + */ + lancedb?: { + directory?: string + } + /** + * Minimum similarity score for search results (default: 0.4) + */ + searchMinScore?: number + /** + * Maximum number of search results (default: 50) + */ + searchMaxResults?: number + /** + * Number of code segments per embedding batch (default: 60) + */ + embeddingBatchSize?: number + /** + * Maximum retry attempts for failed embedding batches (default: 3) + */ + scannerMaxBatchRetries?: number +} + export type PermissionActionConfig = "ask" | "allow" | "deny" | null export type PermissionObjectConfig = { @@ -1384,8 +1521,8 @@ export type PermissionObjectConfig = { export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig export type PermissionConfig = + | PermissionActionConfig | { - __originalKeys?: Array read?: PermissionRuleConfig edit?: PermissionRuleConfig glob?: PermissionRuleConfig @@ -1402,9 +1539,8 @@ export type PermissionConfig = lsp?: PermissionRuleConfig doom_loop?: PermissionActionConfig skill?: PermissionRuleConfig - [key: string]: PermissionRuleConfig | Array | PermissionActionConfig | undefined + [key: string]: PermissionRuleConfig | PermissionActionConfig | undefined } - | PermissionActionConfig export type AgentConfig = { model?: string | null @@ -1719,6 +1855,7 @@ export type Config = { * Enable remote control of sessions via Kilo Cloud. Equivalent to running /remote on startup. */ remote_control?: boolean + indexing?: IndexingConfig /** * Model to use in the format of provider/model, eg anthropic/claude-2 */ @@ -1840,6 +1977,14 @@ export type Config = { * Enable pruning of old tool outputs (default: true) */ prune?: boolean + /** + * Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2) + */ + tail_turns?: number + /** + * Maximum number of tokens from recent turns to preserve verbatim after compaction + */ + preserve_recent_tokens?: number /** * Token buffer for compaction. Leaves enough window to avoid overflow during compaction. */ @@ -1855,6 +2000,10 @@ export type Config = { * Enable AI-powered codebase search */ codebase_search?: boolean + /** + * Enable semantic codebase indexing and the semantic_search tool + */ + semantic_indexing?: boolean /** * Enable telemetry. Set to false to opt-out. */ @@ -2082,12 +2231,7 @@ export type GlobalSession = { additions: number deletions: number files: number - diffs?: Array<{ - file: string - additions: number - deletions: number - status?: "added" | "deleted" | "modified" - }> + diffs?: Array } share?: { url: string @@ -2291,6 +2435,7 @@ export type Event = | EventSessionStatus | EventSessionIdle | EventTodoUpdated + | EventIndexingStatus | EventVcsBranchUpdated | EventSessionCompacted | EventKiloSessionsRemoteStatusChanged @@ -6884,6 +7029,7 @@ export type KiloFimResponses = { delta?: { content?: string } + text?: string }> usage?: { prompt_tokens?: number diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1a13f65118f..518ef8cdc9a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -415,6 +415,391 @@ ] } }, + "/experimental/workspace/adaptor": { + "get": { + "operationId": "experimental.workspace.adaptor.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "List workspace adaptors", + "description": "List all available workspace adaptors for the current project.", + "responses": { + "200": { + "description": "Workspace adaptors", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + }, + "required": ["type", "name", "description"] + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.experimental.workspace.adaptor.list({\n ...\n})" + } + ] + } + }, + "/experimental/workspace": { + "post": { + "operationId": "experimental.workspace.create", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Create workspace", + "description": "Create a workspace for the current project.", + "responses": { + "200": { + "description": "Workspace created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Workspace" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^wrk.*" + }, + "type": { + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "extra": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": ["type", "branch", "extra"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.experimental.workspace.create({\n ...\n})" + } + ] + }, + "get": { + "operationId": "experimental.workspace.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "List workspaces", + "description": "List all workspaces.", + "responses": { + "200": { + "description": "Workspaces", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Workspace" + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.experimental.workspace.list({\n ...\n})" + } + ] + } + }, + "/experimental/workspace/status": { + "get": { + "operationId": "experimental.workspace.status", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Workspace status", + "description": "Get connection status for workspaces in the current project.", + "responses": { + "200": { + "description": "Workspace status", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "status": { + "type": "string", + "enum": ["connected", "connecting", "disconnected", "error"] + } + }, + "required": ["workspaceID", "status"] + } + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.experimental.workspace.status({\n ...\n})" + } + ] + } + }, + "/experimental/workspace/{id}": { + "delete": { + "operationId": "experimental.workspace.remove", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "pattern": "^wrk.*" + }, + "required": true + } + ], + "summary": "Remove workspace", + "description": "Remove an existing workspace.", + "responses": { + "200": { + "description": "Workspace removed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Workspace" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.experimental.workspace.remove({\n ...\n})" + } + ] + } + }, + "/experimental/workspace/{id}/session-restore": { + "post": { + "operationId": "experimental.workspace.sessionRestore", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "pattern": "^wrk.*" + }, + "required": true + } + ], + "summary": "Restore session into workspace", + "description": "Replay a session's sync events into the target workspace in batches.", + "responses": { + "200": { + "description": "Session replay started", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "total": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["total"] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["sessionID"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.experimental.workspace.sessionRestore({\n ...\n})" + } + ] + } + }, "/project": { "get": { "operationId": "project.list", @@ -1557,310 +1942,6 @@ ] } }, - "/experimental/workspace/adaptor": { - "get": { - "operationId": "experimental.workspace.adaptor.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List workspace adaptors", - "description": "List all available workspace adaptors for the current project.", - "responses": { - "200": { - "description": "Workspace adaptors", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "type": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - } - }, - "required": ["type", "name", "description"] - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.experimental.workspace.adaptor.list({\n ...\n})" - } - ] - } - }, - "/experimental/workspace": { - "post": { - "operationId": "experimental.workspace.create", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Create workspace", - "description": "Create a workspace for the current project.", - "responses": { - "200": { - "description": "Workspace created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Workspace" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^wrk.*" - }, - "type": { - "type": "string" - }, - "branch": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "extra": { - "anyOf": [ - {}, - { - "type": "null" - } - ] - } - }, - "required": ["type", "branch", "extra"] - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.experimental.workspace.create({\n ...\n})" - } - ] - }, - "get": { - "operationId": "experimental.workspace.list", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "List workspaces", - "description": "List all workspaces.", - "responses": { - "200": { - "description": "Workspaces", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Workspace" - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.experimental.workspace.list({\n ...\n})" - } - ] - } - }, - "/experimental/workspace/status": { - "get": { - "operationId": "experimental.workspace.status", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - } - ], - "summary": "Workspace status", - "description": "Get connection status for workspaces in the current project.", - "responses": { - "200": { - "description": "Workspace status", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "properties": { - "workspaceID": { - "type": "string", - "pattern": "^wrk.*" - }, - "status": { - "type": "string", - "enum": ["connected", "connecting", "disconnected", "error"] - }, - "error": { - "type": "string" - } - }, - "required": ["workspaceID", "status"] - } - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.experimental.workspace.status({\n ...\n})" - } - ] - } - }, - "/experimental/workspace/{id}": { - "delete": { - "operationId": "experimental.workspace.remove", - "parameters": [ - { - "in": "query", - "name": "directory", - "schema": { - "type": "string" - } - }, - { - "in": "query", - "name": "workspace", - "schema": { - "type": "string" - } - }, - { - "in": "path", - "name": "id", - "schema": { - "type": "string", - "pattern": "^wrk.*" - }, - "required": true - } - ], - "summary": "Remove workspace", - "description": "Remove an existing workspace.", - "responses": { - "200": { - "description": "Workspace removed", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Workspace" - } - } - } - }, - "400": { - "description": "Bad request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BadRequestError" - } - } - } - } - }, - "x-codeSamples": [ - { - "lang": "js", - "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.experimental.workspace.remove({\n ...\n})" - } - ] - } - }, "/experimental/worktree": { "post": { "operationId": "worktree.create", @@ -5620,6 +5701,202 @@ ] } }, + "/sync/replay": { + "post": { + "operationId": "sync.replay", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "Replay sync events", + "description": "Validate and replay a complete sync event history.", + "responses": { + "200": { + "description": "Replayed sync events", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"] + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "directory": { + "type": "string" + }, + "events": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "aggregateID": { + "type": "string" + }, + "seq": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "type": { + "type": "string" + }, + "data": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["id", "aggregateID", "seq", "type", "data"] + } + } + }, + "required": ["directory", "events"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.sync.replay({\n ...\n})" + } + ] + } + }, + "/sync/history": { + "get": { + "operationId": "sync.history.list", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "workspace", + "schema": { + "type": "string" + } + } + ], + "summary": "List sync events", + "description": "List sync events for all aggregates. Keys are aggregate IDs the client already knows about, values are the last known sequence ID. Events with seq > value are returned for those aggregates. Aggregates not listed in the input get their full history.", + "responses": { + "200": { + "description": "Sync events", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "aggregate_id": { + "type": "string" + }, + "seq": { + "type": "number" + }, + "type": { + "type": "string" + }, + "data": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["id", "aggregate_id", "seq", "type", "data"] + } + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createKiloClient } from \"@kilocode/sdk\n\nconst client = createKiloClient()\nawait client.sync.history.list({\n ...\n})" + } + ] + } + }, "/find": { "get": { "operationId": "find.text", @@ -10563,44 +10840,6 @@ }, "required": ["type", "properties"] }, - "Event.installation.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "installation.updated" - }, - "properties": { - "type": "object", - "properties": { - "version": { - "type": "string" - } - }, - "required": ["version"] - } - }, - "required": ["type", "properties"] - }, - "Event.installation.update-available": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "installation.update-available" - }, - "properties": { - "type": "object", - "properties": { - "version": { - "type": "string" - } - }, - "required": ["version"] - } - }, - "required": ["type", "properties"] - }, "Event.server.connected": { "type": "object", "properties": { @@ -10733,6 +10972,44 @@ }, "required": ["type", "properties"] }, + "Event.installation.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "installation.updated" + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"] + } + }, + "required": ["type", "properties"] + }, + "Event.installation.update-available": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "installation.update-available" + }, + "properties": { + "type": "object", + "properties": { + "version": { + "type": "string" + } + }, + "required": ["version"] + } + }, + "required": ["type", "properties"] + }, "Event.tui.prompt.append": { "type": "object", "properties": { @@ -11031,48 +11308,6 @@ }, "required": ["type", "properties"] }, - "Event.session.turn.open": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.turn.open" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string" - } - }, - "required": ["sessionID"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.turn.close": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.turn.close" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string" - }, - "reason": { - "type": "string", - "enum": ["completed", "error", "interrupted"] - } - }, - "required": ["sessionID", "reason"] - } - }, - "required": ["type", "properties"] - }, "PermissionRequest": { "type": "object", "properties": { @@ -11163,6 +11398,48 @@ }, "required": ["type", "properties"] }, + "Event.session.turn.open": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.turn.open" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + } + }, + "required": ["sessionID"] + } + }, + "required": ["type", "properties"] + }, + "Event.session.turn.close": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.turn.close" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": ["completed", "error", "interrupted"] + } + }, + "required": ["sessionID", "reason"] + } + }, + "required": ["type", "properties"] + }, "SnapshotFileDiff": { "type": "object", "properties": { @@ -11482,7 +11759,8 @@ "type": "object", "properties": { "messageID": { - "type": "string" + "type": "string", + "pattern": "^msg.*" }, "callID": { "type": "string" @@ -11494,10 +11772,12 @@ "type": "object", "properties": { "id": { - "type": "string" + "type": "string", + "pattern": "^que.*" }, "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses.*" }, "questions": { "description": "Questions to ask", @@ -11539,10 +11819,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses.*" }, "requestID": { - "type": "string" + "type": "string", + "pattern": "^que.*" }, "answers": { "type": "array", @@ -11570,10 +11852,12 @@ "type": "object", "properties": { "sessionID": { - "type": "string" + "type": "string", + "pattern": "^ses.*" }, "requestID": { - "type": "string" + "type": "string", + "pattern": "^que.*" } }, "required": ["sessionID", "requestID"] @@ -11591,148 +11875,32 @@ }, "required": ["type", "properties"] }, - "Todo": { - "type": "object", - "properties": { - "content": { - "description": "Brief description of the task", - "type": "string" - }, - "status": { - "description": "Current status of the task: pending, in_progress, completed, cancelled", - "type": "string" - }, - "priority": { - "description": "Priority level of the task: high, medium, low", - "type": "string" - } - }, - "required": ["content", "status", "priority"] - }, - "Event.todo.updated": { + "Event.command.executed": { "type": "object", "properties": { "type": { "type": "string", - "const": "todo.updated" + "const": "command.executed" }, "properties": { "type": "object", "properties": { + "name": { + "type": "string" + }, "sessionID": { "type": "string", "pattern": "^ses.*" }, - "todos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Todo" - } - } - }, - "required": ["sessionID", "todos"] - } - }, - "required": ["type", "properties"] - }, - "SessionStatus": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "idle" - } - }, - "required": ["type"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "retry" - }, - "attempt": { - "type": "number" - }, - "message": { + "arguments": { "type": "string" }, - "next": { - "type": "number" - } - }, - "required": ["type", "attempt", "message", "next"] - }, - { - "type": "object", - "properties": { - "type": { + "messageID": { "type": "string", - "const": "busy" + "pattern": "^msg.*" } }, - "required": ["type"] - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "offline" - }, - "requestID": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "requestID", "message"] - } - ] - }, - "Event.session.status": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.status" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - }, - "status": { - "$ref": "#/components/schemas/SessionStatus" - } - }, - "required": ["sessionID", "status"] - } - }, - "required": ["type", "properties"] - }, - "Event.session.idle": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "session.idle" - }, - "properties": { - "type": "object", - "properties": { - "sessionID": { - "type": "string", - "pattern": "^ses.*" - } - }, - "required": ["sessionID"] + "required": ["name", "sessionID", "arguments", "messageID"] } }, "required": ["type", "properties"] @@ -11863,12 +12031,94 @@ }, "required": ["type", "properties"] }, - "Event.session.compacted": { + "SessionStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "idle" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "retry" + }, + "attempt": { + "type": "number" + }, + "message": { + "type": "string" + }, + "next": { + "type": "number" + } + }, + "required": ["type", "attempt", "message", "next"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "busy" + } + }, + "required": ["type"] + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "offline" + }, + "requestID": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["type", "requestID", "message"] + } + ] + }, + "Event.session.status": { "type": "object", "properties": { "type": { "type": "string", - "const": "session.compacted" + "const": "session.status" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "status": { + "$ref": "#/components/schemas/SessionStatus" + } + }, + "required": ["sessionID", "status"] + } + }, + "required": ["type", "properties"] + }, + "Event.session.idle": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.idle" }, "properties": { "type": "object", @@ -11883,32 +12133,96 @@ }, "required": ["type", "properties"] }, - "Event.command.executed": { + "Todo": { + "type": "object", + "properties": { + "content": { + "description": "Brief description of the task", + "type": "string" + }, + "status": { + "description": "Current status of the task: pending, in_progress, completed, cancelled", + "type": "string" + }, + "priority": { + "description": "Priority level of the task: high, medium, low", + "type": "string" + } + }, + "required": ["content", "status", "priority"] + }, + "Event.todo.updated": { "type": "object", "properties": { "type": { "type": "string", - "const": "command.executed" + "const": "todo.updated" }, "properties": { "type": "object", "properties": { - "name": { - "type": "string" - }, "sessionID": { "type": "string", "pattern": "^ses.*" }, - "arguments": { - "type": "string" - }, - "messageID": { - "type": "string", - "pattern": "^msg.*" + "todos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Todo" + } } }, - "required": ["name", "sessionID", "arguments", "messageID"] + "required": ["sessionID", "todos"] + } + }, + "required": ["type", "properties"] + }, + "IndexingStatusState": { + "type": "string", + "enum": ["Disabled", "In Progress", "Complete", "Error", "Standby"] + }, + "IndexingStatus": { + "type": "object", + "properties": { + "state": { + "$ref": "#/components/schemas/IndexingStatusState" + }, + "message": { + "type": "string" + }, + "processedFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "percent": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + }, + "required": ["state", "message", "processedFiles", "totalFiles", "percent"] + }, + "Event.indexing.status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "indexing.status" + }, + "properties": { + "type": "object", + "properties": { + "status": { + "$ref": "#/components/schemas/IndexingStatus" + } + }, + "required": ["status"] } }, "required": ["type", "properties"] @@ -11931,6 +12245,26 @@ }, "required": ["type", "properties"] }, + "Event.session.compacted": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "session.compacted" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string", + "pattern": "^ses.*" + } + }, + "required": ["sessionID"] + } + }, + "required": ["type", "properties"] + }, "Event.kilo-sessions.remote-status-changed": { "type": "object", "properties": { @@ -12145,6 +12479,40 @@ }, "required": ["type", "properties"] }, + "Event.workspace.restore": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "workspace.restore" + }, + "properties": { + "type": "object", + "properties": { + "workspaceID": { + "type": "string", + "pattern": "^wrk.*" + }, + "sessionID": { + "type": "string", + "pattern": "^ses.*" + }, + "total": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "step": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": ["workspaceID", "sessionID", "total", "step"] + } + }, + "required": ["type", "properties"] + }, "Event.workspace.status": { "type": "object", "properties": { @@ -12162,9 +12530,6 @@ "status": { "type": "string", "enum": ["connected", "connecting", "disconnected", "error"] - }, - "error": { - "type": "string" } }, "required": ["workspaceID", "status"] @@ -13260,6 +13625,10 @@ }, "overflow": { "type": "boolean" + }, + "tail_start_id": { + "type": "string", + "pattern": "^msg.*" } }, "required": ["id", "sessionID", "messageID", "type", "auto"] @@ -14101,12 +14470,6 @@ { "$ref": "#/components/schemas/Event.server.instance.disposed" }, - { - "$ref": "#/components/schemas/Event.installation.updated" - }, - { - "$ref": "#/components/schemas/Event.installation.update-available" - }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -14128,6 +14491,12 @@ { "$ref": "#/components/schemas/Event.lsp.updated" }, + { + "$ref": "#/components/schemas/Event.installation.updated" + }, + { + "$ref": "#/components/schemas/Event.installation.update-available" + }, { "$ref": "#/components/schemas/Event.tui.prompt.append" }, @@ -14161,18 +14530,18 @@ { "$ref": "#/components/schemas/Event.message.part.delta" }, - { - "$ref": "#/components/schemas/Event.session.turn.open" - }, - { - "$ref": "#/components/schemas/Event.session.turn.close" - }, { "$ref": "#/components/schemas/Event.permission.asked" }, { "$ref": "#/components/schemas/Event.permission.replied" }, + { + "$ref": "#/components/schemas/Event.session.turn.open" + }, + { + "$ref": "#/components/schemas/Event.session.turn.close" + }, { "$ref": "#/components/schemas/Event.session.diff" }, @@ -14189,13 +14558,7 @@ "$ref": "#/components/schemas/Event.question.rejected" }, { - "$ref": "#/components/schemas/Event.todo.updated" - }, - { - "$ref": "#/components/schemas/Event.session.status" - }, - { - "$ref": "#/components/schemas/Event.session.idle" + "$ref": "#/components/schemas/Event.command.executed" }, { "$ref": "#/components/schemas/Event.suggestion.shown" @@ -14207,14 +14570,23 @@ "$ref": "#/components/schemas/Event.suggestion.dismissed" }, { - "$ref": "#/components/schemas/Event.session.compacted" + "$ref": "#/components/schemas/Event.session.status" }, { - "$ref": "#/components/schemas/Event.command.executed" + "$ref": "#/components/schemas/Event.session.idle" + }, + { + "$ref": "#/components/schemas/Event.todo.updated" + }, + { + "$ref": "#/components/schemas/Event.indexing.status" }, { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, + { + "$ref": "#/components/schemas/Event.session.compacted" + }, { "$ref": "#/components/schemas/Event.kilo-sessions.remote-status-changed" }, @@ -14242,6 +14614,9 @@ { "$ref": "#/components/schemas/Event.workspace.failed" }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, { "$ref": "#/components/schemas/Event.workspace.status" }, @@ -14329,6 +14704,193 @@ }, "additionalProperties": false }, + "IndexingConfig": { + "description": "Codebase indexing configuration", + "type": "object", + "properties": { + "enabled": { + "description": "Enable codebase indexing", + "type": "boolean" + }, + "provider": { + "description": "Embedding provider to use for codebase indexing", + "type": "string", + "enum": [ + "openai", + "ollama", + "openai-compatible", + "gemini", + "mistral", + "vercel-ai-gateway", + "bedrock", + "openrouter", + "voyage" + ] + }, + "model": { + "description": "Embedding model ID (uses provider default if omitted)", + "type": "string" + }, + "dimension": { + "description": "Override embedding vector dimension (auto-detected from model if omitted)", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "vectorStore": { + "description": "Vector store backend (default: qdrant)", + "type": "string", + "enum": ["lancedb", "qdrant"] + }, + "openai": { + "description": "OpenAI embedding provider options", + "type": "object", + "properties": { + "apiKey": { + "type": "string" + } + }, + "additionalProperties": false + }, + "ollama": { + "description": "Ollama embedding provider options", + "type": "object", + "properties": { + "baseUrl": { + "type": "string" + } + }, + "additionalProperties": false + }, + "openai-compatible": { + "description": "OpenAI-compatible embedding provider options", + "type": "object", + "properties": { + "baseUrl": { + "type": "string" + }, + "apiKey": { + "type": "string" + } + }, + "additionalProperties": false + }, + "gemini": { + "description": "Gemini embedding provider options", + "type": "object", + "properties": { + "apiKey": { + "type": "string" + } + }, + "additionalProperties": false + }, + "mistral": { + "description": "Mistral embedding provider options", + "type": "object", + "properties": { + "apiKey": { + "type": "string" + } + }, + "additionalProperties": false + }, + "vercel-ai-gateway": { + "description": "Vercel AI Gateway embedding provider options", + "type": "object", + "properties": { + "apiKey": { + "type": "string" + } + }, + "additionalProperties": false + }, + "bedrock": { + "description": "AWS Bedrock embedding provider options", + "type": "object", + "properties": { + "region": { + "type": "string" + }, + "profile": { + "type": "string" + } + }, + "additionalProperties": false + }, + "openrouter": { + "description": "OpenRouter embedding provider options", + "type": "object", + "properties": { + "apiKey": { + "type": "string" + }, + "specificProvider": { + "type": "string" + } + }, + "additionalProperties": false + }, + "voyage": { + "description": "Voyage embedding provider options", + "type": "object", + "properties": { + "apiKey": { + "type": "string" + } + }, + "additionalProperties": false + }, + "qdrant": { + "description": "Qdrant vector store connection options", + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "apiKey": { + "type": "string" + } + }, + "additionalProperties": false + }, + "lancedb": { + "description": "LanceDB vector store options", + "type": "object", + "properties": { + "directory": { + "type": "string" + } + }, + "additionalProperties": false + }, + "searchMinScore": { + "description": "Minimum similarity score for search results (default: 0.4)", + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "searchMaxResults": { + "description": "Maximum number of search results (default: 50)", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "embeddingBatchSize": { + "description": "Number of code segments per embedding batch (default: 60)", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + "scannerMaxBatchRetries": { + "description": "Maximum retry attempts for failed embedding batches (default: 3)", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false + }, "PermissionActionConfig": { "anyOf": [ { @@ -14361,15 +14923,12 @@ }, "PermissionConfig": { "anyOf": [ + { + "$ref": "#/components/schemas/PermissionActionConfig" + }, { "type": "object", "properties": { - "__originalKeys": { - "type": "array", - "items": { - "type": "string" - } - }, "read": { "$ref": "#/components/schemas/PermissionRuleConfig" }, @@ -14422,9 +14981,6 @@ "additionalProperties": { "$ref": "#/components/schemas/PermissionRuleConfig" } - }, - { - "$ref": "#/components/schemas/PermissionActionConfig" } ] }, @@ -14568,10 +15124,10 @@ "type": "boolean" }, "timeout": { - "description": "Timeout in milliseconds for requests to this provider. Default is 120000 (2 minutes). Set to false to disable timeout.", + "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", "anyOf": [ { - "description": "Timeout in milliseconds for requests to this provider. Default is 120000 (2 minutes). Set to false to disable timeout.", + "description": "Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout.", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 @@ -14598,67 +15154,54 @@ "type": "string" }, "additionalProperties": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "family": { - "type": "string" - }, - "release_date": { - "type": "string" - }, - "attachment": { - "type": "boolean" - }, - "reasoning": { - "type": "boolean" - }, - "temperature": { - "type": "boolean" - }, - "tool_call": { - "type": "boolean" - }, - "interleaved": { - "anyOf": [ - { - "type": "boolean", - "const": true - }, - { - "type": "object", - "properties": { - "field": { - "type": "string", - "enum": ["reasoning_content", "reasoning_details"] - } - }, - "required": ["field"], - "additionalProperties": false - } - ] - }, - "cost": { + "anyOf": [ + { "type": "object", "properties": { - "input": { - "type": "number" + "id": { + "type": "string" }, - "output": { - "type": "number" + "name": { + "type": "string" }, - "cache_read": { - "type": "number" + "family": { + "type": "string" }, - "cache_write": { - "type": "number" + "release_date": { + "type": "string" }, - "context_over_200k": { + "attachment": { + "type": "boolean" + }, + "reasoning": { + "type": "boolean" + }, + "temperature": { + "type": "boolean" + }, + "tool_call": { + "type": "boolean" + }, + "interleaved": { + "anyOf": [ + { + "type": "boolean", + "const": true + }, + { + "type": "object", + "properties": { + "field": { + "type": "string", + "enum": ["reasoning_content", "reasoning_details"] + } + }, + "required": ["field"], + "additionalProperties": false + } + ] + }, + "cost": { "type": "object", "properties": { "input": { @@ -14672,100 +15215,127 @@ }, "cache_write": { "type": "number" + }, + "context_over_200k": { + "type": "object", + "properties": { + "input": { + "type": "number" + }, + "output": { + "type": "number" + }, + "cache_read": { + "type": "number" + }, + "cache_write": { + "type": "number" + } + }, + "required": ["input", "output"] } }, "required": ["input", "output"] - } - }, - "required": ["input", "output"] - }, - "limit": { - "type": "object", - "properties": { - "context": { - "type": "number" }, - "input": { - "type": "number" + "limit": { + "type": "object", + "properties": { + "context": { + "type": "number" + }, + "input": { + "type": "number" + }, + "output": { + "type": "number" + } + }, + "required": ["context", "output"] }, - "output": { - "type": "number" - } - }, - "required": ["context", "output"] - }, - "modalities": { - "type": "object", - "properties": { - "input": { - "type": "array", - "items": { - "type": "string", - "enum": ["text", "audio", "image", "video", "pdf"] + "modalities": { + "type": "object", + "properties": { + "input": { + "type": "array", + "items": { + "type": "string", + "enum": ["text", "audio", "image", "video", "pdf"] + } + }, + "output": { + "type": "array", + "items": { + "type": "string", + "enum": ["text", "audio", "image", "video", "pdf"] + } + } + }, + "required": ["input", "output"] + }, + "experimental": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": ["alpha", "beta", "deprecated"] + }, + "provider": { + "type": "object", + "properties": { + "npm": { + "type": "string" + }, + "api": { + "type": "string" + } } }, - "output": { - "type": "array", - "items": { - "type": "string", - "enum": ["text", "audio", "image", "video", "pdf"] - } - } - }, - "required": ["input", "output"] - }, - "experimental": { - "type": "boolean" - }, - "status": { - "type": "string", - "enum": ["alpha", "beta", "deprecated"] - }, - "provider": { - "type": "object", - "properties": { - "npm": { - "type": "string" + "options": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} }, - "api": { - "type": "string" + "headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "variants": { + "description": "Variant-specific configuration", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "disabled": { + "description": "Disable this variant for the model", + "type": "boolean" + } + }, + "additionalProperties": {} + }, + { + "type": "null" + } + ] + } } } }, - "options": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": {} - }, - "headers": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - "variants": { - "description": "Variant-specific configuration", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "disabled": { - "description": "Disable this variant for the model", - "type": "boolean" - } - }, - "additionalProperties": {} - } + { + "type": "null" } - } + ] } } }, @@ -14999,6 +15569,9 @@ "description": "Enable remote control of sessions via Kilo Cloud. Equivalent to running /remote on startup.", "type": "boolean" }, + "indexing": { + "$ref": "#/components/schemas/IndexingConfig" + }, "autoupdate": { "description": "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications", "anyOf": [ @@ -15153,8 +15726,7 @@ "formatter": { "anyOf": [ { - "type": "boolean", - "const": false + "type": "boolean" }, { "type": "object", @@ -15196,8 +15768,7 @@ "lsp": { "anyOf": [ { - "type": "boolean", - "const": false + "type": "boolean" }, { "type": "object", @@ -15310,6 +15881,18 @@ "description": "Enable pruning of old tool outputs (default: true)", "type": "boolean" }, + "tail_turns": { + "description": "Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "preserve_recent_tokens": { + "description": "Maximum number of tokens from recent turns to preserve verbatim after compaction", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, "reserved": { "description": "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", "type": "integer", @@ -15456,6 +16039,53 @@ } ] }, + "Workspace": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^wrk.*" + }, + "type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "directory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "extra": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "projectID": { + "type": "string" + } + }, + "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"] + }, "NotFoundError": { "type": "object", "properties": { @@ -15776,53 +16406,6 @@ "$ref": "#/components/schemas/ToolListItem" } }, - "Workspace": { - "type": "object", - "properties": { - "id": { - "type": "string", - "pattern": "^wrk.*" - }, - "type": { - "type": "string" - }, - "name": { - "type": "string" - }, - "branch": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "directory": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "extra": { - "anyOf": [ - {}, - { - "type": "null" - } - ] - }, - "projectID": { - "type": "string" - } - }, - "required": ["id", "type", "name", "branch", "directory", "extra", "projectID"] - }, "Worktree": { "type": "object", "properties": { @@ -16234,16 +16817,8 @@ "type": "object", "properties": { "type": { - "anyOf": [ - { - "type": "string", - "const": "oauth" - }, - { - "type": "string", - "const": "api" - } - ] + "type": "string", + "enum": ["oauth", "api"] }, "label": { "type": "string" @@ -16275,16 +16850,8 @@ "type": "string" }, "op": { - "anyOf": [ - { - "type": "string", - "const": "eq" - }, - { - "type": "string", - "const": "neq" - } - ] + "type": "string", + "enum": ["eq", "neq"] }, "value": { "type": "string" @@ -16333,16 +16900,8 @@ "type": "string" }, "op": { - "anyOf": [ - { - "type": "string", - "const": "eq" - }, - { - "type": "string", - "const": "neq" - } - ] + "type": "string", + "enum": ["eq", "neq"] }, "value": { "type": "string" @@ -16366,16 +16925,8 @@ "type": "string" }, "method": { - "anyOf": [ - { - "type": "string", - "const": "auto" - }, - { - "type": "string", - "const": "code" - } - ] + "type": "string", + "enum": ["auto", "code"] }, "instructions": { "type": "string" @@ -16531,12 +17082,6 @@ { "$ref": "#/components/schemas/Event.server.instance.disposed" }, - { - "$ref": "#/components/schemas/Event.installation.updated" - }, - { - "$ref": "#/components/schemas/Event.installation.update-available" - }, { "$ref": "#/components/schemas/Event.server.connected" }, @@ -16558,6 +17103,12 @@ { "$ref": "#/components/schemas/Event.lsp.updated" }, + { + "$ref": "#/components/schemas/Event.installation.updated" + }, + { + "$ref": "#/components/schemas/Event.installation.update-available" + }, { "$ref": "#/components/schemas/Event.tui.prompt.append" }, @@ -16591,18 +17142,18 @@ { "$ref": "#/components/schemas/Event.message.part.delta" }, - { - "$ref": "#/components/schemas/Event.session.turn.open" - }, - { - "$ref": "#/components/schemas/Event.session.turn.close" - }, { "$ref": "#/components/schemas/Event.permission.asked" }, { "$ref": "#/components/schemas/Event.permission.replied" }, + { + "$ref": "#/components/schemas/Event.session.turn.open" + }, + { + "$ref": "#/components/schemas/Event.session.turn.close" + }, { "$ref": "#/components/schemas/Event.session.diff" }, @@ -16619,13 +17170,7 @@ "$ref": "#/components/schemas/Event.question.rejected" }, { - "$ref": "#/components/schemas/Event.todo.updated" - }, - { - "$ref": "#/components/schemas/Event.session.status" - }, - { - "$ref": "#/components/schemas/Event.session.idle" + "$ref": "#/components/schemas/Event.command.executed" }, { "$ref": "#/components/schemas/Event.suggestion.shown" @@ -16637,14 +17182,23 @@ "$ref": "#/components/schemas/Event.suggestion.dismissed" }, { - "$ref": "#/components/schemas/Event.session.compacted" + "$ref": "#/components/schemas/Event.session.status" }, { - "$ref": "#/components/schemas/Event.command.executed" + "$ref": "#/components/schemas/Event.session.idle" + }, + { + "$ref": "#/components/schemas/Event.todo.updated" + }, + { + "$ref": "#/components/schemas/Event.indexing.status" }, { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, + { + "$ref": "#/components/schemas/Event.session.compacted" + }, { "$ref": "#/components/schemas/Event.kilo-sessions.remote-status-changed" }, @@ -16672,6 +17226,9 @@ { "$ref": "#/components/schemas/Event.workspace.failed" }, + { + "$ref": "#/components/schemas/Event.workspace.restore" + }, { "$ref": "#/components/schemas/Event.workspace.status" }, diff --git a/packages/shared/package.json b/packages/shared/package.json index fd029c18c8f..140ae7e808b 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.24", + "version": "7.2.26", "name": "@opencode-ai/shared", "type": "module", "license": "MIT", diff --git a/packages/shared/src/filesystem.ts b/packages/shared/src/filesystem.ts index 44346be8f94..da0af299cb3 100644 --- a/packages/shared/src/filesystem.ts +++ b/packages/shared/src/filesystem.ts @@ -1,5 +1,12 @@ import { NodeFileSystem } from "@effect/platform-node" -import { dirname, join, relative, resolve as pathResolve } from "path" +import { + dirname, + isAbsolute, + join, + relative, + resolve as pathResolve, + sep, +} from "path" // kilocode_change - harden containment checks import { realpathSync } from "fs" import * as NFS from "fs/promises" import { lookup } from "mime-types" @@ -231,6 +238,9 @@ export namespace AppFileSystem { } export function contains(parent: string, child: string) { - return !relative(parent, child).startsWith("..") + // kilocode_change start - reject cross-drive and escaped relative paths + const rel = relative(parent, child) + return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`)) + // kilocode_change end } } diff --git a/packages/shared/src/types.d.ts b/packages/shared/src/types.d.ts index b5d667f1d91..60e1639adb2 100644 --- a/packages/shared/src/types.d.ts +++ b/packages/shared/src/types.d.ts @@ -5,6 +5,7 @@ declare module "@npmcli/arborist" { progress?: boolean savePrefix?: string ignoreScripts?: boolean + [key: string]: unknown } export interface ArboristNode { @@ -24,6 +25,7 @@ declare module "@npmcli/arborist" { add?: string[] save?: boolean saveType?: "prod" | "dev" | "optional" | "peer" + [key: string]: unknown } export class Arborist { diff --git a/packages/shared/sst-env.d.ts b/packages/shared/sst-env.d.ts index 64441936d7a..f25b9714550 100644 --- a/packages/shared/sst-env.d.ts +++ b/packages/shared/sst-env.d.ts @@ -7,4 +7,4 @@ /// import "sst" -export {} \ No newline at end of file +export {} diff --git a/packages/shared/test/kilocode/filesystem-containment.test.ts b/packages/shared/test/kilocode/filesystem-containment.test.ts new file mode 100644 index 00000000000..9f98b926002 --- /dev/null +++ b/packages/shared/test/kilocode/filesystem-containment.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" + +describe("kilocode filesystem containment", () => { + test("keeps dot-prefixed child names internal", () => { + expect(AppFileSystem.contains("/a/b", "/a/b/..cache/file")).toBe(true) + }) + + test("rejects cross-drive paths on Windows", () => { + if (process.platform !== "win32") return + expect(AppFileSystem.contains("C:\\repo", "D:\\outside\\file.txt")).toBe(false) + }) +}) diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 30728022667..f2fae9bf440 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.2.24", + "version": "7.2.26", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index c34d6a88c8d..339e26e38ac 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.2.24", + "version": "7.2.26", "type": "module", "license": "MIT", "exports": { diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 1ccac8b1de8..8a04226fad3 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -33,6 +33,8 @@ const icons = { compress: ``, code: ``, "code-lines": ``, + "circuit-board": ``, + organization: ``, "circle-ban-sign": ``, "edit-small-2": ``, eye: ``, @@ -119,15 +121,18 @@ export interface IconProps extends ComponentProps<"svg"> { export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) - const viewBox = () => (local.name === "magnifying-glass" ? "0 0 16 16" : "0 0 20 20") + const viewBox = () => + local.name === "magnifying-glass" || local.name === "circuit-board" || local.name === "organization" + ? "0 0 16 16" + : "0 0 20 20" return (
{ const file = diff.file // binary files have empty diffs that we can't render - const diffCanRender = () => diff.additions !== 0 && diff.deletions !== 0 + const diffCanRender = () => diff.additions !== 0 || diff.deletions !== 0 const expanded = createMemo(() => open().includes(file)) const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file))) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 3e8e7f1ca1f..7542c930774 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -280,14 +280,12 @@ export function SessionTurn( if (!msg) return emptyAssistant const messages = allMessages() ?? emptyMessages - const index = messageIndex() - if (index < 0) return emptyAssistant + if (messageIndex() < 0) return emptyAssistant const result: AssistantMessage[] = [] - for (let i = index + 1; i < messages.length; i++) { + for (let i = 0; i < messages.length; i++) { const item = messages[i] if (!item) continue - if (item.role === "user") break if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage) } return result diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index 5f8378201fa..15fe8eacaed 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -318,7 +318,7 @@ const TOOL_SAMPLES = { tool: "bash", input: { command: "bun test --filter session", description: "Run session tests" }, output: - "bun test v1.3.11\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s", + "bun test v1.3.13\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s", title: "Run session tests", metadata: { command: "bun test --filter session" }, }, diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index 4d3cfa263f8..2c136310386 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -30,9 +30,19 @@ export type NavigateToSessionFn = (sessionID: string) => void export type SessionHrefFn = (sessionID: string) => string -export type OpenFileFn = (filePath: string, line?: number, column?: number) => void // kilocode_change +// kilocode_change start +export type OpenFileFn = (filePath: string, line?: number, column?: number) => void -export type OpenUrlFn = (url: string) => void // kilocode_change +export type OpenDiffFn = (diff: { + file: string + before: string + after: string + additions: number + deletions: number +}) => void + +export type OpenUrlFn = (url: string) => void +// kilocode_change end export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", @@ -42,6 +52,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onNavigateToSession?: NavigateToSessionFn onSessionHref?: SessionHrefFn onOpenFile?: OpenFileFn // kilocode_change + onOpenDiff?: OpenDiffFn // kilocode_change onOpenUrl?: OpenUrlFn // kilocode_change }) => { return { @@ -54,6 +65,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ navigateToSession: props.onNavigateToSession, sessionHref: props.onSessionHref, openFile: props.onOpenFile, // kilocode_change + openDiff: props.onOpenDiff, // kilocode_change openUrl: props.onOpenUrl, // kilocode_change } }, diff --git a/patches/@npmcli%2Fagent@4.0.0.patch b/patches/@npmcli%2Fagent@4.0.0.patch new file mode 100644 index 00000000000..a3506a90e3d --- /dev/null +++ b/patches/@npmcli%2Fagent@4.0.0.patch @@ -0,0 +1,13 @@ +diff --git a/lib/agents.js b/lib/agents.js +index 45ec59c4c13757379095131c4f0a5ea6f7284f45..0763b031e355a755ec6a26f98461aa3f63b8339b 100644 +--- a/lib/agents.js ++++ b/lib/agents.js +@@ -32,7 +32,7 @@ module.exports = class Agent extends AgentBase { + } + + get proxy () { +- return this.#proxy ? { url: this.#proxy } : {} ++ return this.#proxy ? { url: this.#proxy.toString() } : {} + } + + #getProxy (options) { diff --git a/script/beta.ts b/script/beta.ts index b243aac79ac..e095e5e98d9 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -61,7 +61,7 @@ async function typecheck() { console.log(" Running typecheck...") try { - await $`bun typecheck`.cwd("packages/opencode") + await $`bun typecheck` return true } catch (err) { console.log(`Typecheck failed: ${err}`) @@ -113,7 +113,7 @@ async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: n "If bun.lock is conflicted, do not hand-merge it. Delete bun.lock and run bun install after the code conflicts are resolved.", "If a PR already deleted a file/directory, do not re-add it, instead apply changes in the new semantic location.", "If a PR already changed an import, keep that change.", - "After resolving the conflicts, run `bun typecheck` in `packages/opencode`.", + "After resolving the conflicts, run `bun typecheck` at the repo root.", "If typecheck fails, you may also update any files reported by typecheck.", "Keep any non-conflict edits narrowly scoped to restoring a valid merged state for the current PR batch.", "Fix any merge-caused typecheck errors before finishing.", @@ -149,7 +149,7 @@ async function smoke(prs: PR[], applied: number[]) { const prompt = [ "The beta merge batch is complete.", `Merged PRs on HEAD:\n${done}`, - "Run `bun typecheck` in `packages/opencode`.", + "Run `bun typecheck` at the repo root.", "Run `./script/build.ts --single` in `packages/opencode`.", "Fix any merge-caused issues until both commands pass.", "Do not create a commit.", @@ -295,10 +295,7 @@ async function main() { } if (applied.length > 0) { - const ok = await smoke(prs, applied) - if (!ok) { - throw new Error("Final smoke check failed") - } + console.log("\nSkipping final smoke check") } console.log("\nChecking if beta branch has changes...") diff --git a/script/extract-source-links.ts b/script/extract-source-links.ts index e1c2cdf6705..6aa3acc59ec 100755 --- a/script/extract-source-links.ts +++ b/script/extract-source-links.ts @@ -39,6 +39,7 @@ const EXCLUDE_PATTERNS = [ /^https?:\/\/tauri\.localhost/, // Example/placeholder URLs /^https?:\/\/example\.com/, + /^https?:\/\/api\.example\.com/, /^https?:\/\/api\.myprovider\.com/, /^https?:\/\/synthetic\.new/, // API endpoints (not user-facing) diff --git a/script/upstream/merge.ts b/script/upstream/merge.ts index d660bbe4117..2158f8d806f 100644 --- a/script/upstream/merge.ts +++ b/script/upstream/merge.ts @@ -148,8 +148,12 @@ async function main() { // Train rerere from past upstream merge commits so the cache is populated // even on a fresh clone. This replays past merges to learn their resolutions. + // The grep covers both the current convention ("merge: upstream vX.Y.Z") and the + // historical convention used by older upstream merges ("[Rr]esolve merge conflicts"). + // Without the lowercase alternative, ~70 past merges are dropped from training on + // this repo, since most older resolution commits use a lowercase "resolve". logger.info("Training rerere cache from past merge history...") - const learned = await git.trainRerere("merge: upstream\\|Resolve merge conflict") + const learned = await git.trainRerere("merge: upstream\\|[Rr]esolve merge conflict") if (learned > 0) { logger.success(`Learned ${learned} conflict resolution(s) from history`) } else { diff --git a/script/upstream/package.json b/script/upstream/package.json index e8de91d9246..8e8f59a264a 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.2.24", + "version": "7.2.26", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 3fc03b619b1..35ff7d46b56 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "7.2.24", + "version": "7.2.26", "publisher": "sst-dev", "repository": { "type": "git",