mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
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:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Avoid showing incomplete-response warnings for snapshot initialization status turns.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep chat auto-scroll stable while edit, write, and patch tools hand off between assistant steps.
|
||||
@@ -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.
|
||||
@@ -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
@@ -1,4 +1,3 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/tui.json",
|
||||
"keybinds": {}
|
||||
"$schema": "https://opencode.ai/tui.json"
|
||||
}
|
||||
@@ -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
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||
+15
-1
@@ -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))
|
||||
}
|
||||
|
||||
+15
-3
@@ -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
|
||||
|
||||
|
||||
+35
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user