Merge remote-tracking branch 'origin/main' into marius-kilocode/kilo-opencode-v1.14.46

# Conflicts:
#	packages/opencode/src/config/provider.ts
This commit is contained in:
marius-kilocode
2026-06-02 18:30:26 +02:00
150 changed files with 9330 additions and 377 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Avoid showing incomplete-response warnings for snapshot initialization status turns.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep chat auto-scroll stable while edit, write, and patch tools hand off between assistant steps.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Treat empty provider responses as retryable failures and avoid sending oversized prompt payloads that remain too large after pruning.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Speed up Agent Manager switching for long sessions by lazily mounting collapsed historical tool details and sharing timeline hover infrastructure across activity bars.
+18 -15
View File
@@ -13,22 +13,25 @@ jobs:
is_fork: ${{ steps.fork-check.outputs.is_fork }}
steps:
- uses: actions/checkout@v6 # kilocode_change
- uses: Kilo-Org/paths-filter@master
# kilocode_change start
- name: Check changed files
id: filter
with:
filters: |
matched:
- "packages/kilo-ui/**"
- "packages/ui/**"
- "packages/util/**"
- "packages/sdk/js/**"
- "packages/kilo-vscode/webview-ui/**"
- "packages/kilo-vscode/.storybook/**"
- "packages/kilo-vscode/tests/visual-regression*"
- "packages/kilo-vscode/tests/permission-dock-dropdown*"
- "packages/kilo-vscode/tests/accessibility*" # kilocode_change
- "packages/kilo-docs/public/img/screenshot-tests/**"
- ".github/workflows/visual-regression.yml"
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
run: |
matched=false
while IFS= read -r file; do
case "$file" in
packages/kilo-ui/*|packages/ui/*|packages/util/*|packages/sdk/js/*|packages/kilo-vscode/webview-ui/*|packages/kilo-vscode/.storybook/*|packages/kilo-vscode/tests/visual-regression*|packages/kilo-vscode/tests/permission-dock-dropdown*|packages/kilo-vscode/tests/accessibility*|packages/kilo-docs/public/img/screenshot-tests/*|.github/workflows/visual-regression.yml)
matched=true
break
;;
esac
done < <(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${PR}/files" --jq '.[].filename')
echo "matched=$matched" >> "$GITHUB_OUTPUT"
echo "matched=$matched"
# kilocode_change end
- name: Check if PR is from a fork
id: fork-check
run: |
+1 -2
View File
@@ -1,4 +1,3 @@
{
"$schema": "https://opencode.ai/tui.json",
"keybinds": {}
"$schema": "https://opencode.ai/tui.json"
}
+95 -32
View File
@@ -127,7 +127,7 @@
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vitest": "^3.2.3",
"vitest": "4.1.0",
},
},
"packages/kilo-gateway": {
@@ -428,6 +428,8 @@
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@secretlint/core": "10.2.2",
"@secretlint/secretlint-rule-preset-recommend": "10.2.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
@@ -507,6 +509,7 @@
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/mime-types": "3.0.1",
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8",
@@ -2160,8 +2163,6 @@
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/diff": ["@types/diff@6.0.0", "", {}, "sha512-dhVCYGv3ZSbzmQaBSagrv1WJ6rXCdkyTcDyoNu1MD8JohI7pR7k8wdZEm+mvdxRKXyHVwckFzWU1vJc+Z29MlA=="],
@@ -2194,8 +2195,6 @@
"@types/mocha": ["@types/mocha@10.0.10", "", {}, "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/mssql": ["@types/mssql@9.1.11", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-vcujgrDbDezCxNDO4KY6gjwduLYOKfrexpRUwhoysRvcXZ3+IgZ/PMYFDgh8c3cQIxZ6skAwYo+H6ibMrBWPjQ=="],
"@types/node": ["@types/node@24.12.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g=="],
@@ -2298,19 +2297,19 @@
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/expect": ["@vitest/expect@4.1.0", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "chai": "^6.2.2", "tinyrainbow": "^3.0.3" } }, "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA=="],
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
"@vitest/mocker": ["@vitest/mocker@4.1.0", "", { "dependencies": { "@vitest/spy": "4.1.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw=="],
"@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.0", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A=="],
"@vitest/runner": ["@vitest/runner@3.2.4", "", { "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ=="],
"@vitest/runner": ["@vitest/runner@4.1.0", "", { "dependencies": { "@vitest/utils": "4.1.0", "pathe": "^2.0.3" } }, "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ=="],
"@vitest/snapshot": ["@vitest/snapshot@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "@vitest/utils": "4.1.0", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg=="],
"@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@vitest/spy": ["@vitest/spy@4.1.0", "", {}, "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw=="],
"@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@vitest/utils": ["@vitest/utils@4.1.0", "", { "dependencies": { "@vitest/pretty-format": "4.1.0", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.0.3" } }, "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw=="],
"@vscode/codicons": ["@vscode/codicons@0.0.44", "", {}, "sha512-F7qPRumUK3EHjNdopfICLGRf3iNPoZQt+McTHAn4AlOWPB3W2kL4H0S7uqEqbyZ6rCxaeDjpAn3MCUnwTu/VJQ=="],
@@ -2546,8 +2545,6 @@
"c8": ["c8@10.1.3", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.1", "@istanbuljs/schema": "^0.1.3", "find-up": "^5.0.0", "foreground-child": "^3.1.1", "istanbul-lib-coverage": "^3.2.0", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.1.6", "test-exclude": "^7.0.1", "v8-to-istanbul": "^9.0.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "monocart-coverage-reports": "^2" }, "optionalPeers": ["monocart-coverage-reports"], "bin": { "c8": "bin/c8.js" } }, "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"cacache": ["cacache@20.0.4", "", { "dependencies": { "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", "glob": "^13.0.0", "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", "ssri": "^13.0.0" } }, "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA=="],
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
@@ -2566,7 +2563,7 @@
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ=="],
@@ -2902,7 +2899,7 @@
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
@@ -3670,6 +3667,8 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
"oidc-token-hash": ["oidc-token-hash@5.2.0", "", {}, "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw=="],
@@ -4152,7 +4151,7 @@
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="],
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
@@ -4184,8 +4183,6 @@
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
"strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
"structured-source": ["structured-source@4.0.0", "", { "dependencies": { "boundary": "^2.0.0" } }, "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA=="],
@@ -4250,13 +4247,11 @@
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
"tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="],
"tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="],
@@ -4396,15 +4391,13 @@
"vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="],
"vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="],
"vite-plugin-icons-spritesheet": ["vite-plugin-icons-spritesheet@3.0.1", "", { "dependencies": { "chalk": "^5.4.1", "glob": "^11.0.1", "node-html-parser": "^7.0.1", "tinyexec": "^0.3.2" }, "peerDependencies": { "vite": ">=5.2.0" } }, "sha512-Cr0+Z6wRMwSwKisWW9PHeTjqmQFv0jwRQQMc3YgAhAgZEe03j21el0P/CA31KN/L5eiL1LhR14VTXl96LetonA=="],
"vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
"vitest": ["vitest@4.1.0", "", { "dependencies": { "@vitest/expect": "4.1.0", "@vitest/mocker": "4.1.0", "@vitest/pretty-format": "4.1.0", "@vitest/runner": "4.1.0", "@vitest/snapshot": "4.1.0", "@vitest/spy": "4.1.0", "@vitest/utils": "4.1.0", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.0", "@vitest/browser-preview": "4.1.0", "@vitest/browser-webdriverio": "4.1.0", "@vitest/ui": "4.1.0", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw=="],
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="],
@@ -4540,8 +4533,6 @@
"@antfu/install-pkg/package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
"@antfu/install-pkg/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"@aws-crypto/sha1-browser/@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-crypto/sha256-browser/@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=="],
@@ -4748,6 +4739,8 @@
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@vscode/ripgrep/yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
"@vscode/test-cli/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
@@ -4982,8 +4975,6 @@
"nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
"nypm/tinyexec": ["tinyexec@1.1.1", "", {}, "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
@@ -5072,6 +5063,10 @@
"stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="],
"storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"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=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -5080,8 +5075,6 @@
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"supertap/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"supports-hyperlinks/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -5116,6 +5109,8 @@
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"vite-plugin-icons-spritesheet/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"vite-plugin-solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
"vitest/why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
@@ -5222,6 +5217,10 @@
"@opencode-ai/storybook/@storybook/addon-docs/react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
"@opencode-ai/storybook/storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@opencode-ai/storybook/storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@opencode-ai/storybook/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=="],
"@opencode-ai/ui/@solid-primitives/resize-observer/@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-N8cIDAHbWcLahNRLr0knAAQvXyEdEMoAZvIMZKmhNb1mlx9e2UOv9BRD5YNwQUJwbNoYVhhLwFOEOcVXFx0HqA=="],
@@ -5234,12 +5233,28 @@
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@storybook/addon-links/storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@storybook/addon-links/storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@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/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@storybook/addon-onboarding/storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@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=="],
"@storybook/addon-vitest/storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@storybook/addon-vitest/storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@storybook/addon-vitest/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/builder-vite/storybook/@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@storybook/builder-vite/storybook/@vitest/spy": ["@vitest/spy@3.2.4", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw=="],
"@storybook/builder-vite/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=="],
"@tailwindcss/vite/@tailwindcss/node/lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
@@ -5526,6 +5541,12 @@
"secretlint/globby/slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="],
"storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"table/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -5570,6 +5591,36 @@
"@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
"@opencode-ai/storybook/storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@opencode-ai/storybook/storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"@opencode-ai/storybook/storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"@storybook/addon-links/storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@storybook/addon-links/storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"@storybook/addon-links/storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"@storybook/addon-onboarding/storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@storybook/addon-onboarding/storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"@storybook/addon-onboarding/storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"@storybook/addon-vitest/storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@storybook/addon-vitest/storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"@storybook/addon-vitest/storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"@storybook/builder-vite/storybook/@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
"@storybook/builder-vite/storybook/@vitest/expect/chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"@storybook/builder-vite/storybook/@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
"@tailwindcss/vite/@tailwindcss/node/lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"@tailwindcss/vite/@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
@@ -5710,6 +5761,8 @@
"readdir-glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"tar-fs/tar-stream/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"test-exclude/glob/jackspeak/@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
@@ -5720,6 +5773,16 @@
"@manypkg/find-root/find-up/locate-path/p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"@opencode-ai/storybook/storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"@storybook/addon-links/storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"@storybook/addon-onboarding/storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"@storybook/addon-vitest/storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"@storybook/builder-vite/storybook/@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@vscode/test-cli/glob/jackspeak/@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Q3e+/Tq3lWDP0+Kg+FiDPLLKx9EVuBKvDsKz/xYUXBE=",
"aarch64-linux": "sha256-MHjY5/2QdIDDRn8+67DlVw2HDtMcUOOxEZEQkHtwW1Y=",
"aarch64-darwin": "sha256-5aZcE2Df1U81MLOLEAhAPwyjlwMuMQS4Xzsq3Wbi6mI=",
"x86_64-darwin": "sha256-Z0pl2KJxBpe7kyHRx9dAmWA0YmWjCosi6Hn8SObffvc="
"x86_64-linux": "sha256-STfDdoDiRwXnQ3B8lueNSZp4iJFL9TAME6R+dnxI1eU=",
"aarch64-linux": "sha256-v/N0eCo1pa3n5QTMdLHBYICIEdI0BiNEsMzgRbOEXfA=",
"aarch64-darwin": "sha256-gC6iPlR+UTo336ukkHIzHKQMwxFOdyTIl9UCIh/XVdw=",
"x86_64-darwin": "sha256-es6bcIwldn0sKHnC7TqOaL+IST3zZ3JrHbH8ojGbQQ0="
}
}
+1 -1
View File
@@ -32,7 +32,7 @@
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vitest": "^3.2.3"
"vitest": "4.1.0"
},
"peerDependencies": {}
}
@@ -3,6 +3,7 @@ package ai.kilocode.client.session.ui.model
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.ui.PickerButton
import ai.kilocode.rpc.dto.ModelSelectionDto
import com.intellij.icons.AllIcons
import com.intellij.openapi.ui.popup.JBPopup
import com.intellij.openapi.ui.popup.JBPopupFactory
import com.intellij.openapi.ui.popup.PopupShowOptions
@@ -34,6 +35,7 @@ import javax.swing.JScrollPane
import javax.swing.KeyStroke
import javax.swing.ListSelectionModel
import javax.swing.ScrollPaneConstants
import javax.swing.SwingConstants
import javax.swing.SwingUtilities
import javax.swing.event.DocumentEvent
@@ -100,11 +102,17 @@ class ModelPicker : PickerButton() {
if (items.isEmpty()) {
isEnabled = false
text = " "
icon = null
cursor = Cursor.getDefaultCursor()
return
}
val display = selected?.display ?: items.firstOrNull()?.display ?: ""
val item = selected ?: items.firstOrNull()
val display = item?.display ?: ""
text = "${ModelText.sanitize(display)}"
icon = if (item?.let(ModelText::collectsData) == true) AllIcons.General.Warning else null
horizontalTextPosition = SwingConstants.LEFT
iconTextGap = JBUI.CurrentTheme.ActionsList.elementIconGap()
toolTipText = if (item?.let(ModelText::collectsData) == true) ModelText.dataCollected() else KiloBundle.message("model.picker.tooltip")
isEnabled = true
cursor = Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)
}
@@ -406,5 +414,11 @@ internal object ModelText {
fun providerSort(id: String): Int = if (id == "kilo") 0 else 1
fun dataCollected(): String = KiloBundle.message("model.picker.dataCollected")
fun freeLabel(): String = KiloBundle.message("model.picker.free")
fun collectsData(item: ModelPicker.Item): Boolean = item.free && item.provider == "kilo"
fun freeBg(): JBColor = JBColor.namedColor("Kilo.ModelPicker.freeBadgeBackground", JBColor(0x95D6AC, 0x7FCA99))
}
@@ -1,6 +1,5 @@
package ai.kilocode.client.session.ui.model
import ai.kilocode.client.plugin.KiloBundle
import ai.kilocode.client.session.ui.PickerRow
import ai.kilocode.client.ui.FilledBadgeIcon
import ai.kilocode.client.ui.UiStyle
@@ -66,16 +65,22 @@ internal class ModelPickerRenderer(
}
private val title = SimpleColoredComponent()
private val badge = FilledBadgeIcon(
KiloBundle.message("model.picker.free"),
ModelText.freeLabel(),
ModelText.freeBg(),
JBColor.namedColor("Kilo.ModelPicker.freeBadgeForeground", JBColor.WHITE),
)
private val warn = JBLabel(AllIcons.General.Warning).apply {
toolTipText = ModelText.dataCollected()
}
private val provider = JBLabel()
private val head = JPanel(FlowLayout(FlowLayout.LEFT, 0, 0)).apply {
add(title)
add(BadgeLabel(badge).apply {
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
})
add(warn.apply {
border = JBUI.Borders.emptyLeft(JBUI.CurrentTheme.ActionsList.elementIconGap())
})
add(provider)
}
private val star = JBLabel().apply {
@@ -92,7 +97,7 @@ internal class ModelPickerRenderer(
init {
isOpaque = true
top.isOpaque = true
UiStyle.Components.transparent(row, check, title, head, provider, star)
UiStyle.Components.transparent(row, check, title, head, warn, provider, star)
row.border = JBUI.Borders.empty(
UiStyle.Gap.md(),
UiStyle.Gap.lg(),
@@ -134,6 +139,7 @@ internal class ModelPickerRenderer(
title.append(name.model, SimpleTextAttributes(SimpleTextAttributes.STYLE_BOLD, fg))
head.getComponent(1).isVisible = value.item.free
warn.isVisible = ModelText.collectsData(value.item)
provider.isVisible = value.favorite
provider.text = value.item.providerName
provider.foreground = weak
@@ -155,5 +161,11 @@ internal class ModelPickerRenderer(
internal fun badgeVisible(): Boolean = head.getComponent(1).isVisible
internal fun badgeText(): String = badge.text
internal fun warningVisible(): Boolean = warn.isVisible
internal fun warningTooltip(): String? = warn.toolTipText
private class BadgeLabel(icon: Icon) : JBLabel(icon)
}
@@ -140,6 +140,7 @@ model.picker.no.matches=No matching models
model.picker.favorite.add=Add to favorites
model.picker.favorite.remove=Remove from favorites
model.picker.free=Free
model.picker.dataCollected=Data collected
model.picker.reset=Reset model to default
reasoning.picker.tooltip=Select reasoning effort
@@ -153,6 +153,25 @@ class ModelPickerTest : BasePlatformTestCase() {
assertEquals(listOf("low", "high"), item.variants)
}
fun `test selected free model indicates data collection`() {
val picker = ModelPicker()
picker.setItems(listOf(item("auto", "Auto Free", "kilo", "Kilo", free = true)))
assertFalse(picker.text.contains("Data collected"))
assertSame(AllIcons.General.Warning, picker.icon)
assertEquals("Data collected", picker.toolTipText)
}
fun `test selected non-kilo free model does not indicate data collection`() {
val picker = ModelPicker()
picker.setItems(listOf(item("free", "OpenRouter Free", "openrouter", "OpenRouter", free = true)))
assertNull(picker.icon)
assertEquals("Select model", picker.toolTipText)
}
fun `test display parts split provider prefix`() {
val parts = ModelText.parts(item("claude-opus", "Anthropic Claude Opus 4.7", "anthropic", "Anthropic"))
@@ -261,6 +280,22 @@ class ModelPickerTest : BasePlatformTestCase() {
renderer.getListCellRendererComponent(list, row, 0, false, false)
assertTrue(renderer.badgeVisible())
assertEquals("Free", renderer.badgeText())
assertTrue(renderer.warningVisible())
assertEquals("Data collected", renderer.warningTooltip())
}
fun `test renderer hides data collection warning for non-kilo free model`() {
val row = ModelPickerRow(ModelPicker.Item("free", "Free", "openrouter", "OpenRouter", free = true), "OpenRouter", false)
val model = CollectionListModel(listOf(row))
val renderer = ModelPickerRenderer(model, { null }, { emptySet() })
val list = JBList(model)
renderer.getListCellRendererComponent(list, row, 0, false, false)
assertTrue(renderer.badgeVisible())
assertEquals("Free", renderer.badgeText())
assertFalse(renderer.warningVisible())
}
private fun item(
@@ -2189,6 +2189,11 @@ ToolRegistry.register({
const subtitle = () => props.input.description ?? props.metadata.description
const key = () => toolOpenKey(props)
const [open, setOpen] = createSignal(readToolOpen(key(), props.defaultOpen ?? true) ?? true)
const [mounted, setMounted] = createSignal(open())
createEffect(() => {
if (open() || pending()) setMounted(true)
})
// also apply processCarriageReturns for Windows CLI tools
const cmd = createMemo(() => {
@@ -2208,6 +2213,7 @@ ToolRegistry.register({
{...props}
icon="console"
animated
hasDetails
defaultOpen={props.defaultOpen ?? true}
onOpenChange={setOpen}
allowPendingToggle
@@ -2222,7 +2228,9 @@ ToolRegistry.register({
</div>
}
>
<BashHighlightedOutput cmd={cmd()} output={out()} outputPath={props.metadata.outputPath} active={open()} />
<Show when={mounted()}>
<BashHighlightedOutput cmd={cmd()} output={out()} outputPath={props.metadata.outputPath} active={open()} />
</Show>
</BasicTool>
)
},
@@ -2293,6 +2301,7 @@ ToolRegistry.register({
{...props}
icon="code-lines"
defer
hasDetails
trigger={
<div data-component="edit-trigger">
<div data-slot="message-part-title-area">
@@ -2407,6 +2416,7 @@ ToolRegistry.register({
{...props}
icon="code-lines"
defer
hasDetails
trigger={
<div data-component="write-trigger">
<div data-slot="message-part-title-area">
@@ -2539,6 +2549,7 @@ ToolRegistry.register({
{...props}
icon="code-lines"
defer
hasDetails
trigger={
<div data-component={single() ? "edit-trigger" : "write-trigger"}>
<div data-slot="message-part-title-area">
+7 -121
View File
@@ -1,7 +1,5 @@
import * as path from "path"
import * as vscode from "vscode"
import { buildPreviewPath, getPreviewCommand, getPreviewDir, parseImage, trimEntries } from "./image-preview"
import { isAbsolutePath } from "./path-utils"
import type {
KiloClient,
Session,
@@ -17,6 +15,7 @@ import { FileIgnoreController } from "./services/autocomplete/shims/FileIgnoreCo
import { ChatTextAreaAutocomplete } from "./services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete"
import { buildWebviewHtml, getWebviewFontSize } from "./utils"
import { saveImage } from "./kilo-provider/save-image"
import { handleEditorAction } from "./kilo-provider/editor-actions"
import { exportTranscript } from "./kilo-provider/export-transcript"
import {
TelemetryProxy,
@@ -778,9 +777,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "refreshProfile":
await handleRefreshProfile(this.authCtx)
break
case "openExternal":
this.openExternal(message.url)
break
case "openSettingsPanel":
vscode.commands.executeCommand("kilo-code.new.settingsButtonClicked", message.tab)
break
@@ -793,9 +789,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "openMarketplacePanel":
vscode.commands.executeCommand("kilo-code.new.marketplaceButtonClicked", this.projectDirectory)
break
case "openDiffVirtual":
this.openDiffVirtual(message.diff, message.initialDiffStyle)
break
case "forkSession":
handleForkSession(this.forkCtx, message.sessionId, message.messageId).catch((e) =>
console.error("[Kilo New] handleForkSession failed:", e),
@@ -810,9 +803,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "openSubAgentViewer":
vscode.commands.executeCommand("kilo-code.new.openSubAgentViewer", message.sessionID, message.title)
break
case "previewImage":
this.handlePreviewImage(message.dataUrl, message.filename)
break
case "saveImage":
return saveImage(this.getWorkspaceDirectory(this.currentSession?.id), message)
case "requestProviders":
@@ -1153,16 +1143,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.webviewMessageDisposable = watchFontSizeConfig((msg) => this.postMessage(msg), this.webviewMessageDisposable)
}
private openExternal(url: unknown): void {
if (typeof url !== "string") return
void vscode.env.openExternal(vscode.Uri.parse(url))
}
private openDiffVirtual(diff: unknown, initialDiffStyle?: unknown): void {
if (!this.diffVirtualProvider || !diff) return
const d = diff as import("./DiffVirtualProvider").DiffVirtualFile
d.initialDiffStyle = initialDiffStyle === "split" ? "split" : "unified"
this.diffVirtualProvider.open(d)
private handleEditorOpenMessage(message: Parameters<typeof handleEditorAction>[0]): boolean {
return handleEditorAction(message, {
dir: () => this.getWorkspaceDirectory(this.currentSession?.id),
diff: this.diffVirtualProvider,
storage: this.extensionContext?.globalStorageUri,
})
}
private async handleFetchMarketplaceData(): Promise<void> {
@@ -2940,106 +2926,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
}
private handlePreviewImage(dataUrl: string, filename: string): void {
const dir = this.extensionContext?.globalStorageUri
if (!dir) return
const img = parseImage(dataUrl, filename)
if (!img) return
const root = vscode.Uri.joinPath(dir, getPreviewDir())
const uri = vscode.Uri.joinPath(dir, buildPreviewPath(img.name, Date.now()))
const clean = () =>
vscode.workspace.fs.readDirectory(root).then(
(items) => {
const stale = trimEntries(items.map(([name]) => ({ path: name })))
return Promise.all(
stale.map((name) =>
Promise.resolve(vscode.workspace.fs.delete(vscode.Uri.joinPath(root, name), { recursive: true })).then(
undefined,
(err: unknown) => {
console.warn("[Kilo New] KiloProvider: Failed to delete stale preview:", err)
},
),
),
)
},
() => [],
)
const open = () =>
vscode.commands
.executeCommand(...getPreviewCommand(uri))
.then(undefined, () => vscode.commands.executeCommand("vscode.open", uri))
void vscode.workspace.fs
.createDirectory(root)
.then(() => vscode.workspace.fs.writeFile(uri, img.data))
.then(() => clean())
.then(open, (err) => console.error("[Kilo New] KiloProvider: Failed to preview image:", err))
}
private handleEditorOpenMessage(message: {
type?: string
filePath?: string
line?: number
column?: number
content?: string
language?: string
}): boolean {
if (message.type === "openFile") {
if (message.filePath) this.handleOpenFile(message.filePath, message.line, message.column)
return true
}
if (message.type === "openContent") {
if (message.content) this.handleOpenContent(message.content, message.language)
return true
}
return false
}
/**
* Handle openContent request - open arbitrary text in an untitled VS Code editor tab.
*/
private handleOpenContent(content: string, language?: string): void {
vscode.workspace.openTextDocument({ content, language: language || "log" }).then(
(doc) => vscode.window.showTextDocument(doc, { preview: true }),
(err) => console.error("[Kilo New] KiloProvider: Failed to open content:", err),
)
}
/**
* Handle openFile request from the webview — open a file in the VS Code editor.
* Resolves relative paths against the current session's directory (which may be
* a worktree path registered via setSessionDirectory), falling back to workspace root.
* Absolute paths (Unix `/…` or Windows `C:\…`) are used as-is.
*/
private handleOpenFile(filePath: string, line?: number, column?: number): void {
const uri = isAbsolutePath(filePath)
? vscode.Uri.file(filePath)
: vscode.Uri.joinPath(vscode.Uri.file(this.getWorkspaceDirectory(this.currentSession?.id)), filePath)
vscode.workspace.fs.stat(uri).then(
(stat) => {
if (stat.type & vscode.FileType.Directory) {
vscode.commands.executeCommand("revealInExplorer", uri)
return
}
vscode.workspace.openTextDocument(uri).then(
(doc) => {
const options: vscode.TextDocumentShowOptions = { preview: true }
if (line !== undefined && line > 0) {
const col = column !== undefined && column > 0 ? column - 1 : 0
const pos = new vscode.Position(line - 1, col)
options.selection = new vscode.Range(pos, pos)
}
vscode.window.showTextDocument(doc, options)
},
(err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err),
)
},
(err) => console.error("[Kilo New] KiloProvider: Path does not exist:", uri.fsPath, err),
)
}
/**
* Handle a generic setting update from the webview.
* The key uses dot notation relative to `kilo-code.new` (e.g. "browserAutomation.enabled").
@@ -68,7 +68,7 @@ export function toSessionDiffFile(raw: SnapshotFileDiff): DiffFile {
// parse it, so short-circuit to empty strings.
const view = raw.patch === "" ? null : normalize(raw)
return {
file: raw.file,
file: raw.file ?? "",
before: view ? text(view, "deletions") : "",
after: view ? text(view, "additions") : "",
patch: raw.patch,
@@ -145,7 +145,7 @@ async function resolveOverrideRef(
*/
function toDiffFile(entry: WorktreeDiffEntry): DiffFile {
return {
file: entry.file,
file: entry.file ?? "",
before: entry.before ?? "",
after: entry.after ?? "",
patch: entry.patch,
@@ -0,0 +1,131 @@
import * as vscode from "vscode"
import { buildPreviewPath, getPreviewCommand, getPreviewDir, parseImage, trimEntries } from "../image-preview"
import { isAbsolutePath } from "../path-utils"
import type { DiffVirtualFile, DiffVirtualProvider } from "../DiffVirtualProvider"
type EditorOpenMessage = {
type?: string
filePath?: string
line?: number
column?: number
content?: string
language?: string
}
function openExternal(url: unknown): void {
if (typeof url !== "string") return
void vscode.env.openExternal(vscode.Uri.parse(url))
}
function openDiffVirtual(provider: DiffVirtualProvider | undefined, diff: unknown, initialDiffStyle?: unknown): void {
if (!provider || !diff) return
const file = diff as DiffVirtualFile
file.initialDiffStyle = initialDiffStyle === "split" ? "split" : "unified"
provider.open(file)
}
function previewImage(dir: vscode.Uri | undefined, dataUrl: string, filename: string): void {
if (!dir) return
const img = parseImage(dataUrl, filename)
if (!img) return
const root = vscode.Uri.joinPath(dir, getPreviewDir())
const uri = vscode.Uri.joinPath(dir, buildPreviewPath(img.name, Date.now()))
const clean = () =>
vscode.workspace.fs.readDirectory(root).then(
(items) => {
const stale = trimEntries(items.map(([name]) => ({ path: name })))
return Promise.all(
stale.map((name) =>
Promise.resolve(vscode.workspace.fs.delete(vscode.Uri.joinPath(root, name), { recursive: true })).then(
undefined,
(err: unknown) => {
console.warn("[Kilo New] KiloProvider: Failed to delete stale preview:", err)
},
),
),
)
},
() => [],
)
const open = () =>
vscode.commands
.executeCommand(...getPreviewCommand(uri))
.then(undefined, () => vscode.commands.executeCommand("vscode.open", uri))
void vscode.workspace.fs
.createDirectory(root)
.then(() => vscode.workspace.fs.writeFile(uri, img.data))
.then(() => clean())
.then(open, (err) => console.error("[Kilo New] KiloProvider: Failed to preview image:", err))
}
export function handleEditorAction(
message: EditorOpenMessage & {
url?: unknown
diff?: unknown
initialDiffStyle?: unknown
dataUrl?: string
filename?: string
},
opts: {
dir: () => string
diff?: DiffVirtualProvider
storage?: vscode.Uri
},
): boolean {
if (message.type === "openFile") {
if (message.filePath) openFile(opts.dir(), message.filePath, message.line, message.column)
return true
}
if (message.type === "openContent") {
if (message.content) openContent(message.content, message.language)
return true
}
if (message.type === "openExternal") {
openExternal(message.url)
return true
}
if (message.type === "openDiffVirtual") {
openDiffVirtual(opts.diff, message.diff, message.initialDiffStyle)
return true
}
if (message.type === "previewImage") {
if (message.dataUrl && message.filename) previewImage(opts.storage, message.dataUrl, message.filename)
return true
}
return false
}
function openContent(content: string, language?: string): void {
vscode.workspace.openTextDocument({ content, language: language || "log" }).then(
(doc) => vscode.window.showTextDocument(doc, { preview: true }),
(err) => console.error("[Kilo New] KiloProvider: Failed to open content:", err),
)
}
function openFile(dir: string, filePath: string, line?: number, column?: number): void {
const uri = isAbsolutePath(filePath) ? vscode.Uri.file(filePath) : vscode.Uri.joinPath(vscode.Uri.file(dir), filePath)
vscode.workspace.fs.stat(uri).then(
(stat) => {
if (stat.type & vscode.FileType.Directory) {
vscode.commands.executeCommand("revealInExplorer", uri)
return
}
vscode.workspace.openTextDocument(uri).then(
(doc) => {
const options: vscode.TextDocumentShowOptions = { preview: true }
if (line !== undefined && line > 0) {
const col = column !== undefined && column > 0 ? column - 1 : 0
const pos = new vscode.Position(line - 1, col)
options.selection = new vscode.Range(pos, pos)
}
vscode.window.showTextDocument(doc, options)
},
(err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err),
)
},
(err) => console.error("[Kilo New] KiloProvider: Path does not exist:", uri.fsPath, err),
)
}
@@ -202,6 +202,15 @@ describe("Agent Manager Provider Messages", () => {
})
})
describe("Agent Manager Model Picker", () => {
it("discloses data collection for free models in compare picker", () => {
const source = fs.readFileSync(path.join(ROOT, "webview-ui/agent-manager/MultiModelSelector.tsx"), "utf-8")
expect(source).toContain("model.tag.dataCollected")
expect(source).toContain("model.isFree")
})
})
// ---------------------------------------------------------------------------
// Provider message routing — static-analysis regression tests
//
@@ -19,6 +19,7 @@ import path from "node:path"
const MONOREPO_ROOT = path.resolve(import.meta.dir, "../../../..")
const KILO_UI_DIR = path.join(MONOREPO_ROOT, "packages/kilo-ui")
const BASIC_TOOL_FILE = path.join(MONOREPO_ROOT, "packages/ui/src/components/basic-tool.tsx")
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")
@@ -279,3 +280,34 @@ describe("BasicTool export contract (runtime)", () => {
expect(result.ok, `BasicTool export check failed: ${result.output}`).toBe(true)
})
})
describe("Collapsed deferred tool details contract (source)", () => {
const basic = fs.readFileSync(BASIC_TOOL_FILE, "utf-8")
const message = fs.readFileSync(KILO_MESSAGE_PART_FILE, "utf-8")
it("uses an explicit details hint before touching deferred children", () => {
expect(basic).toContain("hasDetails?: boolean")
expect(basic).toContain("props.hasDetails ?? !!props.children")
expect(basic).toMatch(/<Show when=\{!props\.defer \|\| ready\(\)\}>\{props\.children\}<\/Show>/)
})
it("opts edit-family transcript cards into collapsed lazy details", () => {
for (const name of ["edit", "write", "apply_patch"]) {
const block =
message.match(
new RegExp(`ToolRegistry\\.register\\(\\{\\s*name:\\s*"${name}"[\\s\\S]*?(?=ToolRegistry\\.register\\(|$)`),
)?.[0] ?? ""
expect(block).toContain("defer")
expect(block).toContain("hasDetails")
}
})
it("lazy-mounts completed bash output and retains it after first expansion", () => {
const block =
message.match(/ToolRegistry\.register\(\{\s*name:\s*"bash"[\s\S]*?(?=ToolRegistry\.register\(|$)/)?.[0] ?? ""
expect(block).toContain("const [mounted, setMounted] = createSignal(open())")
expect(block).toMatch(/if \(open\(\) \|\| pending\(\)\) setMounted\(true\)/)
expect(block).toContain("hasDetails")
expect(block).toMatch(/<Show when=\{mounted\(\)\}>[\s\S]*?<BashHighlightedOutput/)
})
})
@@ -0,0 +1,21 @@
import { describe, expect, it } from "bun:test"
import fs from "node:fs"
import path from "node:path"
const root = path.resolve(import.meta.dir, "../..")
const preview = fs.readFileSync(path.join(root, "webview-ui/src/components/shared/ModelPreview.tsx"), "utf8")
const styles = fs.readFileSync(path.join(root, "webview-ui/src/styles/model-selector.css"), "utf8")
describe("model preview data collection line", () => {
it("shows a visible data collection row above context details", () => {
const data = preview.indexOf('class="model-preview-data-line"')
const context = preview.indexOf("model.preview.label.context")
expect(data).toBeGreaterThanOrEqual(0)
expect(context).toBeGreaterThan(data)
expect(preview).toContain('Icon name="warning"')
expect(preview).toContain("isDataCollectedModel(model())")
expect(preview).toContain('language.t("model.tag.dataCollected")')
expect(styles).toContain(".model-preview-data-line")
})
})
@@ -6,6 +6,8 @@ import {
sanitizeName,
KILO_GATEWAY_ID,
PROVIDER_ORDER,
freeDataLabel,
isDataCollectedModel,
} from "../../webview-ui/src/components/shared/model-selector-utils"
const labels = { select: "Select model", noProviders: "No providers", notSet: "Not set" }
@@ -94,6 +96,20 @@ describe("sanitizeName", () => {
})
})
describe("freeDataLabel", () => {
it("uses the data collection label without repeating free", () => {
expect(freeDataLabel("Free", "Data collected")).toBe("Data collected")
})
})
describe("isDataCollectedModel", () => {
it("only marks free Kilo Gateway models as data collected", () => {
expect(isDataCollectedModel({ providerID: KILO_GATEWAY_ID, isFree: true })).toBe(true)
expect(isDataCollectedModel({ providerID: "openrouter", isFree: true })).toBe(false)
expect(isDataCollectedModel({ providerID: KILO_GATEWAY_ID, isFree: false })).toBe(false)
})
})
describe("buildTriggerLabel", () => {
it("returns resolved model name for non-kilo provider unchanged", () => {
expect(buildTriggerLabel("GPT-4o", "openai", undefined, null, false, "", true, labels)).toBe("GPT-4o")
@@ -1,6 +1,6 @@
import { describe, expect, it } from "bun:test"
import { terminal } from "../../webview-ui/src/context/session-outcome"
import type { Message, TodoItem } from "../../webview-ui/src/types/messages"
import type { Message, Part, TodoItem } from "../../webview-ui/src/types/messages"
function message(finish?: string, error?: Message["error"]): Message {
return {
@@ -17,6 +17,27 @@ function todo(status: TodoItem["status"]): TodoItem {
return { id: status, content: status, status }
}
function snapshotMessage(id = "snapshot"): Message {
return {
...message("other"),
id,
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
}
}
function snapshotParts(reason = "other"): Part[] {
return [
{ id: "start", type: "step-start" },
{ id: "progress", type: "text", text: "Initializing snapshot...", synthetic: true },
{ id: "finish", type: "step-finish", reason },
]
}
function lookup(id: string, parts: Part[]): (msg: Message) => Part[] | undefined {
return (msg) => (msg.id === id ? parts : msg.parts)
}
describe("terminal", () => {
it("returns no terminal state before a turn closes", () => {
expect(terminal({ messages: [message("stop")], todos: [] })).toBeUndefined()
@@ -48,6 +69,50 @@ describe("terminal", () => {
expect(terminal({ reason: "completed", messages: [message("other")], todos: [] })?.kind).toBe("unexpected")
})
it("ignores snapshot-only assistant tails when choosing the terminal finish", () => {
const real = { ...message("length"), id: "real" }
const snap = snapshotMessage()
expect(
terminal({
reason: "completed",
messages: [real, snap],
todos: [],
parts: lookup(snap.id, snapshotParts()),
}),
).toEqual({ kind: "limit", tone: "warning", finish: "length", remaining: 0 })
})
it("uses inline parts to ignore snapshot-only assistant tails", () => {
const real = { ...message("stop"), id: "real" }
const snap = { ...snapshotMessage(), parts: snapshotParts() }
expect(terminal({ reason: "completed", messages: [real, snap], todos: [todo("pending")] })).toEqual({
kind: "incomplete",
tone: "warning",
finish: "stop",
remaining: 1,
})
})
it("keeps unexpected warnings for real other finishes", () => {
const snap = {
...snapshotMessage(),
parts: [...snapshotParts(), { id: "real", type: "text", text: "Actual assistant text" } satisfies Part],
}
expect(terminal({ reason: "completed", messages: [snap], todos: [] })?.kind).toBe("unexpected")
})
it("requires synthetic snapshot progress before ignoring an other finish", () => {
const snap = {
...snapshotMessage(),
parts: [{ id: "progress", type: "text", text: "Initializing snapshot..." } satisfies Part],
}
expect(terminal({ reason: "completed", messages: [snap], todos: [] })?.kind).toBe("unexpected")
})
it("surfaces interruption and failures without a rendered error", () => {
expect(terminal({ reason: "interrupted", messages: [message("stop")], todos: [todo("pending")] })).toEqual({
kind: "interrupted",
@@ -34,6 +34,19 @@ const layout = (messages: Message[], status: SessionStatusInfo, boundary?: strin
)
}
const expectLayout = (
messages: Message[],
status: SessionStatusInfo,
expected: { virtual: string[]; direct: string[]; queued: string[] },
) => {
const result = layout(messages, status)
expect({
virtual: result.virtual.map((turn) => turn.user.id),
direct: result.direct.map((turn) => turn.user.id),
queued: result.queued.map((turn) => turn.user.id),
}).toEqual(expected)
}
describe("queuedUserMessageIDs", () => {
it("keeps follow-ups queued before the first assistant exists", () => {
const messages = [user("message_1"), user("message_2")]
@@ -109,6 +122,58 @@ describe("partitionTurns", () => {
expect(result.queued).toEqual([])
})
it("keeps completed resumable assistants direct while the session is active", () => {
const messages = (finish: string) => [
user("message_1"),
assistant("message_2", "message_1", { finish, time: { created: 1, completed: 2 } }),
user("message_3"),
]
const expected = { virtual: [], direct: ["message_1"], queued: ["message_3"] }
expectLayout(messages("tool-calls"), { type: "busy" }, expected)
expectLayout(messages("unknown"), { type: "busy" }, expected)
expectLayout(messages("tool-calls"), { type: "retry", attempt: 1, message: "retrying", next: 2 }, expected)
expectLayout(messages("tool-calls"), { type: "offline", message: "offline" }, expected)
})
it("does not retain a completed tool-call assistant with an error", () => {
const messages = [
user("message_1"),
assistant("message_2", "message_1", {
finish: "tool-calls",
time: { created: 1, completed: 2 },
error: { name: "MessageAbortedError" },
}),
user("message_3"),
]
expectLayout(messages, { type: "busy" }, { virtual: ["message_1"], direct: ["message_3"], queued: [] })
})
it("does not revive a completed tool-call step behind an errored assistant", () => {
const messages = [
user("message_1"),
assistant("message_2", "message_1", { finish: "tool-calls", time: { created: 1, completed: 2 } }),
assistant("message_3", "message_1", { error: { name: "MessageAbortedError" } }),
user("message_4"),
]
expectLayout(messages, { type: "busy" }, { virtual: ["message_1"], direct: ["message_4"], queued: [] })
})
it("does not revive completed resumable steps behind a terminal assistant", () => {
const messages = (finish: string) => [
user("message_1"),
assistant("message_2", "message_1", { finish, time: { created: 1, completed: 2 } }),
assistant("message_3", "message_1", { finish: "stop", time: { created: 3, completed: 4 } }),
user("message_4"),
]
const expected = { virtual: ["message_1"], direct: ["message_4"], queued: [] }
expectLayout(messages("tool-calls"), { type: "busy" }, expected)
expectLayout(messages("unknown"), { type: "busy" }, expected)
})
it("keeps an active partial turn direct when later loaded prompts are queued", () => {
const result = layout([assistant("message_2", "message_1", { finish: "tool-calls" }), user("message_3")], {
type: "busy",
@@ -372,6 +437,15 @@ describe("activeUserMessageID", () => {
expect(activeUserMessageID(messages, { type: "busy" })).toBe("message_1")
})
it("ignores completed tool-call assistants after the session becomes idle", () => {
const messages = [
user("message_1"),
assistant("message_2", "message_1", { finish: "tool-calls", time: { created: 1, completed: 2 } }),
]
expect(activeUserMessageID(messages, { type: "idle" })).toBeUndefined()
})
it("keeps unknown assistants active until cleanup finishes", () => {
const messages = [user("message_1"), assistant("message_2", "message_1", { finish: "unknown" }), user("message_3")]
@@ -0,0 +1,30 @@
import { describe, expect, it } from "bun:test"
import { readFileSync } from "node:fs"
import { join } from "node:path"
/**
* Regression guard for timeline tooltip mount cost.
*
* A long session can render hundreds of timeline bars. Wrapping every bar in
* the shared Tooltip component creates a Kobalte tooltip instance and
* MutationObserver per bar during session activation. The timeline keeps all
* bars but delegates hover handling to one portal tooltip instead.
*/
describe("TaskTimeline delegated tooltip contract", () => {
const path = join(__dirname, "..", "..", "webview-ui", "src", "components", "chat", "TaskTimeline.tsx")
const src = readFileSync(path, "utf8")
it("does not mount one shared Tooltip component per timeline bar", () => {
expect(src).not.toMatch(/@kilocode\/kilo-ui\/tooltip/)
expect(src).not.toMatch(/<Tooltip\b/)
})
it("keeps bar labels and renders one delegated portal tooltip", () => {
expect(src).toMatch(/data-tip=\{bar\(\)\.tip\}/)
expect(src).toMatch(/role="img"/)
expect(src).toMatch(/aria-label=\{bar\(\)\.tip\}/)
expect(src).toMatch(/if \(!bar \|\| !ref\?\.contains\(bar\)\) return hideTip\(\)/)
expect(src).toMatch(/<Portal>/)
expect(src).toMatch(/class="task-timeline-tooltip"/)
})
})
@@ -1,9 +1,15 @@
import { type Component, createSignal, createMemo, For, Show, onMount } from "solid-js"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useProvider } from "../src/context/provider"
import type { EnrichedModel } from "../src/context/provider"
import { useLanguage } from "../src/context/language"
import { KILO_GATEWAY_ID, providerSortKey } from "../src/components/shared/model-selector-utils"
import {
KILO_GATEWAY_ID,
freeDataLabel,
isDataCollectedModel,
providerSortKey,
} from "../src/components/shared/model-selector-utils"
import {
type ModelAllocations,
MAX_MULTI_VERSIONS,
@@ -32,6 +38,8 @@ export const MultiModelSelector: Component<{
const { t } = useLanguage()
const [search, setSearch] = createSignal("")
let searchRef: HTMLInputElement | undefined
const freeLabel = () => t("model.tag.free")
const dataLabel = () => freeDataLabel(t("model.tag.free"), t("model.tag.dataCollected"))
const visibleModels = createMemo(() => {
const c = connected()
@@ -107,6 +115,18 @@ export const MultiModelSelector: Component<{
}
/>
<span class="am-mm-item-name">{model.name}</span>
<Show when={model.isFree}>
<span class="am-mm-free-data">
<span class="am-mm-free-badge">{freeLabel()}</span>
<Show when={isDataCollectedModel(model)}>
<Tooltip value={dataLabel()} placement="top">
<span class="am-mm-free-data-icon" aria-label={dataLabel()}>
<Icon name="warning" size="small" />
</span>
</Tooltip>
</Show>
</span>
</Show>
</label>
<Show when={checked()}>
<select
@@ -2801,6 +2801,34 @@ body.am-wt-dragging-active * {
white-space: nowrap;
}
.am-mm-free-data {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
max-width: 132px;
white-space: nowrap;
}
.am-mm-free-badge {
display: inline-flex;
align-items: center;
padding: 1px 4px;
border-radius: var(--radius-sm);
color: #fff;
background: var(--vscode-testing-iconPassed, #4caf50);
font-size: var(--kilo-font-size-10);
overflow: hidden;
text-overflow: ellipsis;
}
.am-mm-free-data-icon {
display: inline-flex;
align-items: center;
color: var(--vscode-inputValidation-warningForeground, var(--vscode-descriptionForeground));
flex-shrink: 0;
}
.am-mm-count-select {
flex-shrink: 0;
padding: 2px 4px;
@@ -12,8 +12,8 @@
* updates bindings in place (unlike React). Even 1000+ bars are fine.
*/
import { Component, Index, createMemo, createEffect, on, onCleanup } from "solid-js"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { Component, Index, Show, createMemo, createEffect, createSignal, on, onCleanup } from "solid-js"
import { Portal } from "solid-js/web"
import { useSession } from "../../context/session"
import { color, label } from "../../utils/timeline/colors"
import { sizes, MAX_HEIGHT } from "../../utils/timeline/sizes"
@@ -56,6 +56,8 @@ export const TaskTimeline: Component = () => {
let dragging = false
let startX = 0
let startScroll = 0
let tipBar: HTMLElement | undefined
const [tip, setTip] = createSignal<{ text: string; x: number; y: number }>()
const messages = () => session.visibleMessages()
const allParts = () => {
@@ -85,8 +87,31 @@ export const TaskTimeline: Component = () => {
),
)
const hideTip = () => {
tipBar = undefined
setTip(undefined)
}
createEffect(on(bars, hideTip, { defer: true }))
const showTip = (e: PointerEvent) => {
if (dragging || !(e.target instanceof Element)) return
const bar = e.target.closest<HTMLElement>(".task-timeline-bar")
if (!bar || !ref?.contains(bar)) return hideTip()
if (bar === tipBar) return
const rect = bar.getBoundingClientRect()
tipBar = bar
const margin = Math.min(160, window.innerWidth / 2)
setTip({
text: bar.dataset.tip ?? "",
x: Math.max(margin, Math.min(window.innerWidth - margin, rect.left + rect.width / 2)),
y: rect.top,
})
}
// ── Drag scroll ──────────────────────────────────────────────────
const onPointerDown = (e: PointerEvent) => {
hideTip()
if (!ref) return
dragging = true
startX = e.clientX
@@ -97,7 +122,7 @@ export const TaskTimeline: Component = () => {
}
const onPointerMove = (e: PointerEvent) => {
if (!dragging || !ref) return
if (!dragging || !ref) return showTip(e)
ref.scrollLeft = startScroll - (e.clientX - startX)
}
@@ -111,6 +136,7 @@ export const TaskTimeline: Component = () => {
// ── Wheel → horizontal scroll ────────────────────────────────────
const onWheel = (e: WheelEvent) => {
hideTip()
if (!ref) return
e.preventDefault()
ref.scrollLeft += e.deltaY || e.deltaX
@@ -124,23 +150,27 @@ export const TaskTimeline: Component = () => {
})
return (
<div class="task-timeline-outer">
<div
ref={ref}
class="task-timeline"
style={{ height: `${MAX_HEIGHT}px` }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<Index each={bars()}>
{(bar) => {
const active = () => busy() && bar().idx === bars().length - 1
return (
<Tooltip value={bar().tip} placement="top">
<>
<div class="task-timeline-outer">
<div
ref={ref}
class="task-timeline"
style={{ height: `${MAX_HEIGHT}px` }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onPointerLeave={hideTip}
>
<Index each={bars()}>
{(bar) => {
const active = () => busy() && bar().idx === bars().length - 1
return (
<div
class="task-timeline-bar"
data-tip={bar().tip}
role="img"
aria-label={bar().tip}
style={{
width: `${bar().width}px`,
height: `${MAX_HEIGHT}px`,
@@ -157,11 +187,25 @@ export const TaskTimeline: Component = () => {
}}
/>
</div>
</Tooltip>
)
}}
</Index>
)
}}
</Index>
</div>
</div>
</div>
<Show when={tip()}>
{(current) => (
<Portal>
<div
data-component="tooltip"
class="task-timeline-tooltip"
role="tooltip"
style={{ left: `${current().x}px`, top: `${current().y}px` }}
>
{current().text}
</div>
</Portal>
)}
</Show>
</>
)
}
@@ -83,8 +83,9 @@ export const VscodeSessionTurn: Component<VscodeSessionTurnProps> = (props) => {
const seen = new Set<string>()
return (rawDiffs as SnapshotFileDiff[])
.reduceRight<SnapshotFileDiff[]>((result, diff) => {
if (seen.has(diff.file)) return result
seen.add(diff.file)
const file = diff.file ?? ""
if (seen.has(file)) return result
seen.add(file)
result.push(diff)
return result
}, [])
@@ -48,7 +48,6 @@ export function permissionExceptions(
return Object.entries(rule)
.filter(([key, action]) => key !== "*" && action !== null)
.map(([pattern, action]) => ({ pattern, action: action as PermissionLevel }))
.sort((a, b) => a.pattern.localeCompare(b.pattern))
}
export function setGroupedPatch(ids: string[], level: PermissionLevel): PermissionPatch {
@@ -5,7 +5,7 @@ import { Markdown } from "@kilocode/kilo-ui/markdown"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { useLanguage } from "../../context/language"
import { sanitizeName } from "./model-selector-utils"
import { freeDataLabel, isDataCollectedModel, sanitizeName } from "./model-selector-utils"
import { avgPrice, fmtCachedPrice, fmtPrice } from "./model-preview-utils"
interface Props {
@@ -46,6 +46,8 @@ export const ModelPreview: Component<Props> = (props) => {
return fmtCachedPrice(cost()!) ?? language.t("model.preview.value.notSupported")
}
const avg = () => (cost() ? avgPrice(cost()!) : undefined)
const freeLabel = () => language.t("model.tag.free")
const dataLabel = () => freeDataLabel(language.t("model.tag.free"), language.t("model.tag.dataCollected"))
const ctx = () => model().limit?.context ?? model().contextLength
const caps = () => model().capabilities
const inputs = () => caps()?.input
@@ -61,35 +63,44 @@ export const ModelPreview: Component<Props> = (props) => {
return (
<>
<Show when={model().isFree}>
<span class="model-preview-badge model-preview-badge--free model-preview-badge--top-right">
{language.t("model.tag.free")}
</span>
</Show>
{/* Header — name + provider + star */}
<div class="model-preview-header">
<div class="model-preview-name-row">
<span class="model-preview-name">{sanitizeName(model().name)}</span>
<Show when={session}>
{(() => {
const starred = () =>
session!
.favoriteModels()
.some((f) => f.providerID === model().providerID && f.modelID === model().id)
return (
<button
type="button"
class={`model-selector-star model-selector-star--preview${starred() ? " model-selector-star--active" : ""}`}
aria-label={
starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")
}
aria-pressed={starred()}
onClick={() => session!.toggleFavorite(model().providerID, model().id)}
>
<Icon name={starred() ? "star-filled" : "star"} size="small" />
</button>
)
})()}
<div class="model-preview-title-row">
<div class="model-preview-name-row">
<span class="model-preview-name">{sanitizeName(model().name)}</span>
<Show when={session}>
{(() => {
const starred = () =>
session!
.favoriteModels()
.some((f) => f.providerID === model().providerID && f.modelID === model().id)
return (
<button
type="button"
class={`model-selector-star model-selector-star--preview${starred() ? " model-selector-star--active" : ""}`}
aria-label={
starred() ? language.t("model.favorite.remove") : language.t("model.favorite.add")
}
aria-pressed={starred()}
onClick={() => session!.toggleFavorite(model().providerID, model().id)}
>
<Icon name={starred() ? "star-filled" : "star"} size="small" />
</button>
)
})()}
</Show>
</div>
<Show when={model().isFree}>
<span class="model-preview-free-data">
<span class="model-preview-badge model-preview-badge--free">{freeLabel()}</span>
<Show when={isDataCollectedModel(model())}>
<Tooltip value={dataLabel()} placement="top">
<span class="model-preview-free-data-icon" aria-label={dataLabel()}>
<Icon name="warning" size="small" />
</span>
</Tooltip>
</Show>
</span>
</Show>
</div>
<span class="model-preview-provider">{model().providerName}</span>
@@ -119,6 +130,13 @@ export const ModelPreview: Component<Props> = (props) => {
<span class="model-preview-value">{fmtPrice(avg()!)}</span>
</Show>
<Show when={isDataCollectedModel(model())}>
<span class="model-preview-data-line" aria-label={dataLabel()}>
<Icon name="warning" size="small" />
<span>- {dataLabel()}</span>
</span>
</Show>
{/* Context window */}
<Show when={ctx()}>
<span class="model-preview-label">{language.t("model.preview.label.context")}</span>
@@ -36,6 +36,8 @@ import {
isSmall,
providerSortKey,
isFree,
isDataCollectedModel,
freeDataLabel,
buildTriggerLabel,
sanitizeName,
} from "./model-selector-utils"
@@ -584,6 +586,13 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
const controlLabel = () => `${label()}: ${triggerLabel()}`
const searchLabel = () => `${controlLabel()}. ${language.t("dialog.model.search.placeholder")}`
const describedBy = () => (props.description ? descriptionID : undefined)
const freeLabel = () => language.t("model.tag.free")
const dataLabel = () => freeDataLabel(language.t("model.tag.free"), language.t("model.tag.dataCollected"))
const activeCollectsData = () => {
const model = activeModel()
if (!model) return false
return isDataCollectedModel(model)
}
return (
<>
@@ -624,6 +633,13 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
trigger={
<>
<span class="model-selector-trigger-label">{triggerLabel()}</span>
<Show when={activeCollectsData()}>
<Tooltip value={dataLabel()} placement="top">
<span class="model-selector-trigger-free-data" aria-label={dataLabel()}>
<Icon name="warning" size="small" />
</span>
</Tooltip>
</Show>
<svg class="model-selector-trigger-chevron" width="10" height="10" viewBox="0 0 16 16" fill="currentColor">
<path d="M8 4l4 5H4l4-5z" />
</svg>
@@ -813,7 +829,18 @@ export const ModelSelectorBase: Component<ModelSelectorBaseProps> = (props) => {
})()}
</span>
<Show when={isFree(model)}>
<Tag data-variant="member">{language.t("model.tag.free")}</Tag>
<span class="model-selector-free-data">
<span class="model-selector-data-badge">
<Tag data-variant="member">{freeLabel()}</Tag>
</span>
<Show when={isDataCollectedModel(model)}>
<Tooltip value={dataLabel()} placement="top">
<span class="model-selector-free-data-icon" aria-label={dataLabel()}>
<Icon name="warning" size="small" />
</span>
</Tooltip>
</Show>
</span>
</Show>
<Show when={showProvider()}>
<span class="model-selector-item-provider-tag">{model.providerName}</span>
@@ -12,6 +12,7 @@ export const TurnOutcome: Component = () => {
reason: session.closeReason(),
messages: session.visibleMessages(),
todos: session.todos(),
parts: (msg) => session.getParts(msg.id),
hidden: session.isErrorHidden,
}),
)
@@ -22,6 +22,14 @@ export function isFree(model: Pick<EnrichedModel, "isFree">): boolean {
return model.isFree === true
}
export function isDataCollectedModel(model: Pick<EnrichedModel, "providerID" | "isFree">): boolean {
return model.isFree === true && model.providerID === KILO_GATEWAY_ID
}
export function freeDataLabel(_free: string, data: string): string {
return data
}
// Strips trailing "(free)" parenthesized suffix from model display names, e.g.
// "Llama 3 (free)" → "Llama 3". A separate "Free" label/tag is rendered
// elsewhere, so preserve bare trailing "Free" words (e.g. "Kilo Auto Free").
@@ -1,4 +1,5 @@
import type { Message, SessionCloseReason, TodoItem } from "../types/messages"
import type { Message, Part, SessionCloseReason, TodoItem } from "../types/messages"
import { snapshotOnlyAssistant } from "./session-utils"
type TerminalKind = "incomplete" | "limit" | "unknown" | "filtered" | "unexpected" | "interrupted" | "error"
type TerminalTone = "warning" | "critical"
@@ -14,18 +15,31 @@ interface Input {
reason?: SessionCloseReason
messages: Message[]
todos: TodoItem[]
parts?: (msg: Message) => Part[] | undefined
hidden?: (id: string) => boolean
}
function last(input: Input): Message | undefined {
for (let i = input.messages.length - 1; i >= 0; i -= 1) {
const msg = input.messages[i]
if (!msg) continue
if (msg.role !== "assistant") return undefined
const parts = input.parts?.(msg) ?? msg.parts
if (snapshotOnlyAssistant(msg, parts)) continue
return msg
}
return undefined
}
export function terminal(input: Input): TerminalState | undefined {
if (!input.reason) return undefined
const last = input.messages[input.messages.length - 1]
const finish = last?.role === "assistant" ? last.finish : undefined
const msg = last(input)
const finish = msg?.finish
const remaining = input.todos.filter((item) => item.status !== "completed" && item.status !== "cancelled").length
if (input.reason === "interrupted") return { kind: "interrupted", tone: "warning", finish, remaining }
if (input.reason === "error") {
if (last?.role === "assistant" && last.error && !input.hidden?.(last.id)) return undefined
if (msg?.error && !input.hidden?.(msg.id)) return undefined
return { kind: "error", tone: "critical", finish, remaining }
}
if (finish === "length") return { kind: "limit", tone: "warning", finish, remaining }
@@ -106,13 +106,17 @@ export function stableMessageTurns(next: MessageTurn[], prev: MessageTurn[] = []
})
}
function active(messages: Message[]) {
function active(messages: Message[], status: SessionStatusInfo) {
let latest = true
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i]
if (!msg || msg.role !== "assistant") continue
if (typeof msg.time?.completed === "number") continue
const resumable = msg.finish === "tool-calls" || msg.finish === "unknown"
const running = latest && status.type !== "idle" && resumable
latest = false
if (typeof msg.time?.completed === "number" && !running) continue
if (msg.error) continue
if (msg.finish && !["tool-calls", "unknown"].includes(msg.finish)) continue
if (msg.finish && !resumable) continue
if (!msg.parentID) break
const parent = messages.find((item) => item.id === msg.parentID)
if (!parent) return msg.parentID
@@ -146,7 +150,7 @@ function pending(messages: Message[]) {
// Find the user message whose turn the server is actively processing.
// Any user message after this one is "queued" (waiting for its turn).
export function activeUserMessageID(messages: Message[], status: SessionStatusInfo) {
const id = active(messages)
const id = active(messages, status)
if (id) return id
if (status.type === "idle") return undefined
return pending(messages)
@@ -155,7 +159,7 @@ export function activeUserMessageID(messages: Message[], status: SessionStatusIn
export function queuedUserMessageIDs(messages: Message[], status: SessionStatusInfo) {
if (status.type === "idle") return []
const users = messages.filter((msg) => msg.role === "user")
const running = active(messages)
const running = active(messages, status)
if (running) {
const idx = users.findIndex((msg) => msg.id === running)
if (idx < 0) return users.map((msg) => msg.id)
@@ -14,6 +14,26 @@ export function snapshotProgress(part: SnapshotPart | undefined): boolean {
return (part.text ?? "").includes("Initializing snapshot")
}
function tokenCount(tokens: Message["tokens"] | undefined): number {
if (!tokens) return 0
return tokens.input + tokens.output + (tokens.reasoning ?? 0) + (tokens.cache?.read ?? 0) + (tokens.cache?.write ?? 0)
}
export function snapshotOnlyAssistant(msg: Message, parts: Part[] | undefined): boolean {
if (msg.role !== "assistant") return false
if (msg.finish !== "other") return false
if (msg.error) return false
if ((msg.cost ?? 0) !== 0) return false
if (tokenCount(msg.tokens) !== 0) return false
if (!parts?.length) return false
const snapshot = parts.some(snapshotProgress)
const allowed = parts.every(
(part) => snapshotProgress(part) || part.type === "step-start" || part.type === "step-finish",
)
return snapshot && allowed
}
type ParentSession = { parentID?: string | null }
type RecentSession = ParentSession & { updatedAt: string }
+1
View File
@@ -174,6 +174,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "تم فصل {{provider}}",
"provider.disconnect.toast.disconnected.description": "لم تعد نماذج {{provider}} متاحة.",
"model.tag.free": "مجاني",
"model.tag.dataCollected": "يتم جمع البيانات",
"model.tag.latest": "الأحدث",
"model.group.recommended": "موصى به",
"model.group.favorites": "المفضلة",
+1
View File
@@ -175,6 +175,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} desconectado",
"provider.disconnect.toast.disconnected.description": "Os modelos de {{provider}} não estão mais disponíveis.",
"model.tag.free": "Grátis",
"model.tag.dataCollected": "Dados coletados",
"model.tag.latest": "Mais recente",
"model.group.recommended": "Recomendado",
"model.group.favorites": "Favoritos",
+1
View File
@@ -176,6 +176,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "{{provider}} modeli više nisu dostupni.",
"model.tag.free": "Besplatno",
"model.tag.dataCollected": "Podaci se prikupljaju",
"model.tag.latest": "Najnovije",
"model.group.recommended": "Preporučeno",
"model.group.favorites": "Favoriti",
+1
View File
@@ -175,6 +175,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} frakoblet",
"provider.disconnect.toast.disconnected.description": "Modeller fra {{provider}} er ikke længere tilgængelige.",
"model.tag.free": "Gratis",
"model.tag.dataCollected": "Data indsamles",
"model.tag.latest": "Nyeste",
"model.group.recommended": "Anbefalet",
"model.group.favorites": "Favoritter",
+1
View File
@@ -179,6 +179,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} getrennt",
"provider.disconnect.toast.disconnected.description": "Die {{provider}}-Modelle sind nicht mehr verfügbar.",
"model.tag.free": "Kostenlos",
"model.tag.dataCollected": "Daten erfasst",
"model.tag.latest": "Neueste",
"model.group.recommended": "Empfohlen",
"model.group.favorites": "Favoriten",
@@ -176,6 +176,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.",
"model.tag.free": "Free",
"model.tag.dataCollected": "Data collected",
"model.tag.latest": "Latest",
"model.group.recommended": "Recommended",
"model.group.favorites": "Favorites",
+1
View File
@@ -176,6 +176,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} desconectado",
"provider.disconnect.toast.disconnected.description": "Los modelos de {{provider}} ya no están disponibles.",
"model.tag.free": "Gratis",
"model.tag.dataCollected": "Datos recopilados",
"model.tag.latest": "Último",
"model.group.recommended": "Recomendado",
"model.group.favorites": "Favoritos",
+1
View File
@@ -177,6 +177,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} déconnecté",
"provider.disconnect.toast.disconnected.description": "Les modèles {{provider}} ne sont plus disponibles.",
"model.tag.free": "Gratuit",
"model.tag.dataCollected": "Données collectées",
"model.tag.latest": "Dernier",
"model.group.recommended": "Recommandé",
"model.group.favorites": "Favoris",
+1
View File
@@ -151,6 +151,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} disconnesso",
"provider.disconnect.toast.disconnected.description": "I modelli {{provider}} non sono più disponibili.",
"model.tag.free": "Gratis",
"model.tag.dataCollected": "Dati raccolti",
"model.tag.latest": "Più recente",
"model.group.recommended": "Consigliati",
"model.group.favorites": "Preferiti",
+1
View File
@@ -174,6 +174,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}}が切断されました",
"provider.disconnect.toast.disconnected.description": "{{provider}}のモデルは利用できなくなりました。",
"model.tag.free": "無料",
"model.tag.dataCollected": "データ収集あり",
"model.tag.latest": "最新",
"model.group.recommended": "推奨",
"model.group.favorites": "お気に入り",
+1
View File
@@ -178,6 +178,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} 연결 해제됨",
"provider.disconnect.toast.disconnected.description": "{{provider}} 모델을 더 이상 사용할 수 없습니다.",
"model.tag.free": "무료",
"model.tag.dataCollected": "데이터 수집됨",
"model.tag.latest": "최신",
"model.group.recommended": "추천",
"model.group.favorites": "즐겨찾기",
+1
View File
@@ -176,6 +176,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "{{provider}} modellen zijn niet langer beschikbaar.",
"model.tag.free": "Gratis",
"model.tag.dataCollected": "Gegevens verzameld",
"model.tag.latest": "Nieuwste",
"model.group.recommended": "Aanbevolen",
"model.group.favorites": "Favorieten",
+1
View File
@@ -178,6 +178,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} frakoblet",
"provider.disconnect.toast.disconnected.description": "Modeller fra {{provider}} er ikke lenger tilgjengelige.",
"model.tag.free": "Gratis",
"model.tag.dataCollected": "Data samles inn",
"model.tag.latest": "Nyeste",
"model.group.recommended": "Anbefalt",
"model.group.favorites": "Favoritter",
+1
View File
@@ -175,6 +175,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "Rozłączono {{provider}}",
"provider.disconnect.toast.disconnected.description": "Modele {{provider}} nie są już dostępne.",
"model.tag.free": "Darmowy",
"model.tag.dataCollected": "Dane zbierane",
"model.tag.latest": "Najnowszy",
"model.group.recommended": "Zalecane",
"model.group.favorites": "Ulubione",
+1
View File
@@ -175,6 +175,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} отключён",
"provider.disconnect.toast.disconnected.description": "Модели {{provider}} больше недоступны.",
"model.tag.free": "Бесплатно",
"model.tag.dataCollected": "Данные собираются",
"model.tag.latest": "Последняя",
"model.group.recommended": "Рекомендуемые",
"model.group.favorites": "Избранное",
+1
View File
@@ -175,6 +175,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "โมเดล {{provider}} ไม่พร้อมใช้งานอีกต่อไป",
"model.tag.free": "ฟรี",
"model.tag.dataCollected": "มีการเก็บข้อมูล",
"model.tag.latest": "ล่าสุด",
"model.group.recommended": "แนะนำ",
"model.group.favorites": "รายการโปรด",
+1
View File
@@ -175,6 +175,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "{{provider}} modelleri artık kullanılabilir değil.",
"model.tag.free": "Ücretsiz",
"model.tag.dataCollected": "Veri toplanır",
"model.tag.latest": "En yeni",
"model.group.recommended": "Önerilen",
"model.group.favorites": "Favoriler",
+1
View File
@@ -176,6 +176,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "Моделі {{provider}} більше недоступні.",
"model.tag.free": "Безкоштовно",
"model.tag.dataCollected": "Дані збираються",
"model.tag.latest": "Остання",
"model.group.recommended": "Рекомендовані",
"model.group.favorites": "Обране",
+1
View File
@@ -175,6 +175,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} 已断开连接",
"provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。",
"model.tag.free": "免费",
"model.tag.dataCollected": "已收集数据",
"model.tag.latest": "最新",
"model.group.recommended": "推荐",
"model.group.favorites": "收藏夹",
+1
View File
@@ -175,6 +175,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} 已中斷連線",
"provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。",
"model.tag.free": "免費",
"model.tag.dataCollected": "已收集資料",
"model.tag.latest": "最新",
"model.group.recommended": "推薦",
"model.group.favorites": "我的最愛",
@@ -62,10 +62,18 @@
gap: 2px;
}
.model-preview-title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
}
.model-preview-name-row {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.model-preview-name {
@@ -73,6 +81,9 @@
font-weight: 600;
color: var(--vscode-editor-foreground);
line-height: 1.3;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.model-preview-provider {
@@ -131,6 +142,15 @@
font-variant-numeric: tabular-nums;
}
.model-preview-data-line {
grid-column: 1 / -1;
display: inline-flex;
align-items: center;
gap: 4px;
font-size: var(--kilo-font-size-11);
color: var(--vscode-inputValidation-warningForeground, var(--vscode-descriptionForeground));
}
/* Capability badge row */
.model-preview-caps {
display: flex;
@@ -158,13 +178,23 @@
.model-preview-badge--free {
background: var(--vscode-testing-iconPassed, #4caf50);
color: #fff;
text-transform: uppercase;
flex-shrink: 0;
white-space: nowrap;
}
.model-preview-badge--top-right {
position: absolute;
top: 12px;
right: 14px;
.model-preview-free-data {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.model-preview-free-data-icon,
.model-selector-free-data-icon {
display: inline-flex;
align-items: center;
color: var(--vscode-inputValidation-warningForeground, var(--vscode-descriptionForeground));
flex-shrink: 0;
}
.model-selector-preview--visible {
@@ -384,16 +414,29 @@
text-overflow: ellipsis;
}
.model-selector-item [data-component="tag"] {
.model-selector-data-badge [data-component="tag"] {
flex-shrink: 0;
background: var(--vscode-testing-iconPassed, #4caf50) !important;
color: #fff !important;
text-transform: uppercase;
font-size: var(--kilo-font-size-10) !important;
padding: 1px 3px !important;
opacity: 0.8;
}
.model-selector-trigger-free-data {
display: inline-flex;
align-items: center;
color: var(--vscode-inputValidation-warningForeground, var(--vscode-descriptionForeground));
flex-shrink: 0;
}
.model-selector-free-data {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.model-selector-item-provider-tag {
flex-shrink: 0;
font-size: var(--kilo-font-size-10);
@@ -164,6 +164,14 @@
animation: timeline-pulse 2s ease-in-out infinite 0.5s;
}
.task-timeline-tooltip {
position: fixed;
max-width: min(320px, calc(100vw - 16px));
transform: translate(-50%, calc(-100% - 4px));
white-space: normal;
pointer-events: none;
}
@keyframes timeline-fade-in {
from {
opacity: 0;
+3
View File
@@ -60,6 +60,7 @@
"@types/bun": "catalog:",
"@types/cross-spawn": "catalog:",
"@types/mime-types": "3.0.1",
"@types/node": "catalog:",
"@types/npm-package-arg": "6.1.4",
"@types/semver": "^7.5.8",
"@types/turndown": "5.0.5",
@@ -134,6 +135,8 @@
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@secretlint/core": "10.2.2",
"@secretlint/secretlint-rule-preset-recommend": "10.2.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
+6 -4
View File
@@ -214,7 +214,7 @@ const targets = singleFlag
})
: allTargets
await $`rm -rf dist`
await $`rm -rf dist` // kilocode_change
const kiloConsoleDist = await buildKiloConsole() // kilocode_change
@@ -241,16 +241,17 @@ 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 sessionExportWorkerPath = "./src/kilocode/session-export/worker.ts" // kilocode_change
const indexingWorkerPath = "./src/kilocode/indexing-worker.ts" // kilocode_change
// Use platform-specific bunfs root path based on target OS
// Use platform-specific bunfs root path based on target OS // kilocode_change
const bunfsRoot = item.os === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/"
const workerRelativePath = path.relative(dir, parserWorker).replaceAll("\\", "/")
await Bun.build({
conditions: ["browser"],
tsconfig: "./tsconfig.json",
plugins: [plugin],
plugins: [plugin], // kilocode_change
// kilocode_change start - skip sourcemaps for release builds (each .js.map adds ~50 MB per target → ~600 MB total)
sourcemap: Script.release ? "none" : "external",
// kilocode_change end
@@ -270,13 +271,14 @@ for (const item of targets) {
},
// kilocode_change start - packages/app was removed; no embedded web UI
files: {},
entrypoints: ["./src/index.ts", parserWorker, workerPath, indexingWorkerPath],
entrypoints: ["./src/index.ts", parserWorker, workerPath, sessionExportWorkerPath, indexingWorkerPath],
// kilocode_change end
define: {
KILO_VERSION: `'${Script.version}'`,
KILO_MIGRATIONS: JSON.stringify(migrations),
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
KILO_WORKER_PATH: workerPath,
KILO_SESSION_EXPORT_WORKER_PATH: sessionExportWorkerPath, // kilocode_change
KILO_INDEXING_WORKER_PATH: indexingWorkerPath, // kilocode_change
KILO_CHANNEL: `'${Script.channel}'`,
KILO_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "",
@@ -11,6 +11,7 @@ import type { Model } from "@kilocode/sdk/v2" // kilocode_change
import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { ModelInfoPanel } from "@/kilocode/components/model-info-panel" // kilocode_change
import { FreeModelDisclosure } from "@/kilocode/components/free-model-disclosure" // kilocode_change
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
@@ -61,6 +62,12 @@ export function DialogModel(props: { providerID?: string }) {
if (!next) return
setPreview(next)
})
const footer = (providerID: string, model: Model) => {
if (providerID === "kilo" && FreeModelDisclosure.collectsData(model)) return FreeModelDisclosure.label
if (model.cost?.input === 0 && providerID === "opencode") return "Free"
return undefined
}
// kilocode_change end
const options = createMemo(() => {
@@ -84,9 +91,9 @@ export function DialogModel(props: { providerID?: string }) {
description: provider.name,
category,
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
footer: footer(provider.id, model), // kilocode_change
onSelect: () => {
onSelect(provider.id, model.id)
onSelect(provider.id, model.id) // kilocode_change
},
},
]
@@ -127,9 +134,9 @@ export function DialogModel(props: { providerID?: string }) {
: undefined,
// kilocode_change end
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
footer: footer(provider.id, info), // kilocode_change
onSelect() {
onSelect(provider.id, model)
onSelect(provider.id, model) // kilocode_change
},
})),
filter((x) => {
@@ -147,9 +154,11 @@ export function DialogModel(props: { providerID?: string }) {
// kilocode_change start - Sort within Recommended / Kilo Gateway
(x) => (x.value.providerID === "kilo" ? (kiloRank().get(x.value.modelID) ?? Infinity) : 0),
// kilocode_change end
(x) => x.footer !== "Free",
(x) => x.title,
),
// kilocode_change start - free model footers include Kilo disclosure labels
(x) => x.footer === undefined,
// kilocode_change end
(x) => x.title, // kilocode_change
), // kilocode_change
),
),
)
+3 -1
View File
@@ -1,5 +1,5 @@
import { Schema } from "effect"
import { PROMPTS } from "@kilocode/kilo-gateway" // kilocode_change
import { PROMPTS, AI_SDK_PROVIDERS } from "@kilocode/kilo-gateway" // kilocode_change
import { zod } from "@opencode-ai/core/effect-zod"
import { PositiveInt, withStatics } from "@opencode-ai/core/schema"
import { ModelStatus } from "@/provider/model-status"
@@ -9,6 +9,8 @@ export const Model = Schema.Struct({
name: Schema.optional(Schema.String),
family: Schema.optional(Schema.String),
prompt: Schema.optional(Schema.Literals(PROMPTS)), // kilocode_change
isFree: Schema.optional(Schema.Boolean), // kilocode_change
ai_sdk_provider: Schema.optional(Schema.Literals(AI_SDK_PROVIDERS)), // kilocode_change
release_date: Schema.optional(Schema.String),
attachment: Schema.optional(Schema.Boolean),
reasoning: Schema.optional(Schema.Boolean),
+9 -3
View File
@@ -44,6 +44,7 @@ import { KiloConsoleCommand } from "./kilocode/cli/cmd/console" // kilocode_chan
import { Telemetry } from "@kilocode/kilo-telemetry"
import { InstanceRuntime } from "./project/instance-runtime" // kilocode_change
import { migrateLegacyKiloAuth, ENV_FEATURE, ENV_VERSION } from "@kilocode/kilo-gateway"
import { SessionExport } from "./kilocode/session-export" // kilocode_change
// kilocode_change - set feature for tracking. 'serve' is spawned by other services
// (extension, cloud) which set their own KILOCODE_FEATURE env var. Direct CLI use
@@ -174,6 +175,7 @@ let cli = yargs(args) // kilocode_change
Telemetry.trackCliStart()
// kilocode_change end
// kilocode_change start - one-time database migration progress
const marker = path.join(Global.Path.data, "kilo.db")
if (!(await Filesystem.exists(marker))) {
const tty = process.stderr.isTTY
@@ -210,6 +212,7 @@ let cli = yargs(args) // kilocode_change
}
process.stderr.write("Database migration complete." + EOL)
}
// kilocode_change end
})
.usage("")
.completion("completion", "generate shell completion script")
@@ -302,6 +305,7 @@ try {
})
}
// kilocode_change start - log extra Bun resolve metadata for startup failures
if (e instanceof ResolveMessage) {
Object.assign(data, {
name: e.name,
@@ -313,11 +317,12 @@ try {
importKind: e.importKind,
})
}
Log.Default.error("fatal", data)
// kilocode_change end
Log.Default.error("fatal", data) // kilocode_change
const formatted = FormatError(e)
if (formatted) UI.error(formatted)
if (formatted === undefined) {
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL)
UI.error("Unexpected error, check log file at " + Log.file() + " for more details" + EOL) // kilocode_change
process.stderr.write(errorMessage(e) + EOL)
}
process.exitCode = 1
@@ -325,6 +330,7 @@ try {
// kilocode_change start - Track CLI exit and shutdown telemetry
const exitCode = typeof process.exitCode === "number" ? process.exitCode : undefined
Telemetry.trackCliExit(exitCode)
await SessionExport.shutdown()
await Telemetry.shutdown()
// kilocode_change end
@@ -334,5 +340,5 @@ try {
// Most notably, some docker-container-based MCP servers don't handle such signals unless
// run using `docker run --init`.
// Explicitly exit to avoid any hanging subprocesses.
process.exit()
process.exit() // kilocode_change
}
@@ -2,6 +2,14 @@ import { Cause, Context, Effect, Layer } from "effect"
import { EffectBridge } from "@/effect/bridge"
import { KiloSessions } from "@/kilo-sessions/kilo-sessions"
import * as Log from "@opencode-ai/core/util/log"
import { Global } from "@opencode-ai/core/global"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import path from "node:path"
import { Bus } from "@/bus"
import { SessionExport } from "@/kilocode/session-export"
import { createWorkspaceProvider } from "@/kilocode/session-export/workspace-provider"
import { Instance } from "@/project/instance"
import { Identity } from "@kilocode/kilo-telemetry"
const log = Log.create({ service: "kilocode-bootstrap" })
@@ -19,6 +27,31 @@ export namespace KilocodeBootstrap {
const init = Effect.fn("KilocodeBootstrap.init")(function* () {
yield* sessions.init()
// kilocode_change start - session export bootstrap
yield* Effect.gen(function* () {
const anon = yield* EffectBridge.fromPromise(() =>
Identity.getMachineId().catch((err) => {
log.warn("session export identity failed", { err })
return undefined
}),
)
SessionExport.init({
agentVersion: InstallationVersion,
anonId: anon,
dbPath: path.join(Global.Path.data, "session-export.db"),
workspaceKey: Instance.directory,
subscribeAll: (cb) => Bus.subscribeAll(cb),
snapshotProvider: createWorkspaceProvider({
root: Instance.directory,
statePath: path.join(Global.Path.data, "session-export-workspace.json"),
}),
})
}).pipe(
Effect.catchCause((cause) =>
Effect.sync(() => log.warn("session export bootstrap failed", { err: Cause.squash(cause) })),
),
)
// kilocode_change end
yield* EffectBridge.fromPromise(() =>
import("@/kilocode/indexing").then((mod) => mod.KiloIndexing.init()),
).pipe(
@@ -0,0 +1,7 @@
export const FreeModelDisclosure = {
label: "Data collected",
panel: "Free - data collected",
collectsData(model: { isFree?: boolean; api?: { npm?: string } }): boolean {
return model.isFree === true && model.api?.npm === "@kilocode/kilo-gateway"
},
} as const
@@ -3,6 +3,7 @@ import { useTerminalDimensions } from "@opentui/solid"
import { createMemo, Show } from "solid-js"
import { useTheme } from "@tui/context/theme"
import type { Model } from "@kilocode/sdk/v2"
import { FreeModelDisclosure } from "./free-model-disclosure"
import { avgPrice, fmtCachedPrice, fmtContext, fmtDate, fmtPrice } from "./model-info-panel-utils"
interface Props {
@@ -66,9 +67,9 @@ export function ModelInfoPanel(props: Props) {
</text>
<text fg={theme.textMuted}>{props.provider ?? m().providerID ?? ""}</text>
</box>
<Show when={m().isFree}>
<Show when={FreeModelDisclosure.collectsData(m())}>
<box>
<text fg={theme.text}>Free</text>
<text fg={theme.text}>{FreeModelDisclosure.panel}</text>
</box>
</Show>
<Show when={m().family}>
@@ -43,11 +43,12 @@ export const KILO_MODEL_SCHEMA_EXTENSIONS = {
// ---------------------------------------------------------------------------
export function patchModelsDevModel(providerID: string, source: any) {
const free = providerID === "kilo" && source.cost?.input === 0 && source.cost?.output === 0
return {
variants: providerID === "kilo" ? (source.variants ?? {}) : {},
recommendedIndex: source.recommendedIndex,
prompt: source.prompt,
isFree: source.isFree,
isFree: source.isFree ?? (free ? true : undefined),
ai_sdk_provider: source.ai_sdk_provider,
options: source.options ?? {},
}
@@ -2,7 +2,11 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization"
import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
WorkspaceRoutingQueryFields,
} from "@/server/routes/instance/httpapi/middleware/workspace-routing"
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
const root = "/kilo"
@@ -240,6 +244,7 @@ export const KiloGatewayApi = HttpApi.make("kilo")
HttpApiGroup.make("kilo")
.add(
HttpApiEndpoint.get("profile", KiloGatewayPaths.profile, {
query: WorkspaceRoutingQuery,
success: described(ProfileWithBalance, "Profile data"),
error: [HttpApiError.BadRequest, HttpApiError.Unauthorized],
}).annotateMerge(
@@ -250,6 +255,7 @@ export const KiloGatewayApi = HttpApi.make("kilo")
}),
),
HttpApiEndpoint.get("modes", KiloGatewayPaths.modes, {
query: WorkspaceRoutingQuery,
success: described(OrganizationModes, "Organization modes list"),
}).annotateMerge(
OpenApi.annotations({
@@ -259,6 +265,7 @@ export const KiloGatewayApi = HttpApi.make("kilo")
}),
),
HttpApiEndpoint.post("fim", KiloGatewayPaths.fim, {
query: WorkspaceRoutingQuery,
payload: FimBody,
success: Schema.String.pipe(HttpApiSchema.asText({ contentType: "text/event-stream" })),
error: [HttpApiError.BadRequest, HttpApiError.Unauthorized],
@@ -270,6 +277,7 @@ export const KiloGatewayApi = HttpApi.make("kilo")
}),
),
HttpApiEndpoint.post("edit", KiloGatewayPaths.edit, {
query: WorkspaceRoutingQuery,
payload: EditBody,
success: described(EditResponse, "Next Edit completion"),
error: [HttpApiError.BadRequest, HttpApiError.Unauthorized],
@@ -283,6 +291,7 @@ export const KiloGatewayApi = HttpApi.make("kilo")
}),
),
HttpApiEndpoint.post("audioTranscriptions", KiloGatewayPaths.audioTranscriptions, {
query: WorkspaceRoutingQuery,
payload: AudioTranscriptionsBody,
success: described(TranscriptionResponse, "Transcription response"),
error: [HttpApiError.BadRequest, HttpApiError.Unauthorized],
@@ -294,6 +303,7 @@ export const KiloGatewayApi = HttpApi.make("kilo")
}),
),
HttpApiEndpoint.get("notifications", KiloGatewayPaths.notifications, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Notification), "Notifications list"),
error: [HttpApiError.BadRequest, HttpApiError.Unauthorized],
}).annotateMerge(
@@ -304,6 +314,7 @@ export const KiloGatewayApi = HttpApi.make("kilo")
}),
),
HttpApiEndpoint.post("organization", KiloGatewayPaths.organization, {
query: WorkspaceRoutingQuery,
payload: OrganizationBody,
success: described(Schema.Boolean, "Organization updated successfully"),
error: [HttpApiError.BadRequest, HttpApiError.Unauthorized],
@@ -315,6 +326,7 @@ export const KiloGatewayApi = HttpApi.make("kilo")
}),
),
HttpApiEndpoint.get("clawStatus", KiloGatewayPaths.clawStatus, {
query: WorkspaceRoutingQuery,
success: described(ClawStatus, "Instance status"),
error: [HttpApiError.Unauthorized, HttpApiError.ServiceUnavailable],
}).annotateMerge(
@@ -325,6 +337,7 @@ export const KiloGatewayApi = HttpApi.make("kilo")
}),
),
HttpApiEndpoint.get("clawChatCredentials", KiloGatewayPaths.clawChatCredentials, {
query: WorkspaceRoutingQuery,
success: described(ClawChatCredentials, "Kilo Chat credentials or null"),
error: HttpApiError.Unauthorized,
}).annotateMerge(
@@ -339,6 +352,7 @@ export const KiloGatewayApi = HttpApi.make("kilo")
),
HttpApiEndpoint.get("cloudSessions", KiloGatewayPaths.cloudSessions, {
query: {
...WorkspaceRoutingQueryFields,
cursor: Schema.optional(Schema.String),
limit: Schema.optional(Schema.String),
gitUrl: Schema.optional(Schema.String),
@@ -354,6 +368,7 @@ export const KiloGatewayApi = HttpApi.make("kilo")
),
HttpApiEndpoint.get("cloudSession", KiloGatewayPaths.cloudSession, {
params: { id: Schema.String },
query: WorkspaceRoutingQuery,
success: described(CloudSessionData, "Cloud session data"),
error: [HttpApiError.Unauthorized, HttpApiError.NotFound],
}).annotateMerge(
@@ -364,6 +379,7 @@ export const KiloGatewayApi = HttpApi.make("kilo")
}),
),
HttpApiEndpoint.post("cloudSessionImport", KiloGatewayPaths.cloudSessionImport, {
query: WorkspaceRoutingQuery,
payload: CloudSessionImportBody,
success: described(CloudSessionData.fields.info, "Imported session info"),
error: [HttpApiError.BadRequest, HttpApiError.Unauthorized, HttpApiError.NotFound],
@@ -2,7 +2,10 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization"
import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
} from "@/server/routes/instance/httpapi/middleware/workspace-routing"
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
const root = "/kilocode"
@@ -26,6 +29,7 @@ export const KilocodeApi = HttpApi.make("kilocode")
HttpApiGroup.make("kilocode")
.add(
HttpApiEndpoint.post("heapSnapshot", KilocodePaths.heapSnapshot, {
query: WorkspaceRoutingQuery,
success: described(Schema.String, "Heap snapshot file path"),
error: HttpApiError.BadRequest,
}).annotateMerge(
@@ -36,6 +40,7 @@ export const KilocodeApi = HttpApi.make("kilocode")
}),
),
HttpApiEndpoint.post("removeSkill", KilocodePaths.removeSkill, {
query: WorkspaceRoutingQuery,
payload: RemoveSkillPayload,
success: described(Schema.Boolean, "Skill removed"),
error: HttpApiError.BadRequest,
@@ -47,6 +52,7 @@ export const KilocodeApi = HttpApi.make("kilocode")
}),
),
HttpApiEndpoint.post("removeAgent", KilocodePaths.removeAgent, {
query: WorkspaceRoutingQuery,
payload: RemoveAgentPayload,
success: described(Schema.Boolean, "Agent removed"),
error: HttpApiError.BadRequest,
@@ -2,7 +2,10 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization"
import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
} from "@/server/routes/instance/httpapi/middleware/workspace-routing"
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
const root = "/remote"
@@ -23,6 +26,7 @@ export const RemoteApi = HttpApi.make("remote")
HttpApiGroup.make("remote")
.add(
HttpApiEndpoint.post("enable", RemotePaths.enable, {
query: WorkspaceRoutingQuery,
success: described(RemoteStatus, "Remote connection enabled"),
error: HttpApiError.Unauthorized,
}).annotateMerge(
@@ -33,6 +37,7 @@ export const RemoteApi = HttpApi.make("remote")
}),
),
HttpApiEndpoint.post("disable", RemotePaths.disable, {
query: WorkspaceRoutingQuery,
success: described(RemoteStatus, "Remote connection disabled"),
}).annotateMerge(
OpenApi.annotations({
@@ -42,6 +47,7 @@ export const RemoteApi = HttpApi.make("remote")
}),
),
HttpApiEndpoint.get("status", RemotePaths.status, {
query: WorkspaceRoutingQuery,
success: described(RemoteStatus, "Remote connection status"),
}).annotateMerge(
OpenApi.annotations({
@@ -2,7 +2,10 @@ import { Schema } from "effect"
import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization"
import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
} from "@/server/routes/instance/httpapi/middleware/workspace-routing"
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
const root = "/kilocode/session-import"
@@ -227,6 +230,7 @@ export const SessionImportApi = HttpApi.make("session-import")
HttpApiGroup.make("session-import")
.add(
HttpApiEndpoint.post("project", SessionImportPaths.project, {
query: WorkspaceRoutingQuery,
payload: ProjectSchema,
success: described(ResultSchema, "Project import result"),
error: HttpApiError.BadRequest,
@@ -238,6 +242,7 @@ export const SessionImportApi = HttpApi.make("session-import")
}),
),
HttpApiEndpoint.post("session", SessionImportPaths.session, {
query: WorkspaceRoutingQuery,
payload: SessionSchema,
success: described(ResultSchema, "Session import result"),
error: HttpApiError.BadRequest,
@@ -249,6 +254,7 @@ export const SessionImportApi = HttpApi.make("session-import")
}),
),
HttpApiEndpoint.post("message", SessionImportPaths.message, {
query: WorkspaceRoutingQuery,
payload: MessageSchema,
success: described(ResultSchema, "Message import result"),
error: HttpApiError.BadRequest,
@@ -260,6 +266,7 @@ export const SessionImportApi = HttpApi.make("session-import")
}),
),
HttpApiEndpoint.post("part", SessionImportPaths.part, {
query: WorkspaceRoutingQuery,
payload: PartSchema,
success: described(ResultSchema, "Part import result"),
error: HttpApiError.BadRequest,
@@ -3,7 +3,10 @@ import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "e
import { Suggestion } from "@/kilocode/suggestion"
import { Authorization } from "@/server/routes/instance/httpapi/middleware/authorization"
import { InstanceContextMiddleware } from "@/server/routes/instance/httpapi/middleware/instance-context"
import { WorkspaceRoutingMiddleware } from "@/server/routes/instance/httpapi/middleware/workspace-routing"
import {
WorkspaceRoutingMiddleware,
WorkspaceRoutingQuery,
} from "@/server/routes/instance/httpapi/middleware/workspace-routing"
import { described } from "@/server/routes/instance/httpapi/groups/metadata"
const root = "/suggestion"
@@ -27,6 +30,7 @@ export const SuggestionApi = HttpApi.make("suggestion")
HttpApiGroup.make("suggestion")
.add(
HttpApiEndpoint.get("list", SuggestionPaths.list, {
query: WorkspaceRoutingQuery,
success: described(Schema.Array(Suggestion.RequestSchema), "List of pending suggestions"),
}).annotateMerge(
OpenApi.annotations({
@@ -37,6 +41,7 @@ export const SuggestionApi = HttpApi.make("suggestion")
),
HttpApiEndpoint.post("accept", SuggestionPaths.accept, {
params: { requestID: SuggestionRequestID },
query: WorkspaceRoutingQuery,
payload: SuggestionAcceptPayload,
success: described(Schema.Boolean, "Suggestion accepted successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
@@ -49,6 +54,7 @@ export const SuggestionApi = HttpApi.make("suggestion")
),
HttpApiEndpoint.post("dismiss", SuggestionPaths.dismiss, {
params: { requestID: SuggestionRequestID },
query: WorkspaceRoutingQuery,
success: described(Schema.Boolean, "Suggestion dismissed successfully"),
error: [HttpApiError.BadRequest, HttpApiError.NotFound],
}).annotateMerge(
@@ -0,0 +1,362 @@
import type { ToWorker } from "./worker/ipc"
import { Config } from "./config"
import { isEligible, type EligibilityInput } from "./eligibility"
import type { Permission } from "@/permission"
import type { ModelMessage, Tool } from "ai"
import { ulid } from "ulid"
import type {
CompactionCaptured,
DeltaEntry,
ExportEvent,
FileEntry,
LlmRequestCompleted,
LlmRequestStarted,
SessionDegraded,
CaptureMetadata,
} from "./events"
import { startBaselineFiber, startDeltaFiber } from "./workspace-fiber"
export type CaptureDeps = {
worker: { postMessage: (msg: ToWorker) => void; terminate: () => void }
agentVersion: string
nowMs: () => number
syncSeq: (sessionId: string) => number
onPostError?: (err: unknown) => void
snapshotProvider?: {
current?: (sessionId: string) => string | undefined
remember?: (sessionId: string, snapshotHash: string) => void
baseline: () => Promise<{ snapshotId: string; files: FileEntry[]; capture?: CaptureMetadata }>
diff: (prevSnapshotHash: string) => Promise<{ snapshotHash: string; diff: DeltaEntry[] }>
}
baselineTimeoutMs?: number
}
export type RequestMeta = {
sessionId: string
rootSessionId: string
parentSessionId?: string
requestId: string
userMessageId: string
assistantMessageId?: string
agent: string
modeId: string
workspaceKey?: string
agentInfo?: unknown
gitContext?: { branch: string; sha: string; dirtyFileCount: number }
}
export class Capture {
private firstEligible = new Set<string>()
private authorized = new Set<string>()
private degradedAnnounced = new Set<string>()
private degraded = new Set<string>()
private roots = new Map<string, string>()
private snapshots = new Map<string, string>()
private turns = new Map<string, string>()
private deltas = new Map<string, Promise<void>>()
constructor(private readonly deps: CaptureDeps) {}
markDegraded(sessionId: string): void {
this.degraded.add(sessionId)
}
hasEligibleSession(sessionId: string): boolean {
return this.authorized.has(sessionId) && !this.degraded.has(sessionId)
}
turnId(sessionId: string): string | undefined {
return this.turns.get(sessionId)
}
rootSessionId(sessionId: string): string | undefined {
return this.roots.get(sessionId)
}
beforeRequest(args: {
input: EligibilityInput & {
model: EligibilityInput["model"] & {
providerId?: string
providerID?: string
modelId?: string
modelID?: string
id?: string
variant?: string
}
}
requestMeta: RequestMeta
assembled: {
system: string[]
messages: ModelMessage[]
tools: Record<string, Tool>
permissions: Permission.Ruleset
toolChoice?: "auto" | "required" | "none"
params: Record<string, unknown>
}
}): void {
if (args.requestMeta.agent === "title") return
const meta = args.requestMeta
if (!isEligible(args.input)) {
this.authorized.delete(meta.sessionId)
this.turns.delete(meta.sessionId)
return
}
if (this.degraded.has(meta.sessionId)) {
this.announceDegraded(meta)
return
}
this.authorized.add(meta.sessionId)
this.turns.set(meta.sessionId, meta.userMessageId)
if (!this.firstEligible.has(meta.sessionId)) {
this.firstEligible.add(meta.sessionId)
this.roots.set(meta.sessionId, meta.rootSessionId)
const previous = this.deps.snapshotProvider?.current?.(meta.sessionId)
if (previous) {
this.snapshots.set(meta.sessionId, previous)
this.startNextDelta(meta)
} else {
this.startBaseline(meta)
}
} else {
this.startNextDelta(meta)
}
const seq = this.deps.syncSeq(meta.sessionId)
const env: LlmRequestStarted = {
id: ulid(),
schemaVersion: 1,
type: "llm_request_started",
sessionId: meta.sessionId,
rootSessionId: meta.rootSessionId,
parentSessionId: meta.parentSessionId,
turnId: meta.userMessageId,
seq,
eventSeq: seq,
ts: this.deps.nowMs(),
agentVersion: this.deps.agentVersion,
requestId: meta.requestId,
userMessageId: meta.userMessageId,
assistantMessageId: meta.assistantMessageId,
agent: meta.agent,
modeId: meta.modeId,
model: {
providerId: args.input.model.providerId ?? args.input.model.providerID ?? "",
modelId: args.input.model.modelId ?? args.input.model.modelID ?? args.input.model.id ?? "",
variant: args.input.model.variant,
isFree: true,
},
input: args.assembled,
gitContext: meta.gitContext,
agentInfo: meta.agentInfo,
time: { created: this.deps.nowMs() },
}
this.dispatch(env)
}
afterRequest(args: {
sessionId: string
rootSessionId: string
parentSessionId?: string
requestId: string
output: LlmRequestCompleted["output"]
durationMs: number
retryCount: number
workspaceKey?: string
}): void {
if (!this.authorized.has(args.sessionId)) return
if (this.degraded.has(args.sessionId)) return
const seq = this.deps.syncSeq(args.sessionId)
const env: LlmRequestCompleted = {
id: ulid(),
schemaVersion: 1,
type: "llm_request_completed",
sessionId: args.sessionId,
rootSessionId: args.rootSessionId,
parentSessionId: args.parentSessionId,
turnId: this.turns.get(args.sessionId),
seq,
eventSeq: seq,
ts: this.deps.nowMs(),
agentVersion: this.deps.agentVersion,
requestId: args.requestId,
output: args.output,
durationMs: args.durationMs,
retryCount: args.retryCount,
time: { completed: this.deps.nowMs() },
}
this.dispatch(env)
void this.startDelta(args.sessionId, args.rootSessionId, "turn_end")
}
dispatchRaw(envelope: ExportEvent): void {
this.dispatch(envelope)
}
compaction(args: {
sessionId: string
rootSessionId: string
parentSessionId?: string
requestId: string
input: CompactionCaptured["input"]
output: CompactionCaptured["output"]
modelId: string
durationMs: number
usage?: { inputTokens: number; outputTokens: number }
workspaceKey?: string
}): void {
if (!this.authorized.has(args.sessionId)) return
if (this.degraded.has(args.sessionId)) return
const seq = this.deps.syncSeq(args.sessionId)
const env: CompactionCaptured = {
id: ulid(),
schemaVersion: 1,
type: "compaction_captured",
sessionId: args.sessionId,
rootSessionId: args.rootSessionId,
parentSessionId: args.parentSessionId,
turnId: this.turns.get(args.sessionId),
requestId: args.requestId,
seq,
eventSeq: seq,
ts: this.deps.nowMs(),
agentVersion: this.deps.agentVersion,
input: args.input,
output: args.output,
modelId: args.modelId,
durationMs: args.durationMs,
usage: args.usage,
}
this.dispatch(env)
}
async onSessionClose(sessionId: string): Promise<void> {
if (!this.authorized.has(sessionId)) return
if (this.degraded.has(sessionId)) return
const root = this.roots.get(sessionId) ?? sessionId
await this.startDelta(sessionId, root, "session_close").catch((err) => this.deps.onPostError?.(err))
}
private async startDelta(
sessionId: string,
rootSessionId: string,
trigger: "next_request" | "turn_end" | "session_close",
): Promise<void> {
const prev = this.deltas.get(sessionId) ?? Promise.resolve()
const next = prev.then(
() => this.captureDelta(sessionId, rootSessionId, trigger),
() => this.captureDelta(sessionId, rootSessionId, trigger),
)
this.deltas.set(sessionId, next)
void next.then(
() => {
if (this.deltas.get(sessionId) === next) this.deltas.delete(sessionId)
},
(err) => {
if (this.deltas.get(sessionId) === next) this.deltas.delete(sessionId)
this.deps.onPostError?.(err)
},
)
return next
}
private async captureDelta(
sessionId: string,
rootSessionId: string,
trigger: "next_request" | "turn_end" | "session_close",
): Promise<void> {
const provider = this.deps.snapshotProvider
if (!provider) return
const previous = this.snapshots.get(sessionId)
if (!previous) return
const next = await startDeltaFiber({
sessionId,
rootSessionId,
turnId: this.turns.get(sessionId),
trigger,
prevSnapshotHash: previous,
now: () => this.deps.nowMs(),
syncSeq: () => this.deps.syncSeq(sessionId),
agentVersion: this.deps.agentVersion,
requestDiff: provider.diff,
dispatch: (event) => this.dispatchRaw(event),
})
if (next) this.snapshots.set(sessionId, next)
if (next) provider.remember?.(sessionId, next)
}
private startNextDelta(meta: RequestMeta): void {
void this.startDelta(meta.sessionId, meta.rootSessionId, "next_request")
}
private announceDegraded(meta: RequestMeta): void {
if (this.degradedAnnounced.has(meta.sessionId)) return
this.degradedAnnounced.add(meta.sessionId)
const seq = this.deps.syncSeq(meta.sessionId)
const env: SessionDegraded = {
id: ulid(),
schemaVersion: 1,
type: "session_degraded",
sessionId: meta.sessionId,
rootSessionId: meta.rootSessionId,
parentSessionId: meta.parentSessionId,
turnId: this.turns.get(meta.sessionId),
seq,
eventSeq: seq,
ts: this.deps.nowMs(),
agentVersion: this.deps.agentVersion,
reason: "ring_buffer_overflow",
}
this.dispatch(env)
}
private startBaseline(meta: RequestMeta): void {
const provider = this.deps.snapshotProvider
if (!provider) return
void startBaselineFiber({
sessionId: meta.sessionId,
rootSessionId: meta.rootSessionId,
turnId: this.turns.get(meta.sessionId),
timeoutMs: this.deps.baselineTimeoutMs ?? Config.baselineWaitMs,
now: () => this.deps.nowMs(),
syncSeq: () => this.deps.syncSeq(meta.sessionId),
agentVersion: this.deps.agentVersion,
requestSnapshot: provider.baseline,
dispatch: (event) => this.dispatchRaw(event),
}).then((hash) => {
if (hash) this.snapshots.set(meta.sessionId, hash)
if (hash) provider.remember?.(meta.sessionId, hash)
})
}
private dispatch(envelope: ExportEvent): void {
try {
const safe = cloneable(envelope) as ExportEvent
this.deps.worker.postMessage({ kind: "event", envelope: safe, approxBytes: JSON.stringify(safe).length })
} catch (err) {
this.deps.onPostError?.(err)
}
}
}
function cloneable(node: unknown): unknown {
if (node === null) return null
if (node === undefined) return undefined
if (typeof node === "string" || typeof node === "number" || typeof node === "boolean") return node
if (typeof node === "bigint") return String(node)
if (typeof node === "function" || typeof node === "symbol") return undefined
if (node instanceof Error) return { name: node.name, message: node.message, stack: node.stack }
if (Array.isArray(node)) return node.map(cloneable)
if (node instanceof Uint8Array) return node
if (node instanceof ArrayBuffer) return node
if (node && typeof node === "object") {
const out: Record<string, unknown> = {}
for (const [key, val] of Object.entries(node)) {
const next = cloneable(val)
if (next !== undefined) out[key] = next
}
return out
}
return String(node)
}
@@ -0,0 +1,17 @@
export const Config = {
maxPayloadBytes: 50 * 1024 * 1024,
maxSnapshotBytes: 1 * 1024 * 1024 * 1024,
chunkBytes: 1 * 1024 * 1024,
inlineThresholdBytes: 64 * 1024,
flushIntervalMs: 60_000,
flushSizeBytes: 25 * 1024 * 1024,
uploadRateLimitPerMinute: 180,
uploadRateLimitWindowMs: 60_000,
uploadRateLimitIntervalMs: Math.ceil(60_000 / 180),
bufferCapBytes: 50 * 1024 * 1024 * 1024,
ringBufferBytes: 256 * 1024 * 1024,
baselineWaitMs: 3_000,
retryBackoffMinMs: 1_000,
retryBackoffMaxMs: 5 * 60_000,
shutdownFlushTimeoutMs: 15_000,
} as const
@@ -0,0 +1,55 @@
import { getAuthOrgId, type OrgSource, type OrgState } from "./org-sources"
export type { OrgState } from "./org-sources"
let kill = false
let reason: string | undefined
let source: OrgSource = getAuthOrgId
export type EligibilityInput = {
model: {
api: { npm: string }
isFree?: boolean
}
org?: OrgState
}
export function isEligible(input: EligibilityInput): boolean {
if (kill) return false
if (input.org?.type !== "personal") return false
if (input.model.isFree !== true) return false
if (input.model.api.npm !== "@kilocode/kilo-gateway") return false
return true
}
export function setKillSwitch(value: boolean, note?: string): void {
kill = value
reason = value ? note : undefined
}
export function getKillSwitchReason(): string | undefined {
return reason
}
export function resetEligibility(): void {
kill = false
reason = undefined
}
export async function getActiveOrg(): Promise<OrgState> {
const env = process.env.KILO_ORG_ID?.trim()
if (env) return { type: "org", id: env }
try {
return await source()
} catch (err) {
console.warn("[session-export] org source failed", err)
return { type: "unknown" }
}
}
export function setOrgSource(next: OrgSource): void {
source = next
}
export function resetOrgSource(): void {
source = getAuthOrgId
}
@@ -0,0 +1,59 @@
export type ExportEnvelope = {
id: string
schemaVersion: 1
type: ExportEventType
sessionId: string
rootSessionId: string
parentSessionId?: string
requestId?: string
turnId?: string
seq: number
eventSeq?: number
ts: number
agentVersion: string
}
export const ExportEventTypes = [
"llm_request_started",
"llm_request_completed",
"workspace_baseline_started",
"workspace_baseline_completed",
"workspace_delta_captured",
"tool_executed",
"terminal_outcome",
"permission_decided",
"compaction_captured",
"feedback_captured",
"scrub_report",
"session_degraded",
] as const
export type ExportEventType = (typeof ExportEventTypes)[number]
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
export type JsonObject = { [key: string]: JsonValue }
export type UploadedEvent = JsonObject & {
id: string
type: ExportEventType
sessionId: string
rootSessionId: string
parentSessionId?: string
requestId?: string
seq: number
ts: number
}
export type BatchEnvelope = {
schemaVersion: 1
agentVersion: string
surface: string
batchId: string
events: UploadedEvent[]
systemPrompts?: Record<string, JsonValue>
toolSchemas?: Record<string, JsonValue>
permissionSets?: Record<string, JsonValue>
agents?: Record<string, JsonValue>
chunks: { id: string; bytes: string; size: number; encoding: "zstd+base64" }[]
}
@@ -0,0 +1,186 @@
import type { ExportEnvelope } from "./envelope"
import type { Permission } from "@/permission"
import type { MessageV2 } from "@/session/message-v2"
import type { ModelMessage, TextStreamPart, Tool } from "ai"
export type { ExportEnvelope } from "./envelope"
export type LlmStreamPart = TextStreamPart<Record<string, Tool>>
export type LlmRequestStarted = ExportEnvelope & {
type: "llm_request_started"
requestId: string
userMessageId: string
assistantMessageId?: string
agent: string
modeId: string
model: {
providerId: string
modelId: string
variant?: string
isFree: true
}
input: {
system: string[]
messages: ModelMessage[]
tools: Record<string, Tool>
permissions: Permission.Ruleset
toolChoice?: "auto" | "required" | "none"
params: Record<string, unknown>
}
gitContext?: { branch: string; sha: string; dirtyFileCount: number }
agentInfo?: unknown
time: { created: number }
}
export type LlmRequestCompleted = ExportEnvelope & {
type: "llm_request_completed"
requestId: string
output: {
textParts: string[]
reasoningParts?: string[]
toolCalls?: LlmStreamPart[]
finishReason?: string
error?: unknown
usage?: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number }
}
durationMs: number
retryCount: number
time: { completed: number }
}
export type WorkspaceBaselineStarted = ExportEnvelope & {
type: "workspace_baseline_started"
requestedAt: number
}
export type FileEntry = {
path: string
kind: "file" | "symlink" | "directory"
size?: number
hash?: string
content?: string
chunkIds?: string[]
encoding?: "utf8" | "base64"
omitted?: { reason: "secret" | "pii" | "binary" | "large" | "high_risk_path" | "error"; detail?: string }
}
export type CaptureMetadata = {
mode: "git-tracked-and-untracked" | "none"
fileCount: number
totalBytes: number
omittedCountsByReason: Record<string, number>
truncated: boolean
}
export type WorkspaceBaselineCompleted = ExportEnvelope & {
type: "workspace_baseline_completed"
snapshotId?: string
consistency: "stable" | "eventual" | "missing"
files: FileEntry[]
capture?: CaptureMetadata
truncated?: boolean
originalFileCount?: number
originalTotalSize?: number
}
export type DeltaEntry = {
path: string
status: "added" | "modified" | "removed" | "renamed"
additions?: number
deletions?: number
patchChunkIds: string[]
patch?: string
}
export type WorkspaceDeltaCaptured = ExportEnvelope & {
type: "workspace_delta_captured"
snapshotHash: string
prevSnapshotHash: string
trigger: "next_request" | "turn_end" | "session_close"
diff: DeltaEntry[]
}
export type ToolExecuted = ExportEnvelope & {
type: "tool_executed"
toolCallId: string
toolName: string
source: "builtin" | "mcp"
mcpServer?: string
inputChunkIds: string[]
outputChunkIds: string[]
toolInput?: unknown
toolOutput?: string
errorCode?: string
exitCode?: number
signal?: string
durationMs: number
retryCount: number
}
export type TerminalOutcome = ExportEnvelope & {
type: "terminal_outcome"
toolCallId: string
exitCode: number
signal?: string
durationMs: number
}
export type PermissionDecided = ExportEnvelope & {
type: "permission_decided"
toolName: string
decision: "allow" | "deny" | "always_allow" | "always_deny"
reason?: string
durationToDecideMs: number
}
export type CompactionCaptured = ExportEnvelope & {
type: "compaction_captured"
input: {
inputMessagesSnapshot: ModelMessage[]
selectedContext: MessageV2.WithParts[]
previousSummary?: string
prompt: string
tailStartId?: string
}
output: {
summary: string
assistantMessageId: string
}
modelId: string
durationMs: number
usage?: { inputTokens: number; outputTokens: number }
}
export type FeedbackCaptured = ExportEnvelope & {
type: "feedback_captured"
messageId: string
rating: "up" | "down"
previousRating?: "up" | "down"
}
export type ScrubReport = ExportEnvelope & {
type: "scrub_report"
client_scrubbed: boolean
redactionsByType: Record<string, number>
failureReason?: string
}
export type SessionDegraded = ExportEnvelope & {
type: "session_degraded"
reason: "ring_buffer_overflow"
}
export type ExportEvent =
| LlmRequestStarted
| LlmRequestCompleted
| WorkspaceBaselineStarted
| WorkspaceBaselineCompleted
| WorkspaceDeltaCaptured
| ToolExecuted
| TerminalOutcome
| PermissionDecided
| CompactionCaptured
| FeedbackCaptured
| ScrubReport
| SessionDegraded
@@ -0,0 +1 @@
export * as SessionExport from "./session-export"
@@ -0,0 +1,25 @@
import { Auth } from "@/auth"
import { Config } from "@/config/config"
import { makeRuntime } from "@/effect/run-service"
import { resolveKiloIndexingAuth } from "@/kilocode/indexing-auth"
export type OrgState = { type: "personal" } | { type: "org"; id: string } | { type: "unknown" }
export type OrgSource = () => Promise<OrgState>
const config = makeRuntime(Config.Service, Config.defaultLayer)
const auth = makeRuntime(Auth.Service, Auth.defaultLayer)
export async function getAuthOrgId(): Promise<OrgState> {
try {
const [cfg, info] = await Promise.all([
config.runPromise((svc) => svc.get()),
auth.runPromise((svc) => svc.get("kilo")),
])
const id = resolveKiloIndexingAuth({ config: cfg, auth: info }).organizationId
if (id) return { type: "org", id }
return { type: "personal" }
} catch (err) {
console.warn("[session-export] org lookup failed", err)
return { type: "unknown" }
}
}
@@ -0,0 +1,28 @@
import { Database } from "bun:sqlite"
export function createSequencer(path: string) {
const sqlite = new Database(path, { create: true })
sqlite.exec("PRAGMA journal_mode = WAL")
sqlite.exec("PRAGMA busy_timeout = 5000")
sqlite.exec(`
CREATE TABLE IF NOT EXISTS session_export_sequence (
session_id TEXT PRIMARY KEY,
next INTEGER NOT NULL
)
`)
const stmt = sqlite.query(`
INSERT INTO session_export_sequence (session_id, next)
VALUES (?, 1)
ON CONFLICT(session_id) DO UPDATE SET next = next + 1
RETURNING next - 1 AS seq
`)
return {
next(sessionId: string): number {
const row = stmt.get(sessionId) as { seq: number }
return row.seq
},
close(): void {
sqlite.close()
},
}
}
@@ -0,0 +1,220 @@
import type { Agent } from "@/agent/agent"
import { Capture, type CaptureDeps } from "./capture"
import { Config } from "./config"
import { setKillSwitch } from "./eligibility"
import { createSequencer } from "./sequence"
import { SyncSubscriber } from "./sync-subscriber"
declare global {
const KILO_SESSION_EXPORT_WORKER_PATH: string
}
type WorkerTarget = string | URL
type Opts = {
agentVersion: string
dbPath: string
endpoint?: string
surface: string
anonId?: string
workspaceKey: string
snapshotProvider?: CaptureDeps["snapshotProvider"]
syncSeq: (sessionId: string) => number
subscribeAll: (cb: (event: unknown) => void) => () => void
createWorker: (url: WorkerTarget) => Worker
sequencer?: ReturnType<typeof createSequencer>
}
type Instance = { options: Opts; capture: Capture; subscriber: SyncSubscriber; unsubscribe: () => void }
let worker: Worker | undefined
let attempts = 0
let shared: Opts | undefined
const instances = new Map<string, Instance>()
const maxRespawns = 3
export const init = (opts: {
agentVersion: string
dbPath: string
endpoint?: string
surface?: string
anonId?: string
snapshotProvider?: CaptureDeps["snapshotProvider"]
workspaceKey?: string
syncSeq?: (sessionId: string) => number
subscribeAll: (cb: (event: unknown) => void) => () => void
createWorker?: (url: WorkerTarget) => Worker
}): void => {
const url = target()
try {
const key = opts.workspaceKey ?? "default"
const previous = instances.get(key)
previous?.unsubscribe()
previous?.options.sequencer?.close()
const sequencer = opts.syncSeq ? undefined : createSequencer(opts.dbPath)
const syncSeq = opts.syncSeq ?? ((sessionId: string) => sequencer!.next(sessionId))
const next: Opts = {
agentVersion: opts.agentVersion,
dbPath: opts.dbPath,
endpoint: opts.endpoint,
surface: opts.surface ?? currentSurface(),
anonId: opts.anonId,
workspaceKey: key,
snapshotProvider: opts.snapshotProvider,
syncSeq,
subscribeAll: opts.subscribeAll,
createWorker: opts.createWorker ?? ((file) => new Worker(file)),
sequencer,
}
shared = shared ?? next
if (worker) {
configure(next)
return
}
shared = next
spawn(url)
} catch (err) {
const current = worker as unknown as Worker | undefined
if (current) current.terminate()
worker = undefined
for (const item of instances.values()) {
item.unsubscribe()
item.options.sequencer?.close()
}
instances.clear()
shared = undefined
throw err
}
}
export const beforeRequest = (...args: Parameters<Capture["beforeRequest"]>): void => {
captureFor(args[0].requestMeta.workspaceKey)?.beforeRequest(...args)
}
export const afterRequest = (...args: Parameters<Capture["afterRequest"]>): void => {
captureFor(args[0].workspaceKey)?.afterRequest(...args)
}
export const compaction = (args: Parameters<Capture["compaction"]>[0]): void => {
captureFor(args.workspaceKey)?.compaction(args)
}
export const agentInfo = (info: Agent.Info): Record<string, unknown> => {
const out: Record<string, unknown> = {
name: info.name,
mode: info.mode,
}
if (info.displayName !== undefined) out.displayName = info.displayName
if (info.description !== undefined) out.description = info.description
if (info.deprecated !== undefined) out.deprecated = info.deprecated
if (info.native !== undefined) out.native = info.native
if (info.hidden !== undefined) out.hidden = info.hidden
if (info.topP !== undefined) out.topP = info.topP
if (info.temperature !== undefined) out.temperature = info.temperature
if (info.color !== undefined) out.color = info.color
if (info.model !== undefined) out.model = info.model
if (info.variant !== undefined) out.variant = info.variant
if (info.steps !== undefined) out.steps = info.steps
return out
}
export const onSessionClose = async (sessionId: string, workspaceKey?: string): Promise<void> => {
await captureFor(workspaceKey)?.onSessionClose(sessionId)
}
export const shutdown = async (): Promise<void> => {
if (!worker) return
const current = worker
for (const item of instances.values()) item.unsubscribe()
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, Config.shutdownFlushTimeoutMs + 500)
current.onmessage = (event: MessageEvent) => {
if ((event.data as { kind?: string }).kind === "shutdown_done") {
clearTimeout(timer)
resolve()
}
}
current.postMessage({ kind: "shutdown", timeoutMs: Config.shutdownFlushTimeoutMs })
})
current.terminate()
worker = undefined
for (const item of instances.values()) item.options.sequencer?.close()
instances.clear()
shared = undefined
attempts = 0
}
function target(): WorkerTarget {
if (typeof KILO_SESSION_EXPORT_WORKER_PATH !== "undefined") return KILO_SESSION_EXPORT_WORKER_PATH
return new URL("./worker.ts", import.meta.url)
}
function spawn(url = target()): void {
if (!shared) return
worker = shared.createWorker(url)
worker.postMessage({
kind: "init",
dbPath: shared.dbPath,
agentVersion: shared.agentVersion,
endpoint: shared.endpoint,
surface: shared.surface,
anonId: shared.anonId,
})
for (const item of [...instances.values()]) configure(item.options)
if (instances.size === 0) configure(shared)
}
function configure(options: Opts): void {
if (!worker) return
instances.get(options.workspaceKey)?.unsubscribe()
const capture = new Capture({
worker,
agentVersion: options.agentVersion,
nowMs: () => Date.now(),
syncSeq: options.syncSeq,
onPostError: respawn,
snapshotProvider: options.snapshotProvider,
})
const subscriber = new SyncSubscriber({
isEligibleSession: (sessionId) => capture.hasEligibleSession(sessionId),
dispatch: (event) => capture.dispatchRaw(event),
agentVersion: options.agentVersion,
now: () => Date.now(),
syncSeq: options.syncSeq,
getTurnId: (sessionId) => capture.turnId(sessionId),
getRootSessionId: (sessionId) => capture.rootSessionId(sessionId),
})
const unsubscribe = options.subscribeAll((event) => subscriber.onSyncEvent(event as never))
instances.set(options.workspaceKey, { options, capture, subscriber, unsubscribe })
worker.onmessage = (event: MessageEvent) => {
const msg = event.data as { kind?: string; sessionId?: string; reason?: string; name?: string }
if (msg.kind === "pressure" && msg.sessionId) {
for (const item of instances.values()) item.capture.markDegraded(msg.sessionId!)
}
if (msg.kind === "kill_switch") setKillSwitch(true, msg.reason ?? "worker")
}
worker.onerror = (event: ErrorEvent) => {
console.warn("[session-export] worker error", event.message)
respawn(event.error ?? event.message)
}
}
function currentSurface(): string {
return process.env.KILOCODE_FEATURE?.trim() || "unknown"
}
function respawn(err: unknown): void {
console.warn("[session-export] worker respawn", err)
worker?.terminate()
worker = undefined
attempts++
if (attempts > maxRespawns) {
setKillSwitch(true, "worker_respawn_failed")
return
}
spawn()
}
function captureFor(key: string | undefined): Capture | undefined {
if (key) return instances.get(key)?.capture
return instances.get("default")?.capture ?? instances.values().next().value?.capture
}
@@ -0,0 +1,158 @@
import type { ExportEvent, FeedbackCaptured, PermissionDecided, ToolExecuted } from "./events"
import { ulid } from "ulid"
export type SyncSubscriberDeps = {
isEligibleSession: (sessionId: string) => boolean
dispatch: (envelope: ExportEvent) => void
agentVersion: string
now: () => number
syncSeq: (sessionId: string) => number
getTurnId?: (sessionId: string) => string | undefined
getRootSessionId?: (sessionId: string) => string | undefined
}
type SyncLike = { type: string; aggregateID?: string; seq?: number; data?: unknown; properties?: unknown }
export class SyncSubscriber {
private permissions = new Map<string, { sessionId: string; toolName: string; started: number }>()
constructor(private readonly deps: SyncSubscriberDeps) {}
onSyncEvent(event: SyncLike): void {
const data = record(event.data ?? event.properties)
const sessionId = text(event.aggregateID) ?? text(data.sessionID)
if (!sessionId) return
if (!this.deps.isEligibleSession(sessionId)) return
switch (event.type) {
case "permission.asked":
this.handlePermissionAsked(sessionId, data)
return
case "message.part.updated":
this.handlePart(sessionId, data)
return
case "permission.replied":
this.handlePermission(sessionId, data)
return
case "session.feedback":
this.handleFeedback(sessionId, data)
return
default:
return
}
}
private handlePermissionAsked(sessionId: string, data: Record<string, unknown>): void {
const id = text(data.id) ?? text(data.requestID) ?? text(data.requestId)
if (!id) return
this.permissions.set(id, {
sessionId,
toolName: text(data.permission) ?? text(record(data.metadata).tool) ?? "",
started: this.deps.now(),
})
}
private handlePart(sessionId: string, data: Record<string, unknown>): void {
const part = record(data.part)
const state = record(part.state)
if (part.type !== "tool") return
if (state.status !== "completed" && state.status !== "error") return
const toolName = text(part.tool) ?? text(part.toolName) ?? ""
const start = number(record(state.time).start) ?? this.deps.now()
const end = number(record(state.time).end) ?? this.deps.now()
const output = text(state.output)
const input = state.input
const meta = record(state.metadata)
const shell = toolName === "bash" || toolName === "shell"
const seq = this.deps.syncSeq(sessionId)
const tool: ToolExecuted = {
id: ulid(),
schemaVersion: 1,
type: "tool_executed",
sessionId,
rootSessionId: this.deps.getRootSessionId?.(sessionId) ?? sessionId,
turnId: this.deps.getTurnId?.(sessionId),
seq,
eventSeq: seq,
ts: this.deps.now(),
agentVersion: this.deps.agentVersion,
toolCallId: text(part.callID) ?? "",
toolName,
source: text(part.source) === "mcp" ? "mcp" : "builtin",
mcpServer: text(part.mcpServer),
inputChunkIds: [],
outputChunkIds: [],
toolInput: input,
toolOutput: output,
errorCode: text(state.error),
exitCode: shell ? (number(meta.exit) ?? number(meta.exitCode) ?? 0) : undefined,
signal: shell ? text(meta.signal) : undefined,
durationMs: Math.max(0, end - start),
retryCount: 0,
}
this.deps.dispatch(tool)
}
private handlePermission(sessionId: string, data: Record<string, unknown>): void {
const request = text(data.requestID) ?? text(data.requestId)
const asked = request ? this.permissions.get(request) : undefined
if (request) this.permissions.delete(request)
const reply = text(data.reply)
const decision = reply === "always" ? "always_allow" : reply === "once" ? "allow" : "deny"
const seq = this.deps.syncSeq(sessionId)
const env: PermissionDecided = {
id: ulid(),
schemaVersion: 1,
type: "permission_decided",
sessionId,
rootSessionId: this.deps.getRootSessionId?.(sessionId) ?? sessionId,
turnId: this.deps.getTurnId?.(sessionId),
seq,
eventSeq: seq,
ts: this.deps.now(),
agentVersion: this.deps.agentVersion,
toolName: text(data.permission) ?? asked?.toolName ?? "",
decision,
durationToDecideMs: Math.max(0, this.deps.now() - (asked?.started ?? this.deps.now())),
}
this.deps.dispatch(env)
}
private handleFeedback(sessionId: string, data: Record<string, unknown>): void {
const rating = text(data.rating) === "down" ? "down" : "up"
const seq = this.deps.syncSeq(sessionId)
const env: FeedbackCaptured = {
id: ulid(),
schemaVersion: 1,
type: "feedback_captured",
sessionId,
rootSessionId: this.deps.getRootSessionId?.(sessionId) ?? sessionId,
turnId: this.deps.getTurnId?.(sessionId),
seq,
eventSeq: seq,
ts: this.deps.now(),
agentVersion: this.deps.agentVersion,
messageId: text(data.messageID) ?? text(data.messageId) ?? "",
rating,
previousRating:
text(data.previousRating) === "down" ? "down" : text(data.previousRating) === "up" ? "up" : undefined,
}
this.deps.dispatch(env)
}
}
function record(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {}
return value as Record<string, unknown>
}
function text(value: unknown): string | undefined {
if (typeof value !== "string") return undefined
return value
}
function number(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined
return value
}
@@ -0,0 +1,136 @@
import { Config } from "./config"
import { Chunker } from "./worker/chunks"
import { handleEvent } from "./worker/handlers"
import { Inbox } from "./worker/inbox"
import type { FromWorker, ToWorker } from "./worker/ipc"
import { Scrubber } from "./worker/scrub"
import { Storage } from "./worker/storage"
import { Uploader } from "./worker/uploader"
import { checkBufferCap } from "./worker/buffer-cap"
import { resolveEndpoint } from "./worker/endpoint"
import { parseMessage } from "./worker/validate"
import path from "node:path"
type Scope = {
onmessage: (event: MessageEvent<unknown>) => void
postMessage: (message: FromWorker | { kind: "test_event_count"; count: number }) => void
}
const scope = self as unknown as Scope
let storage: Storage | undefined
let chunker: Chunker | undefined
let scrubber: Scrubber | undefined
let inbox: Inbox | undefined
let uploader: Uploader | undefined
let draining = false
let tripped = false
async function drain(): Promise<void> {
if (draining) return
draining = true
try {
while (inbox && storage && chunker && scrubber) {
const batch = inbox.drainBatch(64)
if (batch.length === 0) break
for (const item of batch) {
try {
await handleEvent(item.envelope, {
storage,
chunker,
scrubber,
inlineThresholdBytes: Config.inlineThresholdBytes,
maxPayloadBytes: Config.maxPayloadBytes,
})
uploader?.scheduleFlush("event_persisted")
} catch (err) {
scope.postMessage({
kind: "telemetry",
name: "session_export.handler_error",
props: { message: String(err) },
})
}
}
}
} finally {
draining = false
}
}
scope.onmessage = (event) => {
const msg = parseMessage(event.data)
if (!msg) {
scope.postMessage({ kind: "telemetry", name: "session_export.invalid_worker_message" })
return
}
switch (msg.kind) {
case "init":
storage = new Storage(msg.dbPath)
storage.migrate()
chunker = new Chunker(storage, { chunkBytes: Config.chunkBytes })
scrubber = new Scrubber()
inbox = new Inbox({ capacityBytes: Config.ringBufferBytes })
uploader = new Uploader({
storage,
endpoint: resolveEndpoint({
endpoint: msg.endpoint,
env: process.env.KILO_SESSION_EXPORT_INGEST,
allowCustom: process.env.KILO_SESSION_EXPORT_ALLOW_CUSTOM_INGEST === "1",
}),
fetch: globalThis.fetch,
reportTelemetry: (item) => scope.postMessage(item),
agentVersion: msg.agentVersion ?? "unknown",
surface: msg.surface ?? "unknown",
anonId: msg.anonId,
anonIdPath: path.join(path.dirname(msg.dbPath), "telemetry-id"),
})
tripped = false
scope.postMessage({ kind: "ready" })
return
case "event": {
if (tripped) return
if (!inbox) return
const result = inbox.enqueue(msg.envelope.sessionId, msg.approxBytes, msg.envelope)
if (!result.accepted && result.sessionFirstOverflow) {
scope.postMessage({ kind: "pressure", sessionId: msg.envelope.sessionId })
}
void drain()
return
}
case "test_event_count":
void (async () => {
await drain()
const count = storage?.pendingEvents({ now: Date.now() + 1, limitBytes: 100_000_000 }).length ?? 0
scope.postMessage({ kind: "test_event_count", count })
})()
return
case "shutdown":
void (async () => {
await drain()
await uploader?.flush("shutdown")
uploader?.dispose()
storage?.close()
clearInterval(cap)
storage = undefined
chunker = undefined
scrubber = undefined
inbox = undefined
uploader = undefined
scope.postMessage({ kind: "shutdown_done" })
})()
return
case "network_reconnect":
uploader?.scheduleFlush("network_reconnect")
return
}
}
const cap = setInterval(() => {
if (!storage || tripped) return
const result = checkBufferCap(storage, { capacityBytes: Config.bufferCapBytes })
if (!result.tripped) return
tripped = true
scope.postMessage({ kind: "telemetry", name: "session_export.buffer_overflow", props: { dbSize: result.dbSize } })
scope.postMessage({ kind: "kill_switch", reason: "buffer_cap_50gb" })
}, 60_000)
cap.unref?.()
@@ -0,0 +1,9 @@
import type { Storage } from "./storage"
export function checkBufferCap(
storage: Storage,
opts: { capacityBytes: number },
): { tripped: boolean; dbSize: number } {
const dbSize = storage.dbSize()
return { tripped: dbSize > opts.capacityBytes, dbSize }
}
@@ -0,0 +1,54 @@
import { createHash } from "node:crypto"
import { Storage } from "./storage"
import { compressZstd, decompressZstd } from "./zstd"
export type ChunkerConfig = { chunkBytes: number }
export class Chunker {
constructor(
private readonly storage: Storage,
private readonly cfg: ChunkerConfig,
) {}
async write(bytes: Uint8Array): Promise<string[]> {
const ids: string[] = []
for (let offset = 0; offset < bytes.byteLength; offset += this.cfg.chunkBytes) {
const slice = bytes.subarray(offset, Math.min(offset + this.cfg.chunkBytes, bytes.byteLength))
const id = sha256Hex(slice)
ids.push(id)
const row = this.storage.getChunk(id)
if (row) {
this.storage.incrementRefCount(id)
continue
}
const zipped = await compressZstd(slice)
this.storage.upsertChunk({ id, bytes: zipped, size: slice.byteLength, encoding: "zstd" })
}
return ids
}
async read(ids: string[]): Promise<Uint8Array> {
const parts: Uint8Array[] = []
let total = 0
for (const id of ids) {
const row = this.storage.getChunk(id)
if (!row) throw new Error(`missing chunk ${id}`)
const part = await decompressZstd(row.bytes)
if (sha256Hex(part) !== id) throw new Error(`chunk hash mismatch for ${id}`)
parts.push(part)
total += part.byteLength
}
const out = new Uint8Array(total)
let offset = 0
for (const part of parts) {
out.set(part, offset)
offset += part.byteLength
}
return out
}
}
function sha256Hex(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex")
}
@@ -0,0 +1,16 @@
export const defaultEndpoint = "https://supermassive-black-hole.kiloapps.io/v1/session-export/batch"
const hosts = new Set(["supermassive-black-hole.kiloapps.io"])
export function resolveEndpoint(opts: { endpoint?: string; env?: string; allowCustom?: boolean }): string {
const endpoint = opts.endpoint ?? opts.env ?? defaultEndpoint
if (opts.allowCustom) return endpoint
try {
const url = new URL(endpoint)
if (url.protocol !== "https:") return defaultEndpoint
if (!hosts.has(url.hostname)) return defaultEndpoint
return endpoint
} catch {
return defaultEndpoint
}
}
@@ -0,0 +1,229 @@
import type { ExportEvent } from "../events"
import type { Chunker } from "./chunks"
import { isHighRiskPath, type Scrubber } from "./scrub"
import type { Storage } from "./storage"
export type HandlerCtx = {
storage: Storage
chunker: Chunker
scrubber: Scrubber
inlineThresholdBytes: number
maxPayloadBytes?: number
}
const ENVELOPE = new Set([
"id",
"schemaVersion",
"type",
"sessionId",
"rootSessionId",
"parentSessionId",
"seq",
"ts",
"agentVersion",
])
const IDENTITY = new Set(["accountid", "email", "org", "orgid", "organizationid", "kilo_org_id"])
const METADATA = new Set(["eventSeq", "time", "durationMs", "retryCount", "requestedAt", "durationToDecideMs"])
export async function handleEvent(envelope: ExportEvent, ctx: HandlerCtx): Promise<void> {
const result = await ctx.scrubber.scrubEvent(envelope)
if (!result.success) return
const payload = await normalizePayload(result.data, ctx)
const chunked = await chunkLargeStrings(payload, ctx)
const dataJson = JSON.stringify(chunked)
ctx.storage.insertEvent({
id: envelope.id,
schemaVersion: envelope.schemaVersion,
sessionId: envelope.sessionId,
rootSessionId: envelope.rootSessionId,
parentSessionId: envelope.parentSessionId,
seq: envelope.seq,
requestId: envelope.requestId,
type: envelope.type,
ts: envelope.ts,
agentVersion: envelope.agentVersion,
dataJson,
clientScrubbed: result.success ? 1 : 0,
})
}
async function normalizePayload(envelope: ExportEvent, ctx: HandlerCtx): Promise<unknown> {
const payload = stripIdentity(stripEnvelopeFields(envelope))
if (envelope.type === "workspace_baseline_completed") return normalizeBaseline(payload, ctx)
if (envelope.type === "workspace_delta_captured") return normalizeDelta(payload, ctx)
if (envelope.type === "llm_request_completed") return normalizeCompletion(payload)
if (envelope.type === "compaction_captured") return normalizeCompaction(payload)
if (envelope.type !== "tool_executed") return payload
const out = { ...(payload as Record<string, unknown>) }
if (envelope.toolInput !== undefined) {
out.inputChunkIds = await ctx.chunker.write(Buffer.from(JSON.stringify(envelope.toolInput), "utf8"))
delete out.toolInput
}
if (envelope.toolOutput !== undefined) {
out.outputChunkIds = await ctx.chunker.write(Buffer.from(envelope.toolOutput, "utf8"))
delete out.toolOutput
}
return out
}
async function normalizeBaseline(payload: unknown, ctx: HandlerCtx): Promise<unknown> {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload
const out = { ...(payload as Record<string, unknown>) }
delete out.snapshotId
delete out.capture
delete out.truncated
delete out.originalFileCount
delete out.originalTotalSize
if (!Array.isArray(out.files)) return out
const files: unknown[] = []
for (const file of out.files) {
if (!file || typeof file !== "object" || Array.isArray(file)) {
files.push(file)
continue
}
const item = file as Record<string, unknown>
if (typeof item.path === "string" && isHighRiskPath(item.path)) {
files.push({ path: item.path, kind: item.kind, omitted: { reason: "high_risk_path" } })
continue
}
const next = { ...item }
if (typeof next.content === "string") {
const bytes = Buffer.from(next.content, "utf8")
next.chunkIds = await ctx.chunker.write(bytes)
next.encoding = "utf8"
next.size = typeof next.size === "number" ? next.size : bytes.byteLength
delete next.content
}
files.push(next)
}
out.files = files
return out
}
function normalizeCompletion(payload: unknown): unknown {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload
const out = { ...(payload as Record<string, unknown>) }
const output = out.output
if (!output || typeof output !== "object" || Array.isArray(output)) return out
const next = { ...(output as Record<string, unknown>) }
if (Array.isArray(next.toolCalls)) {
next.toolCalls = next.toolCalls.map((item) => {
if (!item || typeof item !== "object" || Array.isArray(item)) return item
const call = { ...(item as Record<string, unknown>) }
delete call.output
return call
})
}
out.output = next
return out
}
async function normalizeDelta(payload: unknown, ctx: HandlerCtx): Promise<unknown> {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload
const out = { ...(payload as Record<string, unknown>) }
delete out.snapshotHash
delete out.prevSnapshotHash
if (!Array.isArray(out.diff)) return out
const diff: unknown[] = []
for (const item of out.diff) {
if (!item || typeof item !== "object" || Array.isArray(item)) {
diff.push(item)
continue
}
const next = { ...(item as Record<string, unknown>) }
if (typeof next.path === "string" && isHighRiskPath(next.path)) {
next.patchChunkIds = []
delete next.patch
diff.push(next)
continue
}
if (typeof next.patch === "string") {
next.patchChunkIds = await ctx.chunker.write(Buffer.from(next.patch, "utf8"))
delete next.patch
}
diff.push(next)
}
out.diff = diff
return out
}
function normalizeCompaction(payload: unknown): unknown {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload
const out = { ...(payload as Record<string, unknown>) }
delete out.modelId
delete out.usage
const input = out.input
if (input && typeof input === "object" && !Array.isArray(input)) {
const next = { ...(input as Record<string, unknown>) }
delete next.selectedContext
delete next.tailStartId
out.input = next
}
const output = out.output
if (output && typeof output === "object" && !Array.isArray(output)) {
const next = { ...(output as Record<string, unknown>) }
delete next.assistantMessageId
out.output = next
}
return out
}
function stripEnvelopeFields(input: unknown): unknown {
if (!input || typeof input !== "object" || Array.isArray(input)) return input
const out: Record<string, unknown> = {}
for (const [key, val] of Object.entries(input)) {
if (!ENVELOPE.has(key)) out[key] = val
}
return stripMetadata(out)
}
function stripMetadata(input: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {}
for (const [key, val] of Object.entries(input)) {
if (!METADATA.has(key)) out[key] = val
}
return out
}
function stripIdentity(node: unknown): unknown {
if (Array.isArray(node)) return node.map(stripIdentity)
if (!node || typeof node !== "object") return node
const out: Record<string, unknown> = {}
for (const [key, val] of Object.entries(node)) {
if (IDENTITY.has(key.toLowerCase())) continue
out[key] = stripIdentity(val)
}
return out
}
async function chunkLargeStrings(node: unknown, ctx: HandlerCtx): Promise<unknown> {
if (typeof node === "string") {
const bytes = Buffer.from(node, "utf8")
const original = bytes.byteLength
const limit = ctx.maxPayloadBytes ?? Number.POSITIVE_INFINITY
const kept = original > limit ? bytes.subarray(0, limit) : bytes
if (kept.byteLength <= ctx.inlineThresholdBytes && original <= limit) return node
const ids = await ctx.chunker.write(kept)
return {
__chunked: true,
chunkIds: ids,
size: kept.byteLength,
encoding: "utf8",
truncated: original > limit,
originalSize: original,
}
}
if (Array.isArray(node)) {
const out: unknown[] = []
for (const item of node) out.push(await chunkLargeStrings(item, ctx))
return out
}
if (node && typeof node === "object") {
const out: Record<string, unknown> = {}
for (const [key, val] of Object.entries(node)) out[key] = await chunkLargeStrings(val, ctx)
return out
}
return node
}
@@ -0,0 +1,44 @@
import type { ExportEvent } from "../events"
type Item = { sessionId: string; envelope: ExportEvent; bytes: number }
export class Inbox {
private readonly capacity: number
private items: Item[] = []
private bytes = 0
private degraded = new Set<string>()
constructor(opts: { capacityBytes: number }) {
this.capacity = opts.capacityBytes
}
enqueue(
sessionId: string,
approxPayloadSize: string | number,
envelope: ExportEvent,
): { accepted: boolean; sessionFirstOverflow: boolean } {
const bytes = typeof approxPayloadSize === "number" ? approxPayloadSize : approxPayloadSize.length
if (this.bytes + bytes > this.capacity) {
const first = !this.degraded.has(sessionId)
this.degraded.add(sessionId)
return { accepted: false, sessionFirstOverflow: first }
}
this.items.push({ sessionId, envelope, bytes })
this.bytes += bytes
return { accepted: true, sessionFirstOverflow: false }
}
drainBatch(limit: number): Item[] {
const out = this.items.splice(0, limit)
for (const item of out) this.bytes -= item.bytes
return out
}
usedBytes(): number {
return this.bytes
}
isDegraded(sessionId: string): boolean {
return this.degraded.has(sessionId)
}
}
@@ -0,0 +1,15 @@
import type { ExportEvent } from "../events"
export type ToWorker =
| { kind: "init"; dbPath: string; agentVersion?: string; endpoint?: string; surface?: string; anonId?: string }
| { kind: "event"; envelope: ExportEvent; approxBytes: number }
| { kind: "shutdown"; timeoutMs: number }
| { kind: "network_reconnect" }
| { kind: "test_event_count" }
export type FromWorker =
| { kind: "pressure"; sessionId: string }
| { kind: "ready" }
| { kind: "telemetry"; name: string; props?: Record<string, unknown> }
| { kind: "shutdown_done" }
| { kind: "kill_switch"; reason: string }
@@ -0,0 +1,39 @@
import { sql } from "drizzle-orm"
import { blob, index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"
import { ExportEventTypes } from "../envelope"
export const EventTable = sqliteTable(
"event",
{
id: text().primaryKey(),
schema_version: integer().notNull().default(1),
session_id: text().notNull(),
root_session_id: text().notNull(),
parent_session_id: text(),
seq: integer().notNull(),
request_id: text(),
type: text({ enum: ExportEventTypes }).notNull(),
ts: integer().notNull(),
agent_version: text().notNull(),
data_json: text().notNull(),
client_scrubbed: integer().notNull().default(1),
uploaded_at: integer(),
upload_attempts: integer().notNull().default(0),
next_attempt_at: integer(),
},
(t) => [
index("event_session_seq").on(t.session_id, t.seq),
index("event_pending")
.on(t.uploaded_at, t.next_attempt_at)
.where(sql`${t.uploaded_at} IS NULL`),
],
)
export const ChunkTable = sqliteTable("chunk", {
id: text().primaryKey(),
bytes: blob({ mode: "buffer" }).notNull(),
size: integer().notNull(),
encoding: text({ enum: ["zstd"] }).notNull(),
ref_count: integer().notNull(),
uploaded_at: integer(),
})
@@ -0,0 +1,156 @@
import { lintSource } from "@secretlint/core"
import { creator } from "@secretlint/secretlint-rule-preset-recommend"
export type ScrubResult = {
value: string
redactionsByType: Record<string, number>
}
type Pattern = { name: string; regex: RegExp }
const PATTERNS: Pattern[] = [
{ name: "aws_access_key", regex: /\bAKIA[0-9A-Z]{16}\b/g },
{
name: "aws_secret_key",
regex: /(?:aws[_-]?secret[_-]?(?:access[_-]?)?key)\s*[=:]\s*["']?[0-9a-zA-Z/+]{40}["']?/gi,
},
{ name: "gcp_service_key", regex: /\bAIza[0-9A-Za-z\-_]{35}\b/g },
{ name: "openai_key", regex: /\bsk-[A-Za-z0-9_-]{20,}(?=\b|[^A-Za-z0-9_-])/g },
{ name: "anthropic_key", regex: /\bsk-ant-[A-Za-z0-9_\-]{20,}\b/g },
{ name: "github_pat", regex: /\bghp_[A-Za-z0-9]{36}\b/g },
{ name: "stripe_key", regex: /\b(?:sk|pk)_(?:live|test)_[A-Za-z0-9]{16,}\b/g },
{ name: "slack_token", regex: /\bxox[abpr]-[A-Za-z0-9-]{10,}\b/g },
{ name: "jwt", regex: /\beyJ[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\.[A-Za-z0-9_\-]+\b/g },
{ name: "ssh_private_key", regex: /-----BEGIN[^-]+PRIVATE KEY-----[\s\S]+?-----END[^-]+PRIVATE KEY-----/g },
{ name: "env_secret", regex: /\b(SECRET_[A-Z0-9_]+|[A-Z0-9_]+_TOKEN|PASSWORD|API_KEY)\s*=\s*["']?([^"'\s]+)["']?/g },
{ name: "database_uri", regex: /\b(?:mongodb(?:\+srv)?|postgres(?:ql)?|mysql):\/\/[^:\s/@]+:[^@\s]+@[^\s"')]+/gi },
]
const RISK: RegExp[] = [
/^\.env(\.|$)/,
/(^|\/)\.env(\.|$)/,
/(^|\/)\.aws\/credentials$/,
/(^|\/)credentials\.json$/,
/(^|\/)\.netrc$/,
/(^|\/)\.ssh\/id_/,
/\.pem$/,
/\.key$/,
]
const CONFIG = {
rules: [{ id: "@secretlint/secretlint-rule-preset-recommend", rule: creator }],
}
const FIELDS = new Set(["TOKEN", "KEY", "SECRET", "PASSWORD", "URL", "URI", "CONTENT", "VALUE", "CREDENTIAL"])
type Message = {
ruleId: string
ruleParentId?: string
data?: Record<string, unknown>
}
export function scrubString(input: string, patterns: Pattern[] = PATTERNS): ScrubResult {
const redactionsByType: Record<string, number> = {}
let value = input
for (const item of patterns) {
value = value.replace(item.regex, () => {
redactionsByType[item.name] = (redactionsByType[item.name] ?? 0) + 1
return `<<REDACTED:${item.name}>>`
})
}
return { value, redactionsByType }
}
async function scrubSecretlint(input: string): Promise<ScrubResult> {
const result = await lintSource({
source: { content: input, filePath: "", ext: ".json", contentType: "text" },
options: {
maskSecrets: false,
noPhysicFilePath: true,
config: CONFIG,
},
})
const secrets = secretlintSecrets(result.messages)
let value = input
const redactionsByType: Record<string, number> = {}
for (const [secret, rule] of secrets) {
const name = `secretlint_${rule
.replace(/[^a-z0-9]+/gi, "_")
.replace(/^_+|_+$/g, "")
.toLowerCase()}`
const count = value.split(secret).length - 1
if (count === 0) continue
value = value.split(secret).join(`<<REDACTED:${name}>>`)
redactionsByType[name] = (redactionsByType[name] ?? 0) + count
}
return { value, redactionsByType }
}
export function secretlintSecrets(messages: Message[]): Map<string, string> {
const secrets = new Map<string, string>()
for (const msg of messages) {
const data = msg.data ?? {}
for (const [key, val] of Object.entries(data)) {
if (!FIELDS.has(key.toUpperCase())) continue
if (typeof val !== "string") continue
if (val.length < 4) continue
secrets.set(val, msg.ruleParentId ?? msg.ruleId)
}
}
return secrets
}
export function isHighRiskPath(path: string): boolean {
return RISK.some((item) => item.test(path))
}
export type ScrubbedEvent<T> =
| { success: true; data: T; report: { client_scrubbed: true; redactionsByType: Record<string, number> } }
| {
success: false
data: T
report: { client_scrubbed: false; redactionsByType: Record<string, number>; failureReason: string }
}
export class Scrubber {
constructor(private readonly opts: { patterns?: Pattern[] } = {}) {}
async scrubEvent<T>(event: T): Promise<ScrubbedEvent<T>> {
const totals: Record<string, number> = {}
try {
const data = (await this.walk(event, totals)) as T
return { success: true, data, report: { client_scrubbed: true, redactionsByType: totals } }
} catch (err) {
return {
success: false,
data: event,
report: {
client_scrubbed: false,
redactionsByType: totals,
failureReason: err instanceof Error ? err.message : String(err),
},
}
}
}
private async walk(node: unknown, totals: Record<string, number>): Promise<unknown> {
if (typeof node === "string") {
const lint = await scrubSecretlint(node)
const out = scrubString(lint.value, this.opts.patterns)
for (const [key, val] of Object.entries(lint.redactionsByType)) totals[key] = (totals[key] ?? 0) + val
for (const [key, val] of Object.entries(out.redactionsByType)) totals[key] = (totals[key] ?? 0) + val
return out.value
}
if (Array.isArray(node)) {
const out: unknown[] = []
for (const item of node) out.push(await this.walk(item, totals))
return out
}
if (node && typeof node === "object") {
const out: Record<string, unknown> = {}
for (const [key, val] of Object.entries(node)) out[key] = await this.walk(val, totals)
return out
}
return node
}
}
@@ -0,0 +1,315 @@
import { Database, type SQLQueryBindings, type Statement } from "bun:sqlite"
import { and, asc, eq, inArray, isNotNull, isNull, lte, or, sql } from "drizzle-orm"
import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
import type { ExportEventType } from "../envelope"
import { ChunkTable, EventTable } from "./schema"
const tables = { ChunkTable, EventTable }
type Client = SQLiteBunDatabase<typeof tables> & { $client: Database }
type Prepared = Statement<unknown, SQLQueryBindings[]>
export type EventRow = {
id: string
schemaVersion: number
sessionId: string
rootSessionId: string
parentSessionId?: string
seq: number
requestId?: string
type: ExportEventType
ts: number
agentVersion: string
dataJson: string
clientScrubbed: 0 | 1
}
export type PendingEventRow = EventRow & {
uploadAttempts: number
}
export type ChunkRow = {
id: string
bytes: Uint8Array
size: number
encoding: "zstd"
}
export class Storage {
private readonly sqlite: Database
private readonly db: Client
constructor(path: string) {
this.sqlite = new Database(path, { create: true })
this.db = drizzle({ client: finalizing(this.sqlite), schema: tables }) as Client
this.sqlite.exec("PRAGMA journal_mode = WAL")
this.sqlite.exec("PRAGMA synchronous = NORMAL")
this.sqlite.exec("PRAGMA busy_timeout = 5000")
}
migrate(): void {
this.sqlite.exec(`
CREATE TABLE IF NOT EXISTS event (
id TEXT PRIMARY KEY,
schema_version INTEGER NOT NULL DEFAULT 1,
session_id TEXT NOT NULL,
root_session_id TEXT NOT NULL,
parent_session_id TEXT,
seq INTEGER NOT NULL,
request_id TEXT,
type TEXT NOT NULL,
ts INTEGER NOT NULL,
agent_version TEXT NOT NULL,
data_json TEXT NOT NULL,
client_scrubbed INTEGER NOT NULL DEFAULT 1,
uploaded_at INTEGER,
upload_attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER
);
CREATE INDEX IF NOT EXISTS event_session_seq ON event(session_id, seq);
CREATE INDEX IF NOT EXISTS event_pending ON event(uploaded_at, next_attempt_at) WHERE uploaded_at IS NULL;
CREATE TABLE IF NOT EXISTS chunk (
id TEXT PRIMARY KEY,
bytes BLOB NOT NULL,
size INTEGER NOT NULL,
encoding TEXT NOT NULL,
ref_count INTEGER NOT NULL,
uploaded_at INTEGER
);
`)
}
insertEvent(row: EventRow): void {
this.db
.insert(EventTable)
.values({
id: row.id,
schema_version: row.schemaVersion,
session_id: row.sessionId,
root_session_id: row.rootSessionId,
parent_session_id: row.parentSessionId ?? null,
seq: row.seq,
request_id: row.requestId ?? null,
type: row.type,
ts: row.ts,
agent_version: row.agentVersion,
data_json: row.dataJson,
client_scrubbed: row.clientScrubbed,
})
.run()
}
upsertChunk(row: ChunkRow): void {
this.db
.insert(ChunkTable)
.values({ id: row.id, bytes: Buffer.from(row.bytes), size: row.size, encoding: row.encoding, ref_count: 1 })
.onConflictDoUpdate({ target: ChunkTable.id, set: { ref_count: sql`${ChunkTable.ref_count} + 1` } })
.run()
}
incrementRefCount(id: string): void {
this.db
.update(ChunkTable)
.set({ ref_count: sql`${ChunkTable.ref_count} + 1` })
.where(eq(ChunkTable.id, id))
.run()
}
getChunk(id: string): { id: string; bytes: Uint8Array; refCount: number; size: number } | undefined {
const row = this.db
.select({ id: ChunkTable.id, bytes: ChunkTable.bytes, size: ChunkTable.size, ref_count: ChunkTable.ref_count })
.from(ChunkTable)
.where(eq(ChunkTable.id, id))
.get()
if (!row) return undefined
return { id: row.id, bytes: row.bytes, refCount: row.ref_count, size: row.size }
}
pendingEvents(opts: { now: number; limitBytes: number }): PendingEventRow[] {
const rows = this.db
.select()
.from(EventTable)
.where(
and(
isNull(EventTable.uploaded_at),
or(isNull(EventTable.next_attempt_at), lte(EventTable.next_attempt_at, opts.now)),
),
)
.orderBy(asc(EventTable.ts))
.limit(500)
.all()
const out: PendingEventRow[] = []
let bytes = 0
for (const row of rows) {
bytes += row.data_json.length
if (bytes > opts.limitBytes && out.length > 0) break
out.push({
id: row.id,
schemaVersion: row.schema_version,
sessionId: row.session_id,
rootSessionId: row.root_session_id,
parentSessionId: row.parent_session_id ?? undefined,
seq: row.seq,
requestId: row.request_id ?? undefined,
type: row.type,
ts: row.ts,
agentVersion: row.agent_version,
dataJson: row.data_json,
clientScrubbed: row.client_scrubbed === 1 ? 1 : 0,
uploadAttempts: row.upload_attempts ?? 0,
})
}
return out
}
markRetry(id: string, next: number): void {
this.db
.update(EventTable)
.set({ upload_attempts: sql`${EventTable.upload_attempts} + 1`, next_attempt_at: next })
.where(eq(EventTable.id, id))
.run()
}
markUploaded(ids: string[]): void {
if (ids.length === 0) return
this.db.update(EventTable).set({ uploaded_at: Date.now() }).where(inArray(EventTable.id, ids)).run()
}
deleteUploaded(): { events: number; chunks: number } {
const events = this.db
.delete(EventTable)
.where(isNotNull(EventTable.uploaded_at))
.returning({ id: EventTable.id })
.all().length
const chunks = this.db
.delete(ChunkTable)
.where(lte(ChunkTable.ref_count, 0))
.returning({ id: ChunkTable.id })
.all().length
return { events, chunks }
}
decRefChunks(ids: string[]): void {
if (ids.length === 0) return
this.db
.update(ChunkTable)
.set({ ref_count: sql`${ChunkTable.ref_count} - 1` })
.where(inArray(ChunkTable.id, ids))
.run()
}
commitUploaded(eventIds: string[], chunkIds: string[]): { events: number; chunks: number } {
return this.db.transaction((tx) => {
if (eventIds.length > 0) {
tx.update(EventTable).set({ uploaded_at: Date.now() }).where(inArray(EventTable.id, eventIds)).run()
}
if (chunkIds.length > 0) {
for (const id of chunkIds) {
tx.update(ChunkTable)
.set({ ref_count: sql`${ChunkTable.ref_count} - 1` })
.where(eq(ChunkTable.id, id))
.run()
}
}
const events = tx
.delete(EventTable)
.where(isNotNull(EventTable.uploaded_at))
.returning({ id: EventTable.id })
.all().length
const chunks = tx
.delete(ChunkTable)
.where(lte(ChunkTable.ref_count, 0))
.returning({ id: ChunkTable.id })
.all().length
return { events, chunks }
})
}
chunkRefsForEvents(ids: string): string[]
chunkRefsForEvents(ids: string[]): string[]
chunkRefsForEvents(ids: string | string[]): string[] {
const keys = Array.isArray(ids) ? ids : [ids]
if (keys.length === 0) return []
const rows = this.db
.select({ data_json: EventTable.data_json })
.from(EventTable)
.where(inArray(EventTable.id, keys))
.all()
const out: string[] = []
for (const row of rows) collectChunkRefs(JSON.parse(row.data_json), out)
return out
}
chunksForEvents(ids: string[]): ChunkRow[] {
if (ids.length === 0) return []
const chunkIds = new Set(this.chunkRefsForEvents(ids))
if (chunkIds.size === 0) return []
const chunks = this.db
.select()
.from(ChunkTable)
.where(inArray(ChunkTable.id, [...chunkIds]))
.all()
return chunks.map((row) => ({ id: row.id, bytes: row.bytes, size: row.size, encoding: row.encoding }))
}
dbSize(): number {
const row = this.sqlite
.query("SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()")
.get() as { size: number } | undefined
return row?.size ?? 0
}
close(): void {
this.sqlite.close()
}
}
function finalizing(db: Database): Database {
const client = Object.create(db) as Database
const prepare = db.prepare.bind(db) as (query: string) => Prepared
client.prepare = ((query: string) => wrap(prepare(query))) as Database["prepare"]
client.exec = db.exec.bind(db)
client.transaction = db.transaction.bind(db)
return client
}
function wrap(stmt: Prepared): Prepared {
const run = stmt.run.bind(stmt)
const all = stmt.all.bind(stmt)
const get = stmt.get.bind(stmt)
const values = stmt.values.bind(stmt)
stmt.run = (...params) => finish(stmt, () => run(...params))
stmt.all = (...params) => finish(stmt, () => all(...params))
stmt.get = (...params) => finish(stmt, () => get(...params))
stmt.values = (...params) => finish(stmt, () => values(...params))
return stmt
}
function finish<T>(stmt: Prepared, fn: () => T): T {
try {
return fn()
} finally {
stmt.finalize()
}
}
function collectChunkRefs(node: unknown, out: string[]): void {
if (!node || typeof node !== "object") return
if (Array.isArray(node)) {
for (const item of node) collectChunkRefs(item, out)
return
}
const record = node as Record<string, unknown>
if (Array.isArray(record.chunkIds)) push(record.chunkIds, out)
if (Array.isArray(record.patchChunkIds)) push(record.patchChunkIds, out)
if (Array.isArray(record.inputChunkIds)) push(record.inputChunkIds, out)
if (Array.isArray(record.outputChunkIds)) push(record.outputChunkIds, out)
for (const val of Object.values(record)) collectChunkRefs(val, out)
}
function push(ids: unknown[], out: string[]): void {
for (const id of ids) {
if (typeof id === "string") out.push(id)
}
}
@@ -0,0 +1,336 @@
import { Config } from "../config"
import type { BatchEnvelope, JsonObject, JsonValue, UploadedEvent } from "../envelope"
import type { FromWorker } from "./ipc"
import type { Storage } from "./storage"
import { readFile } from "node:fs/promises"
export type UploaderDeps = {
storage: Storage
endpoint: string
fetch: (input: string, init: RequestInit) => Promise<Response>
reportTelemetry: (msg: Extract<FromWorker, { kind: "telemetry" }>) => void
agentVersion: string
surface: string
anonId?: string
anonIdPath?: string
now?: () => number
sleep?: (ms: number) => Promise<void>
}
export class Uploader {
private timer: ReturnType<typeof setTimeout> | undefined
private periodic: ReturnType<typeof setInterval> | undefined
private active: Promise<void> | undefined
private requested = false
private next = 0
constructor(private readonly deps: UploaderDeps) {
this.periodic = setInterval(() => this.scheduleFlush("periodic"), Config.flushIntervalMs)
this.periodic?.unref?.()
this.scheduleFlush("startup")
}
dispose(): void {
if (this.timer) clearTimeout(this.timer)
if (this.periodic) clearInterval(this.periodic)
this.timer = undefined
this.periodic = undefined
}
scheduleFlush(_reason: string): void {
if (this.active) {
this.requested = true
return
}
if (this.timer) clearTimeout(this.timer)
this.timer = setTimeout(() => void this.flush("scheduled"), 0)
}
async flush(_reason: string): Promise<void> {
if (this.active) {
this.requested = true
return this.active
}
this.active = this.run()
try {
await this.active
} finally {
this.active = undefined
}
}
private async run(): Promise<void> {
do {
this.requested = false
await this.drain()
} while (this.requested)
}
private async drain(): Promise<void> {
let rows: ReturnType<Storage["pendingEvents"]> = []
try {
while (true) {
const now = Date.now()
rows = sessionRows(this.deps.storage.pendingEvents({ now, limitBytes: Config.flushSizeBytes }))
if (rows.length === 0) return
const batchId = await sha256Hex(rows.map((row) => row.id).join("\n"))
const chunks = this.deps.storage.chunksForEvents(rows.map((row) => row.id))
const compact = await compactEvents(
rows.map(
(row): UploadedEvent => ({
...parseObject(row.dataJson),
id: row.id,
type: row.type,
sessionId: row.sessionId,
rootSessionId: row.rootSessionId,
...(row.parentSessionId ? { parentSessionId: row.parentSessionId } : {}),
...(row.requestId ? { requestId: row.requestId } : {}),
seq: row.seq,
ts: row.ts,
}),
),
)
const batch: BatchEnvelope = {
schemaVersion: 1,
agentVersion: this.deps.agentVersion,
surface: this.deps.surface,
batchId,
events: compact.events,
...(Object.keys(compact.systemPrompts).length > 0 ? { systemPrompts: compact.systemPrompts } : {}),
...(Object.keys(compact.toolSchemas).length > 0 ? { toolSchemas: compact.toolSchemas } : {}),
...(Object.keys(compact.permissionSets).length > 0 ? { permissionSets: compact.permissionSets } : {}),
...(Object.keys(compact.agents).length > 0 ? { agents: compact.agents } : {}),
chunks: chunks.map((chunk) => ({
id: chunk.id,
bytes: Buffer.from(chunk.bytes).toString("base64"),
size: chunk.size,
encoding: "zstd+base64",
})),
}
const body = JSON.stringify(batch)
await this.throttle()
const res = await this.deps.fetch(this.deps.endpoint, {
method: "POST",
headers: await headers({
rows,
body,
batchId,
agentVersion: this.deps.agentVersion,
surface: this.deps.surface,
anonId: this.deps.anonId,
anonIdPath: this.deps.anonIdPath,
}),
body,
})
const eventIds = rows.map((row) => row.id)
const chunkIds = this.deps.storage.chunkRefsForEvents(eventIds)
if (res.ok) {
const deleted = this.deps.storage.commitUploaded(eventIds, chunkIds)
this.deps.reportTelemetry({
kind: "telemetry",
name: "session_export.uploaded",
props: { events: deleted.events, chunks: deleted.chunks, batchId },
})
continue
}
if (terminal(res.status)) {
this.deps.storage.commitUploaded(eventIds, chunkIds)
this.deps.reportTelemetry({
kind: "telemetry",
name: "session_export.upload_4xx",
props: { status: res.status, batchId },
})
continue
}
const retryAt = Date.now()
const delay = retryAfter(res.headers) ?? backoffFor(rows[0]?.uploadAttempts ?? 0)
for (const row of rows) this.deps.storage.markRetry(row.id, retryAt + delay)
this.deps.reportTelemetry({
kind: "telemetry",
name: "session_export.upload_retryable",
props: { status: res.status, batchId },
})
return
}
} catch (err) {
const retryAt = Date.now()
for (const row of rows) this.deps.storage.markRetry(row.id, retryAt + backoffFor(row.uploadAttempts))
this.deps.reportTelemetry({
kind: "telemetry",
name: "session_export.upload_network_error",
props: { message: String(err) },
})
}
}
private async throttle(): Promise<void> {
const now = this.deps.now?.() ?? Date.now()
const wait = this.next - now
if (wait > 0) await (this.deps.sleep ?? sleep)(wait)
const after = this.deps.now?.() ?? Date.now()
this.next = Math.max(after, this.next) + Config.uploadRateLimitIntervalMs
}
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
export function backoffFor(attempts: number): number {
const exponent = Math.max(0, attempts)
const grown = Config.retryBackoffMinMs * 2 ** Math.min(exponent, 16)
return Math.min(grown, Config.retryBackoffMaxMs)
}
function terminal(status: number): boolean {
if (status === 400) return true
if (status === 413) return true
if (status === 422) return true
return false
}
function retryAfter(headers: Headers): number | undefined {
const value = headers.get("retry-after")
if (!value) return undefined
const seconds = Number(value)
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000)
const time = Date.parse(value)
if (!Number.isFinite(time)) return undefined
return Math.max(0, time - Date.now())
}
type HeaderArgs = {
rows: ReturnType<Storage["pendingEvents"]>
body: string
batchId: string
agentVersion: string
surface: string
anonId?: string
anonIdPath?: string
}
async function headers(args: HeaderArgs): Promise<Headers> {
const seqs = args.rows.map((row) => row.seq)
const first = args.rows[0]
const out = new Headers({
"content-type": "application/json",
"x-kilo-export-api-version": "1",
"x-kilo-export-schema-version": "1",
"x-kilo-export-agent-version": args.agentVersion,
"x-kilo-export-surface": args.surface,
"x-kilo-export-root-session-id": first.rootSessionId,
"x-kilo-export-session-id": first.sessionId,
"x-kilo-export-batch-id": args.batchId,
"x-kilo-export-seq-start": String(Math.min(...seqs)),
"x-kilo-export-seq-end": String(Math.max(...seqs)),
"x-kilo-export-event-count": String(args.rows.length),
"x-kilo-export-payload-sha256": await sha256Hex(args.body),
"x-kilo-export-client-sent-at": new Date().toISOString(),
"x-kilo-export-content-encoding": "identity",
})
const token = process.env.KILO_SESSION_EXPORT_AUTH_TOKEN
if (token) out.set("authorization", `Bearer ${token}`)
const anon = args.anonId ?? (await anonId(args.anonIdPath))
if (!token && anon) out.set("x-kilo-anon-id", anon)
return out
}
async function anonId(file: string | undefined): Promise<string | undefined> {
if (!file) return undefined
const text = await readFile(file, "utf8").catch(() => undefined)
const id = text?.trim()
if (!id) return undefined
return id
}
function sessionRows(rows: ReturnType<Storage["pendingEvents"]>): ReturnType<Storage["pendingEvents"]> {
const first = rows[0]
if (!first) return []
const session = rows
.filter((row) => row.rootSessionId === first.rootSessionId && row.sessionId === first.sessionId)
.sort((a, b) => a.seq - b.seq)
const out: typeof session = []
for (const row of session) {
const prev = out.at(-1)
if (prev && row.seq !== prev.seq + 1) break
out.push(row)
}
return out
}
async function sha256Hex(value: string): Promise<string> {
const bytes = new TextEncoder().encode(value)
const hash = await crypto.subtle.digest("SHA-256", bytes)
return [...new Uint8Array(hash)].map((byte) => byte.toString(16).padStart(2, "0")).join("")
}
function parseObject(value: string): JsonObject {
const json = JSON.parse(value) as JsonValue
if (!json || typeof json !== "object" || Array.isArray(json))
throw new Error("stored event payload must be a JSON object")
return json
}
type Compact = {
events: UploadedEvent[]
systemPrompts: Record<string, JsonValue>
toolSchemas: Record<string, JsonValue>
permissionSets: Record<string, JsonValue>
agents: Record<string, JsonValue>
}
async function compactEvents(events: UploadedEvent[]): Promise<Compact> {
const out: Compact = { events: [], systemPrompts: {}, toolSchemas: {}, permissionSets: {}, agents: {} }
for (const event of events) {
const next: UploadedEvent = { ...event }
if (next.type !== "llm_request_started") {
out.events.push(next)
continue
}
const input = object(next.input)
if (input) {
next.input = { ...input }
const copy = next.input as JsonObject
if (copy.system !== undefined) {
copy.systemRef = await intern(out.systemPrompts, copy.system)
delete copy.system
}
if (copy.tools !== undefined) {
copy.toolSchemaRef = await intern(out.toolSchemas, copy.tools)
delete copy.tools
}
if (copy.permissions !== undefined) {
copy.permissionRef = await intern(out.permissionSets, copy.permissions)
delete copy.permissions
}
}
if (next.agentInfo !== undefined) {
next.agentRef = await intern(out.agents, next.agentInfo)
delete next.agentInfo
}
out.events.push(next)
}
return out
}
async function intern(dict: Record<string, JsonValue>, value: JsonValue): Promise<string> {
const id = await sha256Hex(stable(value))
dict[id] = value
return id
}
function object(value: JsonValue | undefined): JsonObject | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined
return value
}
function stable(value: JsonValue): string {
if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`
if (value && typeof value === "object") {
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${stable(value[key])}`)
.join(",")}}`
}
return JSON.stringify(value)
}
@@ -0,0 +1,59 @@
import { ExportEventTypes, type ExportEventType } from "../envelope"
import type { ExportEvent } from "../events"
import type { ToWorker } from "./ipc"
const types = new Set<ExportEventType>(ExportEventTypes)
export function parseMessage(value: unknown): ToWorker | undefined {
if (!plain(value)) return undefined
switch (value.kind) {
case "init":
if (typeof value.dbPath !== "string") return undefined
return {
kind: "init",
dbPath: value.dbPath,
agentVersion: text(value.agentVersion),
endpoint: text(value.endpoint),
surface: text(value.surface),
anonId: text(value.anonId),
}
case "event": {
const envelope = parseEnvelope(value.envelope)
if (!envelope) return undefined
if (typeof value.approxBytes !== "number" || !Number.isFinite(value.approxBytes)) return undefined
return { kind: "event", envelope, approxBytes: value.approxBytes }
}
case "shutdown":
if (typeof value.timeoutMs !== "number" || !Number.isFinite(value.timeoutMs)) return undefined
return { kind: "shutdown", timeoutMs: value.timeoutMs }
case "network_reconnect":
return { kind: "network_reconnect" }
case "test_event_count":
return { kind: "test_event_count" }
default:
return undefined
}
}
function parseEnvelope(value: unknown): ExportEvent | undefined {
if (!plain(value)) return undefined
if (typeof value.id !== "string") return undefined
if (value.schemaVersion !== 1) return undefined
if (typeof value.type !== "string" || !types.has(value.type as ExportEventType)) return undefined
if (typeof value.sessionId !== "string") return undefined
if (typeof value.rootSessionId !== "string") return undefined
if (typeof value.seq !== "number" || !Number.isFinite(value.seq)) return undefined
if (typeof value.ts !== "number" || !Number.isFinite(value.ts)) return undefined
if (typeof value.agentVersion !== "string") return undefined
return value as ExportEvent
}
function text(value: unknown): string | undefined {
if (typeof value !== "string") return undefined
return value
}
function plain(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
return true
}
@@ -0,0 +1,13 @@
import { zstdCompress, zstdDecompress } from "node:zlib"
import { promisify } from "node:util"
const compress = promisify(zstdCompress)
const decompress = promisify(zstdDecompress)
export async function compressZstd(bytes: Uint8Array): Promise<Uint8Array> {
return compress(bytes) as Promise<Uint8Array>
}
export async function decompressZstd(bytes: Uint8Array): Promise<Uint8Array> {
return decompress(bytes) as Promise<Uint8Array>
}
@@ -0,0 +1,127 @@
import type {
CaptureMetadata,
DeltaEntry,
ExportEvent,
FileEntry,
WorkspaceBaselineCompleted,
WorkspaceDeltaCaptured,
} from "./events"
import { ulid } from "ulid"
export type BaselineFiberArgs = {
sessionId: string
rootSessionId: string
turnId?: string
timeoutMs: number
now: () => number
syncSeq: () => number
agentVersion: string
requestSnapshot: () => Promise<{ snapshotId: string; files: FileEntry[]; capture?: CaptureMetadata }>
dispatch: (envelope: ExportEvent) => void
}
export type DeltaFiberArgs = {
sessionId: string
rootSessionId: string
turnId?: string
trigger: "next_request" | "turn_end" | "session_close"
prevSnapshotHash: string
now: () => number
syncSeq: () => number
agentVersion: string
requestDiff: (prevSnapshotHash: string) => Promise<{ snapshotHash: string; diff: DeltaEntry[] }>
dispatch: (envelope: ExportEvent) => void
}
export async function startBaselineFiber(args: BaselineFiberArgs): Promise<string | undefined> {
const result = await resolveBaseline(args)
emitBaseline(args, result)
return result.snapshotId
}
function emitBaseline(
args: BaselineFiberArgs,
result: {
consistency: "stable" | "eventual" | "missing"
snapshotId?: string
files: FileEntry[]
capture?: CaptureMetadata
},
): void {
const seq = args.syncSeq()
args.dispatch({
id: ulid(),
schemaVersion: 1,
type: "workspace_baseline_completed",
sessionId: args.sessionId,
rootSessionId: args.rootSessionId,
turnId: args.turnId,
seq,
eventSeq: seq,
ts: args.now(),
agentVersion: args.agentVersion,
snapshotId: result.snapshotId,
consistency: result.consistency,
files: result.files,
capture: result.capture,
})
}
export async function startDeltaFiber(args: DeltaFiberArgs): Promise<string | undefined> {
try {
const result = await args.requestDiff(args.prevSnapshotHash)
if (result.diff.length === 0) return result.snapshotHash
const seq = args.syncSeq()
const env: WorkspaceDeltaCaptured = {
id: ulid(),
schemaVersion: 1,
type: "workspace_delta_captured",
sessionId: args.sessionId,
rootSessionId: args.rootSessionId,
turnId: args.turnId,
seq,
eventSeq: seq,
ts: args.now(),
agentVersion: args.agentVersion,
snapshotHash: result.snapshotHash,
prevSnapshotHash: args.prevSnapshotHash,
trigger: args.trigger,
diff: result.diff,
}
args.dispatch(env)
return result.snapshotHash
} catch (err) {
console.warn("[session-export] delta capture failed", err)
return undefined
}
}
async function resolveBaseline(args: BaselineFiberArgs): Promise<{
consistency: "stable" | "eventual" | "missing"
snapshotId?: string
files: FileEntry[]
capture?: CaptureMetadata
}> {
const pending = args.requestSnapshot()
const timeout = new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), args.timeoutMs))
try {
const winner = await Promise.race([pending, timeout])
if (winner === "timeout") {
void pending.then(
(eventual) =>
emitBaseline(args, {
consistency: "eventual",
snapshotId: eventual.snapshotId,
files: eventual.files,
capture: eventual.capture,
}),
(err) => console.warn("[session-export] eventual baseline failed", err),
)
return { consistency: "missing", files: [] }
}
return { consistency: "stable", snapshotId: winner.snapshotId, files: winner.files, capture: winner.capture }
} catch (err) {
console.warn("[session-export] baseline failed", err)
return { consistency: "missing", files: [] }
}
}
@@ -0,0 +1,277 @@
import { createHash } from "node:crypto"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { lstat, readFile } from "node:fs/promises"
import path from "node:path"
import { formatPatch, structuredPatch } from "diff"
import { Config } from "./config"
import type { CaptureMetadata, DeltaEntry, FileEntry } from "./events"
import { isHighRiskPath } from "./worker/scrub"
type File = {
path: string
kind: "file" | "symlink"
size: number
hash: string
content?: string
omitted?: FileEntry["omitted"]
}
export function createWorkspaceProvider(opts: { root: string; statePath?: string; maxSnapshotBytes?: number }) {
const state = load(opts.statePath)
const snapshots = new Map<string, Map<string, File>>(
Object.entries(state.snapshots).map(([key, files]) => [key, new Map(files.map((file) => [file.path, file]))]),
)
const pending = new Set<string>()
const capture = async () => {
const result = await scan(opts.root, opts.maxSnapshotBytes ?? Config.maxSnapshotBytes)
const files = result.files
const id = hash(files)
snapshots.set(id, files)
state.snapshots[id] = [...files.values()].map(persist)
pending.add(id)
setTimeout(() => pending.delete(id), 0).unref?.()
save(opts.statePath, state)
return { id, files, capture: metadata(result.mode, files, result.truncated) }
}
return {
current(sessionId: string): string | undefined {
return state.sessions[sessionId]
},
remember(sessionId: string, snapshotId: string): void {
state.sessions[sessionId] = snapshotId
pending.delete(snapshotId)
prune(state, snapshots, pending)
save(opts.statePath, state)
},
async baseline(): Promise<{ snapshotId: string; files: FileEntry[]; capture: CaptureMetadata }> {
const snap = await capture()
return { snapshotId: snap.id, files: [...snap.files.values()].map(entry), capture: snap.capture }
},
async diff(prevSnapshotHash: string): Promise<{ snapshotHash: string; diff: DeltaEntry[] }> {
const snap = await capture()
const prev = snapshots.get(prevSnapshotHash) ?? new Map()
return { snapshotHash: snap.id, diff: delta(prev, snap.files) }
},
}
}
type State = {
sessions: Record<string, string>
snapshots: Record<string, File[]>
}
function prune(state: State, snapshots: Map<string, Map<string, File>>, pending: Set<string>): void {
const used = new Set([...Object.values(state.sessions), ...pending])
for (const id of Object.keys(state.snapshots)) {
if (used.has(id)) continue
delete state.snapshots[id]
snapshots.delete(id)
}
}
function load(file: string | undefined): State {
if (!file || !existsSync(file)) return { sessions: {}, snapshots: {} }
try {
const value = JSON.parse(readFileSync(file, "utf8"))
return state(value)
} catch {
return { sessions: {}, snapshots: {} }
}
}
function state(value: unknown): State {
if (!plain(value)) return { sessions: {}, snapshots: {} }
const raw = plain(value.snapshots) ? value.snapshots : {}
const snapshots: Record<string, File[]> = {}
for (const [id, files] of Object.entries(raw)) {
if (!Array.isArray(files)) continue
const valid = files.filter((item): item is File => file(item))
if (valid.length !== files.length) continue
snapshots[id] = valid
}
const sessions: Record<string, string> = {}
const refs = plain(value.sessions) ? value.sessions : {}
for (const [session, id] of Object.entries(refs)) {
if (typeof id !== "string") continue
if (!snapshots[id]) continue
sessions[session] = id
}
return { sessions, snapshots }
}
function file(value: unknown): value is File {
if (!plain(value)) return false
if (typeof value.path !== "string") return false
if (value.kind !== "file" && value.kind !== "symlink") return false
if (typeof value.size !== "number" || !Number.isFinite(value.size)) return false
if (typeof value.hash !== "string") return false
if (value.content !== undefined && typeof value.content !== "string") return false
if (value.omitted !== undefined && !plain(value.omitted)) return false
return true
}
function persist(file: File): File {
return {
path: file.path,
kind: file.kind,
size: file.size,
hash: file.hash,
omitted: file.omitted,
}
}
function plain(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
return true
}
function save(file: string | undefined, state: State): void {
if (!file) return
mkdirSync(path.dirname(file), { recursive: true })
writeFileSync(file, JSON.stringify(state))
}
async function scan(
root: string,
limit: number,
): Promise<{ files: Map<string, File>; mode: CaptureMetadata["mode"]; truncated: boolean }> {
const repo = await repository(root)
if (!repo) return { files: new Map(), mode: "none", truncated: false }
const paths = await tracked(repo)
const out = new Map<string, File>()
const budget = { used: 0, limit, truncated: false }
for (const item of paths) {
const file = await inspect(repo, item, budget)
if (file) out.set(file.path, file)
}
return { files: out, mode: "git-tracked-and-untracked", truncated: budget.truncated }
}
async function repository(root: string): Promise<string | undefined> {
const proc = Bun.spawn(["git", "rev-parse", "--show-toplevel"], {
cwd: root,
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
})
const [text, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
if (code !== 0) return
const repo = text.trim()
if (!repo) return
return path.resolve(repo)
}
async function tracked(root: string): Promise<string[]> {
const proc = Bun.spawn(["git", "ls-files", "-co", "--exclude-standard", "-z", "--", "."], {
cwd: root,
stdout: "pipe",
stderr: "pipe",
windowsHide: true,
})
const [text, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
if (code !== 0) return []
return Array.from(new Set(text.split("\0").filter(Boolean))).sort((a, b) => a.localeCompare(b))
}
async function inspect(
root: string,
rel: string,
budget: { used: number; limit: number; truncated: boolean },
): Promise<File | undefined> {
const full = path.join(root, rel)
const info = await lstat(full).catch(() => undefined)
if (!info) return undefined
if (info.isSymbolicLink()) return { path: rel, kind: "symlink", size: info.size, hash: `symlink:${info.size}` }
if (!info.isFile()) return undefined
const size = info.size
if (isHighRiskPath(rel)) {
return { path: rel, kind: "file", size, hash: "", omitted: { reason: "high_risk_path" } }
}
if (size > Config.maxPayloadBytes) {
return { path: rel, kind: "file", size, hash: "", omitted: { reason: "large" } }
}
if (budget.used + size > budget.limit) {
budget.truncated = true
return { path: rel, kind: "file", size, hash: "", omitted: { reason: "large" } }
}
const bytes = await readFile(full).catch(() => undefined)
if (!bytes) return { path: rel, kind: "file", size, hash: "", omitted: { reason: "error" } }
budget.used += size
const hash = sha(bytes)
if (binary(bytes)) return { path: rel, kind: "file", size, hash, omitted: { reason: "binary" } }
return { path: rel, kind: "file", size, hash, content: bytes.toString("utf8") }
}
function binary(bytes: Buffer): boolean {
return bytes.subarray(0, Math.min(bytes.byteLength, 8_000)).includes(0)
}
function sha(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex")
}
function hash(files: Map<string, File>): string {
const text = [...files.values()].map((file) => `${file.path}\0${file.hash}\0${file.size}`).join("\0")
return sha(Buffer.from(text, "utf8"))
}
function entry(file: File): FileEntry {
return {
path: file.path,
kind: file.kind,
size: file.size,
hash: file.hash || undefined,
content: file.content,
omitted: file.omitted,
}
}
function metadata(mode: CaptureMetadata["mode"], files: Map<string, File>, truncated: boolean): CaptureMetadata {
return {
mode,
fileCount: files.size,
totalBytes: [...files.values()].reduce((sum, file) => sum + file.size, 0),
omittedCountsByReason: omitted(files),
truncated,
}
}
function omitted(files: Map<string, File>): Record<string, number> {
const out: Record<string, number> = {}
for (const file of files.values()) {
const reason = file.omitted?.reason
if (!reason) continue
out[reason] = (out[reason] ?? 0) + 1
}
return out
}
function delta(prev: Map<string, File>, next: Map<string, File>): DeltaEntry[] {
const paths = Array.from(new Set([...prev.keys(), ...next.keys()])).sort((a, b) => a.localeCompare(b))
return paths.flatMap((rel) => {
const before = prev.get(rel)
const after = next.get(rel)
if (!before && after) return [patch(rel, "added", "", after.content ?? "")]
if (before && !after) return [patch(rel, "removed", before.content ?? "", "")]
if (!before || !after || before.hash === after.hash) return []
return [patch(rel, "modified", before.content ?? "", after.content ?? "")]
})
}
function patch(rel: string, status: DeltaEntry["status"], before: string, after: string): DeltaEntry {
return {
path: rel,
status,
additions: lines(after),
deletions: lines(before),
patchChunkIds: [],
patch: formatPatch(structuredPatch(rel, rel, before, after, "", "", { context: Number.MAX_SAFE_INTEGER })),
}
}
function lines(text: string): number {
if (!text) return 0
return text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length
}
@@ -82,6 +82,10 @@ export namespace KiloSession {
return roots.get(id) ?? id
}
export function resolveParent(id: string): string | undefined {
return parents.get(id)
}
export function featureForPlatform(platform: string | undefined): string | undefined {
switch (platform) {
case "agent-manager":
@@ -1,6 +1,6 @@
// kilocode_change - new file
import { Telemetry, type ReviewCommand } from "@kilocode/kilo-telemetry"
import { SessionNetwork } from "@/session/network"
import type { ProviderID } from "@/provider/schema"
import type { SessionID } from "@/session/schema"
import type { SessionStatus } from "@/session/status"
import { MessageV2 } from "@/session/message-v2"
@@ -25,6 +25,21 @@ export namespace KiloSessionProcessor {
"The model hit its output limit while reasoning and produced no actionable output. Try disabling reasoning or increasing the output limit."
export const PROVIDER_FINISH_ERROR_MESSAGE =
"The provider ended the response with an error before returning details. Start a new message to retry; Kilo will compact the oversized conversation first if needed."
export const EMPTY_RESPONSE_MESSAGE =
"The provider returned an empty response without a finish reason. Kilo will retry the request."
function tokenTotal(tokens: MessageV2.Assistant["tokens"]) {
return (
(tokens.total ?? 0) + tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
)
}
function output(part: MessageV2.Part) {
if (part.type === "tool") return true
if (part.type === "text") return part.text.trim() !== ""
if (part.type === "reasoning") return part.text.trim() !== ""
return false
}
export function reviewTelemetry(command: string | undefined): ReviewTelemetry | undefined {
if (!isReviewCommand(command)) return
@@ -195,6 +210,47 @@ export namespace KiloSessionProcessor {
}
}
export function emptyResponseError(input: {
msg: MessageV2.Assistant
finish: string
tokens: MessageV2.Assistant["tokens"]
cost: number
parts: MessageV2.Part[]
step: { reasoning: boolean; text: boolean; tool: boolean }
}) {
if (input.finish !== "other") return
if (input.msg.error) return
if (input.cost !== 0) return
if (tokenTotal(input.tokens) !== 0) return
if (input.step.reasoning || input.step.text || input.step.tool) return
if (input.parts.some(output)) return
log.warn("empty provider response", { messageID: input.msg.id })
return new MessageV2.APIError({ message: EMPTY_RESPONSE_MESSAGE, isRetryable: true }).toObject()
}
export function guardEmptyResponse(input: Parameters<typeof emptyResponseError>[0]) {
return Effect.gen(function* () {
const err = emptyResponseError(input)
if (!err) return
return yield* Effect.fail(err)
})
}
function preserveError(error: unknown): MessageV2.Assistant["error"] | undefined {
if (MessageV2.APIError.isInstance(error)) return { name: "APIError", data: error.data }
}
export function parse(error: unknown, input: { providerID: ProviderID; aborted: boolean }) {
return (
preserveError(error) ??
MessageV2.fromError(error, {
providerID: input.providerID,
aborted: input.aborted,
})
)
}
export function lengthWarning(input: {
msg: MessageV2.Assistant
step: { reasoning: boolean; text: boolean; tool: boolean }

Some files were not shown because too many files have changed in this diff Show More