diff --git a/.opencode/command/commit.md b/.opencode/command/commit.md
index a03b0f1bb5..50ed835ff7 100644
--- a/.opencode/command/commit.md
+++ b/.opencode/command/commit.md
@@ -16,15 +16,12 @@ wip:
For anything in the packages/web use the docs: prefix.
-For anything in the packages/app use the ignore: prefix.
-
prefer to explain WHY something was done from an end user perspective instead of
WHAT was done.
do not do generic messages like "improved agent experience" be very specific
about what user facing changes were made
-if there are changes do a git pull --rebase
if there are conflicts DO NOT FIX THEM. notify me and I will fix them
## GIT DIFF
diff --git a/bun.lock b/bun.lock
index 7494713835..6c94fed176 100644
--- a/bun.lock
+++ b/bun.lock
@@ -221,7 +221,7 @@
},
"packages/kilo-vscode": {
"name": "kilo-code",
- "version": "7.0.25",
+ "version": "1.0.25",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@kilocode/kilo-i18n": "workspace:*",
@@ -267,19 +267,18 @@
"name": "@kilocode/cli",
"version": "1.0.25",
"bin": {
- "kilo": "./bin/kilo",
- "kilocode": "./bin/kilocode",
+ "opencode": "./bin/opencode",
},
"dependencies": {
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.14.1",
- "@ai-sdk/amazon-bedrock": "3.0.74",
- "@ai-sdk/anthropic": "2.0.58",
+ "@ai-sdk/amazon-bedrock": "3.0.79",
+ "@ai-sdk/anthropic": "2.0.62",
"@ai-sdk/azure": "2.0.91",
"@ai-sdk/cerebras": "1.0.36",
"@ai-sdk/cohere": "2.0.22",
- "@ai-sdk/deepinfra": "1.0.33",
+ "@ai-sdk/deepinfra": "1.0.36",
"@ai-sdk/gateway": "2.0.30",
"@ai-sdk/google": "2.0.52",
"@ai-sdk/google-vertex": "3.0.98",
@@ -325,6 +324,7 @@
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
+ "drizzle-orm": "1.0.0-beta.12-a5629fb",
"fuzzysort": "3.1.0",
"gray-matter": "4.0.3",
"hono": "catalog:",
@@ -367,6 +367,8 @@
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
+ "drizzle-kit": "1.0.0-beta.12-a5629fb",
+ "drizzle-orm": "1.0.0-beta.12-a5629fb",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -495,6 +497,8 @@
"ai": "5.0.124",
"diff": "8.0.2",
"dompurify": "3.3.1",
+ "drizzle-kit": "1.0.0-beta.12-a5629fb",
+ "drizzle-orm": "1.0.0-beta.12-a5629fb",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -530,9 +534,9 @@
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="],
- "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="],
+ "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.79", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.62", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GfAQUb1GEmdTjLu5Ud1d5sieNHDpwoQdb4S14KmJlA5RsGREUZ1tfSKngFaiClxFtL0xPSZjePhTMV6Z65A7/g=="],
- "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
+ "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="],
"@ai-sdk/azure": ["@ai-sdk/azure@2.0.91", "", { "dependencies": { "@ai-sdk/openai": "2.0.89", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9tznVSs6LGQNKKxb8pKd7CkBV9yk+a/ENpFicHCj2CmBUKefxzwJ9JbUqrlK3VF6dGZw3LXq0dWxt7/Yekaj1w=="],
@@ -542,7 +546,7 @@
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-lqmINr+1Jy2yGXxnQB6IrC2xMtUY5uK96pyKfqTj1kLlXGatKnJfXF7WTkOGgQrFqIYqpjDz+sPVR3n0KUEUtA=="],
- "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="],
+ "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.33", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LndvRktEgY2IFu4peDJMEXcjhHEEFtM0upLx/J64kCpFHCifalXpK4PPSX3PVndnn0bJzvamO5+fc0z2ooqBZw=="],
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NiKjvqXI/96e/7SjZGgQH141PBqggsF7fNbjGTv4RgVWayMXp9mj0Ou2NjAUGwwxJwj/qseY0gXiDCYaHWFBkw=="],
@@ -662,6 +666,8 @@
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="],
+ "@azure-rest/core-client": ["@azure-rest/core-client@2.5.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-EHaOXW0RYDKS5CFffnixdyRPak5ytiCtU7uXDcP/uiY+A6jFRwNGzzJBiznkCzvi5EYpY+YWinieqHb0oY916A=="],
+
"@azure/abort-controller": ["@azure/abort-controller@2.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA=="],
"@azure/core-auth": ["@azure/core-auth@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg=="],
@@ -684,8 +690,20 @@
"@azure/core-xml": ["@azure/core-xml@1.5.0", "", { "dependencies": { "fast-xml-parser": "^5.0.7", "tslib": "^2.8.1" } }, "sha512-D/sdlJBMJfx7gqoj66PKVmhDDaU6TKA49ptcolxdas29X7AfvLTmfAGLjAcIMBK7UZ2o4lygHIqVckOlQU3xWw=="],
+ "@azure/identity": ["@azure/identity@4.13.0", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^4.2.0", "@azure/msal-node": "^3.5.0", "open": "^10.1.0", "tslib": "^2.2.0" } }, "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw=="],
+
+ "@azure/keyvault-common": ["@azure/keyvault-common@2.0.0", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.3.0", "@azure/core-client": "^1.5.0", "@azure/core-rest-pipeline": "^1.8.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.10.0", "@azure/logger": "^1.1.4", "tslib": "^2.2.0" } }, "sha512-wRLVaroQtOqfg60cxkzUkGKrKMsCP6uYXAOomOIysSMyt1/YM0eUn9LqieAWM8DLcU4+07Fio2YGpPeqUbpP9w=="],
+
+ "@azure/keyvault-keys": ["@azure/keyvault-keys@4.10.0", "", { "dependencies": { "@azure-rest/core-client": "^2.3.3", "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-http-compat": "^2.2.0", "@azure/core-lro": "^2.7.2", "@azure/core-paging": "^1.6.2", "@azure/core-rest-pipeline": "^1.19.0", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/keyvault-common": "^2.0.0", "@azure/logger": "^1.1.4", "tslib": "^2.8.1" } }, "sha512-eDT7iXoBTRZ2n3fLiftuGJFD+yjkiB1GNqzU2KbY1TLYeXeSPVTVgn2eJ5vmRTZ11978jy2Kg2wI7xa9Tyr8ag=="],
+
"@azure/logger": ["@azure/logger@1.3.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA=="],
+ "@azure/msal-browser": ["@azure/msal-browser@4.28.2", "", { "dependencies": { "@azure/msal-common": "15.14.2" } }, "sha512-6vYUMvs6kJxJgxaCmHn/F8VxjLHNh7i9wzfwPGf8kyBJ8Gg2yvBXx175Uev8LdrD1F5C4o7qHa2CC4IrhGE1XQ=="],
+
+ "@azure/msal-common": ["@azure/msal-common@15.14.2", "", {}, "sha512-n8RBJEUmd5QotoqbZfd+eGBkzuFI1KX6jw2b3WcpSyGjwmzoeI/Jb99opIBPHpb8y312NB+B6+FGi2ZVSR8yfA=="],
+
+ "@azure/msal-node": ["@azure/msal-node@3.8.7", "", { "dependencies": { "@azure/msal-common": "15.14.2", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" } }, "sha512-a+Xnrae+uwLnlw68bplS1X4kuJ9F/7K6afuMFyRkNIskhjgDezl5Fhrx+1pmAlDmC0VaaAxjRQMp1OmcqVwkIg=="],
+
"@azure/storage-blob": ["@azure/storage-blob@12.30.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.3", "@azure/core-http-compat": "^2.2.0", "@azure/core-lro": "^2.2.0", "@azure/core-paging": "^1.6.2", "@azure/core-rest-pipeline": "^1.19.1", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/core-xml": "^1.4.5", "@azure/logger": "^1.1.4", "@azure/storage-common": "^12.2.0", "events": "^3.0.0", "tslib": "^2.8.1" } }, "sha512-peDCR8blSqhsAKDbpSP/o55S4sheNwSrblvCaHUZ5xUI73XA7ieUGGwrONgD/Fng0EoDe1VOa3fAQ7+WGB3Ocg=="],
"@azure/storage-common": ["@azure/storage-common@12.3.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.9.0", "@azure/core-http-compat": "^2.2.0", "@azure/core-rest-pipeline": "^1.19.1", "@azure/core-tracing": "^1.2.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.1.4", "events": "^3.3.0", "tslib": "^2.8.1" } }, "sha512-/OFHhy86aG5Pe8dP5tsp+BuJ25JOAl9yaMU3WZbkeoiFMHFtJ7tu5ili7qEdBXNW9G5lDB19trwyI6V49F/8iQ=="],
@@ -766,6 +784,8 @@
"@docsearch/js": ["@docsearch/js@4.6.0", "", {}, "sha512-9/rbgkm/BgTq46cwxIohvSAz3koOFjnPpg0mwkJItAfzKbQIj+310PvwtgUY1YITDuGCag6yOL50GW2DBkaaBw=="],
+ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
+
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
@@ -1024,6 +1044,10 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+ "@js-joda/core": ["@js-joda/core@5.7.0", "", {}, "sha512-WBu4ULVVxySLLzK1Ppq+OdfP+adRS4ntmDQT915rzDJ++i95gc2jZkM5B6LWEAwN3lGXpfie3yPABozdD3K3Vg=="],
+
+ "@js-temporal/polyfill": ["@js-temporal/polyfill@0.5.1", "", { "dependencies": { "jsbi": "^4.3.0" } }, "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ=="],
+
"@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="],
"@kilocode/cli": ["@kilocode/cli@workspace:packages/opencode"],
@@ -1332,7 +1356,7 @@
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.8", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.8", "@smithy/property-provider": "^4.2.8", "@smithy/types": "^4.12.0", "@smithy/url-parser": "^4.2.8", "tslib": "^2.6.2" } }, "sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw=="],
- "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
+ "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
"@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.8", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.8", "@smithy/types": "^4.12.0", "tslib": "^2.6.2" } }, "sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw=="],
@@ -1574,6 +1598,8 @@
"@tauri-apps/plugin-window-state": ["@tauri-apps/plugin-window-state@2.4.1", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-OuvdrzyY8Q5Dbzpj+GcrnV1iCeoZbcFdzMjanZMMcAEUNy/6PH5pxZPXpaZLOR7whlzXiuzx0L9EKZbH7zpdRw=="],
+ "@tediousjs/connection-string": ["@tediousjs/connection-string@0.5.0", "", {}, "sha512-7qSgZbincDDDFyRweCIEvZULFAw5iz/DeunhvuxpL31nfntX3P4Yd4HkHBRg9H8CdqY1e5WFN1PZIz/REL9MVQ=="],
+
"@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="],
"@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="],
@@ -1632,6 +1658,8 @@
"@types/mocha": ["@types/mocha@10.0.10", "", {}, "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q=="],
+ "@types/mssql": ["@types/mssql@9.1.9", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A=="],
+
"@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
@@ -1642,6 +1670,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+ "@types/readable-stream": ["@types/readable-stream@4.0.23", "", { "dependencies": { "@types/node": "*" } }, "sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig=="],
+
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/tunnel": ["@types/tunnel@0.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA=="],
@@ -1820,6 +1850,8 @@
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
+ "bl": ["bl@6.1.6", "", { "dependencies": { "@types/readable-stream": "^4.0.0", "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^4.2.0" } }, "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg=="],
+
"bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
@@ -2028,6 +2060,10 @@
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
+ "drizzle-kit": ["drizzle-kit@1.0.0-beta.12-a5629fb", "", { "dependencies": { "@drizzle-team/brocli": "^0.11.0", "@js-temporal/polyfill": "^0.5.1", "esbuild": "^0.25.10", "tsx": "^4.20.6" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-l+p4QOMvPGYBYEE9NBlU7diu+NSlxuOUwi0I7i01Uj1PpfU0NxhPzaks/9q1MDw4FAPP8vdD0dOhoqosKtRWWQ=="],
+
+ "drizzle-orm": ["drizzle-orm@1.0.0-beta.12-a5629fb", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql": "^0.48.5", "@effect/sql-pg": "^0.49.7", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.2.1", "@tursodatabase/database-common": ">=0.2.1", "@tursodatabase/database-wasm": ">=0.2.1", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=9.3.0", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql", "@effect/sql-pg", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-wyOAgr9Cy9oEN6z5S0JGhfipLKbRRJtQKgbDO9SXGR9swMBbGNIlXkeMqPRrqYQ8k70mh+7ZJ/eVmJ2F7zR3Vg=="],
+
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
@@ -2220,6 +2256,8 @@
"get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="],
+ "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
+
"ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
@@ -2380,12 +2418,16 @@
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
+ "js-md4": ["js-md4@0.3.2", "", {}, "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA=="],
+
"js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
+ "jsbi": ["jsbi@4.3.2", "", {}, "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew=="],
+
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
@@ -2408,6 +2450,8 @@
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
+ "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
+
"jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
@@ -2460,8 +2504,22 @@
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
+ "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
+
+ "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
+
+ "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
+
+ "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="],
+
+ "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
+
+ "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="],
+
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
+ "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
+
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
@@ -2542,12 +2600,16 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+ "mssql": ["mssql@11.0.1", "", { "dependencies": { "@tediousjs/connection-string": "^0.5.0", "commander": "^11.0.0", "debug": "^4.3.3", "rfdc": "^1.3.0", "tarn": "^3.0.2", "tedious": "^18.2.1" }, "bin": { "mssql": "bin/mssql" } }, "sha512-KlGNsugoT90enKlR8/G36H0kTxPthDhmtNUCwEHvgRza5Cjpjoj+P2X6eMpFUDN7pFrJZsKadL4x990G8RBE1w=="],
+
"multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="],
"nanoevents": ["nanoevents@7.0.1", "", {}, "sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+ "native-duplexpair": ["native-duplexpair@1.0.0", "", {}, "sha512-E7QQoM+3jvNtlmyfqRZ0/U75VFgCls+fSkbml2MpgWkWyz3ox8Y58gNhfuziuQYGNNQAbFZJQck55LHCnCK6CA=="],
+
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
@@ -2788,6 +2850,8 @@
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
+ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
+
"restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="],
"ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
@@ -2898,7 +2962,7 @@
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
- "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
+ "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="],
"sst": ["sst@3.17.23", "", { "dependencies": { "aws-sdk": "2.1692.0", "aws4fetch": "1.0.18", "jose": "5.2.3", "opencontrol": "0.0.6", "openid-client": "5.6.4" }, "optionalDependencies": { "sst-darwin-arm64": "3.17.23", "sst-darwin-x64": "3.17.23", "sst-linux-arm64": "3.17.23", "sst-linux-x64": "3.17.23", "sst-linux-x86": "3.17.23", "sst-win32-arm64": "3.17.23", "sst-win32-x64": "3.17.23", "sst-win32-x86": "3.17.23" }, "bin": { "sst": "bin/sst.mjs" } }, "sha512-TwKgUgDnZdc1Swe+bvCNeyO4dQnYz5cTodMpYj3jlXZdK9/KNz0PVxT1f0u5E76i1pmilXrUBL/f7iiMPw4RDg=="],
@@ -2976,6 +3040,10 @@
"tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="],
+ "tarn": ["tarn@3.0.2", "", {}, "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ=="],
+
+ "tedious": ["tedious@18.6.2", "", { "dependencies": { "@azure/core-auth": "^1.7.2", "@azure/identity": "^4.2.1", "@azure/keyvault-keys": "^4.4.0", "@js-joda/core": "^5.6.1", "@types/node": ">=18", "bl": "^6.0.11", "iconv-lite": "^0.6.3", "js-md4": "^0.3.2", "native-duplexpair": "^1.0.0", "sprintf-js": "^1.1.3" } }, "sha512-g7jC56o3MzLkE3lHkaFe2ZdOVFBahq5bsB60/M4NYUbocw/MCrS89IOEQUFr+ba6pb8ZHczZ/VqCyYeYq0xBAg=="],
+
"terser": ["terser@5.46.0", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg=="],
"test-exclude": ["test-exclude@7.0.1", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^9.0.4" } }, "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg=="],
@@ -3030,6 +3098,8 @@
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
+
"tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="],
"turbo": ["turbo@2.5.6", "", { "optionalDependencies": { "turbo-darwin-64": "2.5.6", "turbo-darwin-arm64": "2.5.6", "turbo-linux-64": "2.5.6", "turbo-linux-arm64": "2.5.6", "turbo-windows-64": "2.5.6", "turbo-windows-arm64": "2.5.6" }, "bin": { "turbo": "bin/turbo" } }, "sha512-gxToHmi9oTBNB05UjUsrWf0OyN5ZXtD0apOarC1KIx232Vp3WimRNy3810QzeNSgyD5rsaIDXlxlbnOzlouo+w=="],
@@ -3202,8 +3272,18 @@
"@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
+ "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
+
"@ai-sdk/amazon-bedrock/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
+ "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
+
+ "@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="],
+
+ "@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
+
+ "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
+
"@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=="],
@@ -3218,6 +3298,10 @@
"@azure/core-http/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
+ "@azure/identity/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=="],
+
+ "@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
+
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@@ -3398,8 +3482,6 @@
"@shikijs/themes/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
- "@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
-
"@storybook/addon-docs/react": ["react@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ=="],
"@storybook/addon-docs/react-dom": ["react-dom@18.2.0", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g=="],
@@ -3440,6 +3522,10 @@
"@vscode/test-cli/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
+ "ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="],
+
+ "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
+
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -3454,6 +3540,8 @@
"babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
+ "bl/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
+
"buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
@@ -3466,6 +3554,8 @@
"compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
+ "drizzle-kit/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
+
"engine.io-client/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"eslint/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
@@ -3530,6 +3620,8 @@
"mocha/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
+ "mssql/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
+
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
@@ -3602,6 +3694,8 @@
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
+ "tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
+
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"test-exclude/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
@@ -3800,6 +3894,10 @@
"@vscode/test-cli/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
+ "ai-gateway-provider/@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
+
+ "ai-gateway-provider/@ai-sdk/amazon-bedrock/aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
+
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
@@ -3820,12 +3918,66 @@
"babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
+ "bl/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
+
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
"c8/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"c8/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
+ "drizzle-kit/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
+
+ "drizzle-kit/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
+
+ "drizzle-kit/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
+
+ "drizzle-kit/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
+
+ "drizzle-kit/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
+
+ "drizzle-kit/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
+
+ "drizzle-kit/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
+
+ "drizzle-kit/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
+
+ "drizzle-kit/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
+
+ "drizzle-kit/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
+
+ "drizzle-kit/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
+
+ "drizzle-kit/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
+
+ "drizzle-kit/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
+
+ "drizzle-kit/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
+
+ "drizzle-kit/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
+
+ "drizzle-kit/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
+
+ "drizzle-kit/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
+
+ "drizzle-kit/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
+
+ "drizzle-kit/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
+
+ "drizzle-kit/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
+
+ "drizzle-kit/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
+
+ "drizzle-kit/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
+
+ "drizzle-kit/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
+
+ "drizzle-kit/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
+
+ "drizzle-kit/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
+
+ "drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
+
"eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"eslint/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
@@ -4120,6 +4272,8 @@
"c8/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+ "gray-matter/js-yaml/argparse/sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
+
"mocha/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"mocha/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
diff --git a/package.json b/package.json
index 7b9e2f75d6..9986529fa0 100644
--- a/package.json
+++ b/package.json
@@ -38,6 +38,8 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
+ "drizzle-kit": "1.0.0-beta.12-a5629fb",
+ "drizzle-orm": "1.0.0-beta.12-a5629fb",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
diff --git a/packages/app/e2e/prompt/prompt-async.spec.ts b/packages/app/e2e/prompt/prompt-async.spec.ts
new file mode 100644
index 0000000000..ce9b1a7a3b
--- /dev/null
+++ b/packages/app/e2e/prompt/prompt-async.spec.ts
@@ -0,0 +1,43 @@
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { sessionIDFromUrl } from "../actions"
+
+// Regression test for Issue #12453: the synchronous POST /message endpoint holds
+// the connection open while the agent works, causing "Failed to fetch" over
+// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
+test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
+ test.setTimeout(120_000)
+
+ // Simulate Tailscale/VPN killing the long-lived sync connection
+ await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
+
+ await gotoSession()
+
+ const token = `E2E_ASYNC_${Date.now()}`
+ await page.locator(promptSelector).click()
+ await page.keyboard.type(`Reply with exactly: ${token}`)
+ await page.keyboard.press("Enter")
+
+ await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
+ const sessionID = sessionIDFromUrl(page.url())!
+
+ try {
+ // Agent response arrives via SSE despite sync endpoint being dead
+ await expect
+ .poll(
+ async () => {
+ const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
+ return messages
+ .filter((m) => m.info.role === "assistant")
+ .flatMap((m) => m.parts)
+ .filter((p) => p.type === "text")
+ .map((p) => p.text)
+ .join("\n")
+ },
+ { timeout: 90_000 },
+ )
+ .toContain(token)
+ } finally {
+ await sdk.session.delete({ sessionID }).catch(() => undefined)
+ }
+})
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 6d10ba5300..dd22294964 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -1,5 +1,5 @@
import "@/index.css"
-import { ErrorBoundary, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
+import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
@@ -156,8 +156,11 @@ export function AppBaseProviders(props: ParentProps) {
function ServerKey(props: ParentProps) {
const server = useServer()
- if (!server.url) return null
- return props.children
+ return (
+
+ {props.children}
+
+ )
}
export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index 22363c1d6f..cb17c50a47 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -385,7 +385,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const send = async () => {
const ok = await waitForWorktree()
if (!ok) return
- await client.session.prompt({
+ await client.session.promptAsync({
sessionID: session.id,
agent,
model,
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index b85b9a536a..f81a2ec440 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -552,7 +552,7 @@ export function SessionHeader() {
-
+
{
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
+ let fitFrame: number | undefined
+ let sizeTimer: ReturnType | undefined
+ let pendingSize: { cols: number; rows: number } | undefined
+ let lastSize: { cols: number; rows: number } | undefined
let disposed = false
const cleanups: VoidFunction[] = []
const start =
@@ -209,6 +213,43 @@ export const Terminal = (props: TerminalProps) => {
const [terminalColors, setTerminalColors] = createSignal(getTerminalColors())
+ const scheduleFit = () => {
+ if (disposed) return
+ if (!fitAddon) return
+ if (fitFrame !== undefined) return
+
+ fitFrame = requestAnimationFrame(() => {
+ fitFrame = undefined
+ if (disposed) return
+ fitAddon.fit()
+ })
+ }
+
+ const scheduleSize = (cols: number, rows: number) => {
+ if (disposed) return
+ if (lastSize?.cols === cols && lastSize?.rows === rows) return
+
+ pendingSize = { cols, rows }
+
+ if (!lastSize) {
+ lastSize = pendingSize
+ void pushSize(cols, rows)
+ return
+ }
+
+ if (sizeTimer !== undefined) return
+ sizeTimer = setTimeout(() => {
+ sizeTimer = undefined
+ const next = pendingSize
+ if (!next) return
+ pendingSize = undefined
+ if (disposed) return
+ if (lastSize?.cols === next.cols && lastSize?.rows === next.rows) return
+ lastSize = next
+ void pushSize(next.cols, next.rows)
+ }, 100)
+ }
+
createEffect(() => {
const colors = getTerminalColors()
setTerminalColors(colors)
@@ -220,6 +261,16 @@ export const Terminal = (props: TerminalProps) => {
const font = monoFontFamily(settings.appearance.font())
if (!term) return
setOptionIfSupported(term, "fontFamily", font)
+ scheduleFit()
+ })
+
+ let zoom = platform.webviewZoom?.()
+ createEffect(() => {
+ const next = platform.webviewZoom?.()
+ if (next === undefined) return
+ if (next === zoom) return
+ zoom = next
+ scheduleFit()
})
const focusTerminal = () => {
@@ -263,25 +314,6 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false }
- const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
- url.searchParams.set("directory", sdk.directory)
- url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
- url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
- if (window.__KILO__?.serverPassword) {
- url.username = "opencode"
- url.password = window.__KILO__?.serverPassword
- }
- const socket = new WebSocket(url)
- socket.binaryType = "arraybuffer"
- cleanups.push(() => {
- if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
- })
- if (disposed) {
- cleanup()
- return
- }
- ws = socket
-
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize =
restore &&
@@ -344,39 +376,16 @@ export const Terminal = (props: TerminalProps) => {
focusTerminal()
- const startResize = () => {
- fit.observeResize()
- handleResize = () => fit.fit()
- window.addEventListener("resize", handleResize)
- cleanups.push(() => window.removeEventListener("resize", handleResize))
+ if (typeof document !== "undefined" && document.fonts) {
+ document.fonts.ready.then(scheduleFit)
}
- if (restore && restoreSize) {
- t.write(restore, () => {
- fit.fit()
- if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
- startResize()
- })
- } else {
- fit.fit()
- if (restore) {
- t.write(restore, () => {
- if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
- })
- }
- startResize()
- }
-
- const onResize = t.onResize(async (size) => {
- if (socket.readyState === WebSocket.OPEN) {
- await pushSize(size.cols, size.rows)
- }
+ const onResize = t.onResize((size) => {
+ scheduleSize(size.cols, size.rows)
})
cleanups.push(() => disposeIfDisposable(onResize))
const onData = t.onData((data) => {
- if (socket.readyState === WebSocket.OPEN) {
- socket.send(data)
- }
+ if (ws?.readyState === WebSocket.OPEN) ws.send(data)
})
cleanups.push(() => disposeIfDisposable(onData))
const onKey = t.onKey((key) => {
@@ -385,17 +394,64 @@ export const Terminal = (props: TerminalProps) => {
}
})
cleanups.push(() => disposeIfDisposable(onKey))
+
+ const startResize = () => {
+ fit.observeResize()
+ handleResize = scheduleFit
+ window.addEventListener("resize", handleResize)
+ cleanups.push(() => window.removeEventListener("resize", handleResize))
+ }
+
+ if (restore && restoreSize) {
+ t.write(restore, () => {
+ fit.fit()
+ scheduleSize(t.cols, t.rows)
+ if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
+ startResize()
+ })
+ } else {
+ fit.fit()
+ scheduleSize(t.cols, t.rows)
+ if (restore) {
+ t.write(restore, () => {
+ if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
+ })
+ }
+ startResize()
+ }
+
// t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
+ const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
+ url.searchParams.set("directory", sdk.directory)
+ url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
+ if (window.__KILO__?.serverPassword) {
+ url.username = "opencode"
+ url.password = window.__KILO__?.serverPassword
+ }
+ const socket = new WebSocket(url)
+ socket.binaryType = "arraybuffer"
+ ws = socket
+ cleanups.push(() => {
+ if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
+ })
+ if (disposed) {
+ cleanup()
+ return
+ }
+
const handleOpen = () => {
local.onConnect?.()
- void pushSize(t.cols, t.rows)
+ scheduleSize(t.cols, t.rows)
}
socket.addEventListener("open", handleOpen)
cleanups.push(() => socket.removeEventListener("open", handleOpen))
+ if (socket.readyState === WebSocket.OPEN) handleOpen()
+
const decoder = new TextDecoder()
const handleMessage = (event: MessageEvent) => {
@@ -462,6 +518,8 @@ export const Terminal = (props: TerminalProps) => {
onCleanup(() => {
disposed = true
+ if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
+ if (sizeTimer !== undefined) clearTimeout(sizeTimer)
output?.flush()
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
cleanup()
@@ -477,7 +535,7 @@ export const Terminal = (props: TerminalProps) => {
classList={{
...(local.classList ?? {}),
"select-text": true,
- "size-full px-6 py-3 font-mono": true,
+ "size-full px-6 py-3 font-mono relative overflow-hidden": true,
[local.class ?? ""]: !!local.class,
}}
{...others}
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
index b52e2764df..9b707fece1 100644
--- a/packages/app/src/context/global-sync/event-reducer.ts
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -231,6 +231,24 @@ export function applyDirectoryEvent(input: {
}
break
}
+ case "message.part.delta": {
+ const props = event.properties as { messageID: string; partID: string; field: string; delta: string }
+ const parts = input.store.part[props.messageID]
+ if (!parts) break
+ const result = Binary.search(parts, props.partID, (p) => p.id)
+ if (!result.found) break
+ input.setStore(
+ "part",
+ props.messageID,
+ produce((draft) => {
+ const part = draft[result.index]
+ const field = props.field as keyof typeof part
+ const existing = part[field] as string | undefined
+ ;(part[field] as string) = (existing ?? "") + props.delta
+ }),
+ )
+ break
+ }
case "vcs.branch.updated": {
const props = event.properties as { branch: string }
if (input.store.vcs?.branch === props.branch) break
diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx
index 95f3ed74dc..ddabe00b23 100644
--- a/packages/app/src/context/language.tsx
+++ b/packages/app/src/context/language.tsx
@@ -76,6 +76,10 @@ export type Locale =
type RawDictionary = typeof en & typeof uiEn & typeof kiloEn // kilocode_change
type Dictionary = i18n.Flatten
+function cookie(locale: Locale) {
+ return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
+}
+
const LOCALES: readonly Locale[] = [
"en",
"zh",
@@ -218,6 +222,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
createEffect(() => {
if (typeof document !== "object") return
document.documentElement.lang = locale()
+ document.cookie = cookie(locale())
})
return {
diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts
index ce435113c8..aa54f1be9f 100644
--- a/packages/app/src/i18n/ar.ts
+++ b/packages/app/src/i18n/ar.ts
@@ -132,7 +132,8 @@ export const dict = {
"provider.connect.oauth.code.invalid": "رمز التفويض غير صالح",
"provider.connect.oauth.auto.visit.prefix": "قم بزيارة ",
"provider.connect.oauth.auto.visit.link": "هذا الرابط",
- "provider.connect.oauth.auto.visit.suffix": " وأدخل الرمز أدناه لتوصيل حسابك واستخدام نماذج {{provider}} في Kilo.",
+ "provider.connect.oauth.auto.visit.suffix":
+ " وأدخل الرمز أدناه لتوصيل حسابك واستخدام نماذج {{provider}} في Kilo.",
"provider.connect.oauth.auto.confirmationCode": "رمز التأكيد",
"provider.connect.toast.connected.title": "تم توصيل {{provider}}",
"provider.connect.toast.connected.description": "نماذج {{provider}} متاحة الآن للاستخدام.",
diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts
index 2feb0d5bde..f7bf84e1c5 100644
--- a/packages/app/src/i18n/fr.ts
+++ b/packages/app/src/i18n/fr.ts
@@ -385,7 +385,8 @@ export const dict = {
"toast.session.unshare.failed.description": "Une erreur s'est produite lors de l'annulation du partage de la session",
"toast.session.listFailed.title": "Échec du chargement des sessions pour {{project}}",
"toast.update.title": "Mise à jour disponible",
- "toast.update.description": "Une nouvelle version d'Kilo ({{version}}) est maintenant disponible pour installation.",
+ "toast.update.description":
+ "Une nouvelle version d'Kilo ({{version}}) est maintenant disponible pour installation.",
"toast.update.action.installRestart": "Installer et redémarrer",
"toast.update.action.notYet": "Pas encore",
"error.page.title": "Quelque chose s'est mal passé",
@@ -516,7 +517,8 @@ export const dict = {
"sidebar.workspaces.enable": "Activer les espaces de travail",
"sidebar.workspaces.disable": "Désactiver les espaces de travail",
"sidebar.gettingStarted.title": "Commencer",
- "sidebar.gettingStarted.line1": "Kilo inclut des modèles gratuits pour que vous puissiez commencer immédiatement.",
+ "sidebar.gettingStarted.line1":
+ "Kilo inclut des modèles gratuits pour que vous puissiez commencer immédiatement.",
"sidebar.gettingStarted.line2":
"Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sessions récentes",
diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts
index c595b67d27..8de06487ea 100644
--- a/packages/app/src/i18n/th.ts
+++ b/packages/app/src/i18n/th.ts
@@ -144,7 +144,8 @@ export const dict = {
"provider.connect.oauth.code.invalid": "รหัสการอนุญาตไม่ถูกต้อง",
"provider.connect.oauth.auto.visit.prefix": "เยี่ยมชม ",
"provider.connect.oauth.auto.visit.link": "ลิงก์นี้",
- "provider.connect.oauth.auto.visit.suffix": " และป้อนรหัสด้านล่างเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน Kilo",
+ "provider.connect.oauth.auto.visit.suffix":
+ " และป้อนรหัสด้านล่างเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน Kilo",
"provider.connect.oauth.auto.confirmationCode": "รหัสยืนยัน",
"provider.connect.toast.connected.title": "{{provider}} ที่เชื่อมต่อแล้ว",
"provider.connect.toast.connected.description": "โมเดล {{provider}} พร้อมใช้งานแล้ว",
diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts
index 741b32464a..d8cb85408d 100644
--- a/packages/app/src/i18n/zht.ts
+++ b/packages/app/src/i18n/zht.ts
@@ -145,7 +145,8 @@ export const dict = {
"provider.connect.oauth.code.invalid": "授權碼無效",
"provider.connect.oauth.auto.visit.prefix": "造訪 ",
"provider.connect.oauth.auto.visit.link": "此連結",
- "provider.connect.oauth.auto.visit.suffix": " 並輸入以下程式碼,以連線你的帳戶並在 Kilo 中使用 {{provider}} 模型。",
+ "provider.connect.oauth.auto.visit.suffix":
+ " 並輸入以下程式碼,以連線你的帳戶並在 Kilo 中使用 {{provider}} 模型。",
"provider.connect.oauth.auto.confirmationCode": "確認碼",
"provider.connect.toast.connected.title": "{{provider}} 已連線",
"provider.connect.toast.connected.description": "現在可以使用 {{provider}} 模型了。",
diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx
index 3b8f0c3c8d..9bbb58f3fb 100644
--- a/packages/app/src/pages/layout/sidebar-items.tsx
+++ b/packages/app/src/pages/layout/sidebar-items.tsx
@@ -32,7 +32,9 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
0 && props.notify }}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 989459e1a4..cef0d1dafd 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -1860,7 +1860,7 @@ export default function Page() {
state()?.content?.content ?? "")
- const cacheKey = createMemo(() => checksum(contents()))
+ const cacheKey = createMemo(() => sampledChecksum(contents()))
const isImage = createMemo(() => {
const c = state()?.content
return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
@@ -163,11 +163,20 @@ export function FileTabContent(props: {
return
}
+ const estimateTop = (range: SelectedLineRange) => {
+ const line = Math.max(range.start, range.end)
+ const height = 24
+ const offset = 2
+ return Math.max(0, (line - 1) * height + offset)
+ }
+
+ const large = contents().length > 500_000
+
const next: Record = {}
for (const comment of fileComments()) {
const marker = findMarker(root, comment.selection)
- if (!marker) continue
- next[comment.id] = markerTop(el, marker)
+ if (marker) next[comment.id] = markerTop(el, marker)
+ else if (large) next[comment.id] = estimateTop(comment.selection)
}
const removed = Object.keys(note.positions).filter((id) => next[id] === undefined)
@@ -194,12 +203,12 @@ export function FileTabContent(props: {
}
const marker = findMarker(root, range)
- if (!marker) {
- setNote("draftTop", undefined)
+ if (marker) {
+ setNote("draftTop", markerTop(el, marker))
return
}
- setNote("draftTop", markerTop(el, marker))
+ setNote("draftTop", large ? estimateTop(range) : undefined)
}
const scheduleComments = () => {
diff --git a/packages/desktop/src-tauri/build.rs b/packages/desktop/src-tauri/build.rs
index 5c653b58e3..d860e1e6a7 100644
--- a/packages/desktop/src-tauri/build.rs
+++ b/packages/desktop/src-tauri/build.rs
@@ -1,10 +1,3 @@
fn main() {
- if let Ok(git_ref) = std::env::var("GITHUB_REF") {
- let branch = git_ref.strip_prefix("refs/heads/").unwrap_or(&git_ref);
- if branch == "beta" {
- println!("cargo:rustc-env=KILO_SQLITE=1");
- }
- }
-
tauri_build::build()
}
diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs
index c006359c32..9bbf93da5a 100644
--- a/packages/desktop/src-tauri/src/lib.rs
+++ b/packages/desktop/src-tauri/src/lib.rs
@@ -566,7 +566,7 @@ async fn initialize(app: AppHandle) {
// come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor.
// Then in the loading task, we wait for sqlite migration to complete before
// starting our health check against the server, otherwise long migrations could result in a timeout.
- let needs_sqlite_migration = option_env!("KILO_SQLITE").is_some() && !sqlite_file_exists();
+ let needs_sqlite_migration = !sqlite_file_exists();
let sqlite_done = needs_sqlite_migration.then(|| {
tracing::info!(
path = %opencode_db_path().expect("failed to get db path").display(),
diff --git a/packages/opencode/bunfig.toml b/packages/opencode/bunfig.toml
index c4617527d0..c3b7270764 100644
--- a/packages/opencode/bunfig.toml
+++ b/packages/opencode/bunfig.toml
@@ -2,4 +2,6 @@ preload = ["@opentui/solid/preload"]
[test]
preload = ["./test/preload.ts"]
-timeout = 30000 # 30 seconds - allow time for package installation
+# timeout is not actually parsed from bunfig.toml (see src/bunfig.zig in oven-sh/bun)
+# using --timeout in package.json scripts instead
+# https://github.com/oven-sh/bun/issues/7789
diff --git a/packages/opencode/drizzle.config.ts b/packages/opencode/drizzle.config.ts
new file mode 100644
index 0000000000..1b4fd556e9
--- /dev/null
+++ b/packages/opencode/drizzle.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "drizzle-kit"
+
+export default defineConfig({
+ dialect: "sqlite",
+ schema: "./src/**/*.sql.ts",
+ out: "./migration",
+ dbCredentials: {
+ url: "/home/thdxr/.local/share/opencode/opencode.db",
+ },
+})
diff --git a/packages/opencode/migration/20260127222353_familiar_lady_ursula/migration.sql b/packages/opencode/migration/20260127222353_familiar_lady_ursula/migration.sql
new file mode 100644
index 0000000000..775c1a1173
--- /dev/null
+++ b/packages/opencode/migration/20260127222353_familiar_lady_ursula/migration.sql
@@ -0,0 +1,90 @@
+CREATE TABLE `project` (
+ `id` text PRIMARY KEY,
+ `worktree` text NOT NULL,
+ `vcs` text,
+ `name` text,
+ `icon_url` text,
+ `icon_color` text,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ `time_initialized` integer,
+ `sandboxes` text NOT NULL
+);
+--> statement-breakpoint
+CREATE TABLE `message` (
+ `id` text PRIMARY KEY,
+ `session_id` text NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ `data` text NOT NULL,
+ CONSTRAINT `fk_message_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE TABLE `part` (
+ `id` text PRIMARY KEY,
+ `message_id` text NOT NULL,
+ `session_id` text NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ `data` text NOT NULL,
+ CONSTRAINT `fk_part_message_id_message_id_fk` FOREIGN KEY (`message_id`) REFERENCES `message`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE TABLE `permission` (
+ `project_id` text PRIMARY KEY,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ `data` text NOT NULL,
+ CONSTRAINT `fk_permission_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE TABLE `session` (
+ `id` text PRIMARY KEY,
+ `project_id` text NOT NULL,
+ `parent_id` text,
+ `slug` text NOT NULL,
+ `directory` text NOT NULL,
+ `title` text NOT NULL,
+ `version` text NOT NULL,
+ `share_url` text,
+ `summary_additions` integer,
+ `summary_deletions` integer,
+ `summary_files` integer,
+ `summary_diffs` text,
+ `revert` text,
+ `permission` text,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ `time_compacting` integer,
+ `time_archived` integer,
+ CONSTRAINT `fk_session_project_id_project_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE TABLE `todo` (
+ `session_id` text NOT NULL,
+ `content` text NOT NULL,
+ `status` text NOT NULL,
+ `priority` text NOT NULL,
+ `position` integer NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ CONSTRAINT `todo_pk` PRIMARY KEY(`session_id`, `position`),
+ CONSTRAINT `fk_todo_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE TABLE `session_share` (
+ `session_id` text PRIMARY KEY,
+ `id` text NOT NULL,
+ `secret` text NOT NULL,
+ `url` text NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ CONSTRAINT `fk_session_share_session_id_session_id_fk` FOREIGN KEY (`session_id`) REFERENCES `session`(`id`) ON DELETE CASCADE
+);
+--> statement-breakpoint
+CREATE INDEX `message_session_idx` ON `message` (`session_id`);--> statement-breakpoint
+CREATE INDEX `part_message_idx` ON `part` (`message_id`);--> statement-breakpoint
+CREATE INDEX `part_session_idx` ON `part` (`session_id`);--> statement-breakpoint
+CREATE INDEX `session_project_idx` ON `session` (`project_id`);--> statement-breakpoint
+CREATE INDEX `session_parent_idx` ON `session` (`parent_id`);--> statement-breakpoint
+CREATE INDEX `todo_session_idx` ON `todo` (`session_id`);
\ No newline at end of file
diff --git a/packages/opencode/migration/20260127222353_familiar_lady_ursula/snapshot.json b/packages/opencode/migration/20260127222353_familiar_lady_ursula/snapshot.json
new file mode 100644
index 0000000000..ff76ee209a
--- /dev/null
+++ b/packages/opencode/migration/20260127222353_familiar_lady_ursula/snapshot.json
@@ -0,0 +1,796 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "068758ed-a97a-46f6-8a59-6c639ae7c20c",
+ "prevIds": ["00000000-0000-0000-0000-000000000000"],
+ "ddl": [
+ {
+ "name": "project",
+ "entityType": "tables"
+ },
+ {
+ "name": "message",
+ "entityType": "tables"
+ },
+ {
+ "name": "part",
+ "entityType": "tables"
+ },
+ {
+ "name": "permission",
+ "entityType": "tables"
+ },
+ {
+ "name": "session",
+ "entityType": "tables"
+ },
+ {
+ "name": "todo",
+ "entityType": "tables"
+ },
+ {
+ "name": "session_share",
+ "entityType": "tables"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "worktree",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "vcs",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_url",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_color",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_initialized",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "sandboxes",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "message_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "parent_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "slug",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "directory",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "title",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "version",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "share_url",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_additions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_deletions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_files",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_diffs",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "revert",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "permission",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_compacting",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_archived",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "content",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "priority",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "position",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "secret",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "url",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_message_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "message"
+ },
+ {
+ "columns": ["message_id"],
+ "tableTo": "message",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_part_message_id_message_id_fk",
+ "entityType": "fks",
+ "table": "part"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_permission_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "permission"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "session"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_todo_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "todo"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_share_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "session_share"
+ },
+ {
+ "columns": ["session_id", "position"],
+ "nameExplicit": false,
+ "name": "todo_pk",
+ "entityType": "pks",
+ "table": "todo"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "project_pk",
+ "table": "project",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "message_pk",
+ "table": "message",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "part_pk",
+ "table": "part",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["project_id"],
+ "nameExplicit": false,
+ "name": "permission_pk",
+ "table": "permission",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "session_pk",
+ "table": "session",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["session_id"],
+ "nameExplicit": false,
+ "name": "session_share_pk",
+ "table": "session_share",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "message_session_idx",
+ "entityType": "indexes",
+ "table": "message"
+ },
+ {
+ "columns": [
+ {
+ "value": "message_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_message_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_session_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "project_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_project_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "parent_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_parent_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "todo_session_idx",
+ "entityType": "indexes",
+ "table": "todo"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/opencode/migration/20260211171708_add_project_commands/migration.sql b/packages/opencode/migration/20260211171708_add_project_commands/migration.sql
new file mode 100644
index 0000000000..b63f147a0b
--- /dev/null
+++ b/packages/opencode/migration/20260211171708_add_project_commands/migration.sql
@@ -0,0 +1 @@
+ALTER TABLE `project` ADD `commands` text;
\ No newline at end of file
diff --git a/packages/opencode/migration/20260211171708_add_project_commands/snapshot.json b/packages/opencode/migration/20260211171708_add_project_commands/snapshot.json
new file mode 100644
index 0000000000..1182cc32de
--- /dev/null
+++ b/packages/opencode/migration/20260211171708_add_project_commands/snapshot.json
@@ -0,0 +1,806 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "8bc2d11d-97fa-4ba8-8bfa-6c5956c49aeb",
+ "prevIds": ["068758ed-a97a-46f6-8a59-6c639ae7c20c"],
+ "ddl": [
+ {
+ "name": "project",
+ "entityType": "tables"
+ },
+ {
+ "name": "message",
+ "entityType": "tables"
+ },
+ {
+ "name": "part",
+ "entityType": "tables"
+ },
+ {
+ "name": "permission",
+ "entityType": "tables"
+ },
+ {
+ "name": "session",
+ "entityType": "tables"
+ },
+ {
+ "name": "todo",
+ "entityType": "tables"
+ },
+ {
+ "name": "session_share",
+ "entityType": "tables"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "worktree",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "vcs",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_url",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_color",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_initialized",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "sandboxes",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "commands",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "message_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "parent_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "slug",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "directory",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "title",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "version",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "share_url",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_additions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_deletions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_files",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_diffs",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "revert",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "permission",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_compacting",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_archived",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "content",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "priority",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "position",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "secret",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "url",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_message_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "message"
+ },
+ {
+ "columns": ["message_id"],
+ "tableTo": "message",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_part_message_id_message_id_fk",
+ "entityType": "fks",
+ "table": "part"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_permission_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "permission"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "session"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_todo_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "todo"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_share_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "session_share"
+ },
+ {
+ "columns": ["session_id", "position"],
+ "nameExplicit": false,
+ "name": "todo_pk",
+ "entityType": "pks",
+ "table": "todo"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "project_pk",
+ "table": "project",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "message_pk",
+ "table": "message",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "part_pk",
+ "table": "part",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["project_id"],
+ "nameExplicit": false,
+ "name": "permission_pk",
+ "table": "permission",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "session_pk",
+ "table": "session",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["session_id"],
+ "nameExplicit": false,
+ "name": "session_share_pk",
+ "table": "session_share",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "message_session_idx",
+ "entityType": "indexes",
+ "table": "message"
+ },
+ {
+ "columns": [
+ {
+ "value": "message_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_message_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_session_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "project_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_project_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "parent_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_parent_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "todo_session_idx",
+ "entityType": "indexes",
+ "table": "todo"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/opencode/migration/20260213144116_wakeful_the_professor/migration.sql b/packages/opencode/migration/20260213144116_wakeful_the_professor/migration.sql
new file mode 100644
index 0000000000..3085fe280f
--- /dev/null
+++ b/packages/opencode/migration/20260213144116_wakeful_the_professor/migration.sql
@@ -0,0 +1,11 @@
+CREATE TABLE `control_account` (
+ `email` text NOT NULL,
+ `url` text NOT NULL,
+ `access_token` text NOT NULL,
+ `refresh_token` text NOT NULL,
+ `token_expiry` integer,
+ `active` integer NOT NULL,
+ `time_created` integer NOT NULL,
+ `time_updated` integer NOT NULL,
+ CONSTRAINT `control_account_pk` PRIMARY KEY(`email`, `url`)
+);
diff --git a/packages/opencode/migration/20260213144116_wakeful_the_professor/snapshot.json b/packages/opencode/migration/20260213144116_wakeful_the_professor/snapshot.json
new file mode 100644
index 0000000000..05c00a10cf
--- /dev/null
+++ b/packages/opencode/migration/20260213144116_wakeful_the_professor/snapshot.json
@@ -0,0 +1,897 @@
+{
+ "version": "7",
+ "dialect": "sqlite",
+ "id": "d2736e43-700f-4e9e-8151-9f2f0d967bc8",
+ "prevIds": ["8bc2d11d-97fa-4ba8-8bfa-6c5956c49aeb"],
+ "ddl": [
+ {
+ "name": "control_account",
+ "entityType": "tables"
+ },
+ {
+ "name": "project",
+ "entityType": "tables"
+ },
+ {
+ "name": "message",
+ "entityType": "tables"
+ },
+ {
+ "name": "part",
+ "entityType": "tables"
+ },
+ {
+ "name": "permission",
+ "entityType": "tables"
+ },
+ {
+ "name": "session",
+ "entityType": "tables"
+ },
+ {
+ "name": "todo",
+ "entityType": "tables"
+ },
+ {
+ "name": "session_share",
+ "entityType": "tables"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "email",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "url",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "access_token",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "refresh_token",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "token_expiry",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "active",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "control_account"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "worktree",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "vcs",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "name",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_url",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "icon_color",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_initialized",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "sandboxes",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "commands",
+ "entityType": "columns",
+ "table": "project"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "message"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "message_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "part"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "data",
+ "entityType": "columns",
+ "table": "permission"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "project_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "parent_id",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "slug",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "directory",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "title",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "version",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "share_url",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_additions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_deletions",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_files",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "summary_diffs",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "revert",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "permission",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_compacting",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "integer",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_archived",
+ "entityType": "columns",
+ "table": "session"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "content",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "status",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "priority",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "position",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "todo"
+ },
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "session_id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "id",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "secret",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "text",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "url",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_created",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "type": "integer",
+ "notNull": true,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "time_updated",
+ "entityType": "columns",
+ "table": "session_share"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_message_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "message"
+ },
+ {
+ "columns": ["message_id"],
+ "tableTo": "message",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_part_message_id_message_id_fk",
+ "entityType": "fks",
+ "table": "part"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_permission_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "permission"
+ },
+ {
+ "columns": ["project_id"],
+ "tableTo": "project",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_project_id_project_id_fk",
+ "entityType": "fks",
+ "table": "session"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_todo_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "todo"
+ },
+ {
+ "columns": ["session_id"],
+ "tableTo": "session",
+ "columnsTo": ["id"],
+ "onUpdate": "NO ACTION",
+ "onDelete": "CASCADE",
+ "nameExplicit": false,
+ "name": "fk_session_share_session_id_session_id_fk",
+ "entityType": "fks",
+ "table": "session_share"
+ },
+ {
+ "columns": ["email", "url"],
+ "nameExplicit": false,
+ "name": "control_account_pk",
+ "entityType": "pks",
+ "table": "control_account"
+ },
+ {
+ "columns": ["session_id", "position"],
+ "nameExplicit": false,
+ "name": "todo_pk",
+ "entityType": "pks",
+ "table": "todo"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "project_pk",
+ "table": "project",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "message_pk",
+ "table": "message",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "part_pk",
+ "table": "part",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["project_id"],
+ "nameExplicit": false,
+ "name": "permission_pk",
+ "table": "permission",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["id"],
+ "nameExplicit": false,
+ "name": "session_pk",
+ "table": "session",
+ "entityType": "pks"
+ },
+ {
+ "columns": ["session_id"],
+ "nameExplicit": false,
+ "name": "session_share_pk",
+ "table": "session_share",
+ "entityType": "pks"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "message_session_idx",
+ "entityType": "indexes",
+ "table": "message"
+ },
+ {
+ "columns": [
+ {
+ "value": "message_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_message_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "part_session_idx",
+ "entityType": "indexes",
+ "table": "part"
+ },
+ {
+ "columns": [
+ {
+ "value": "project_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_project_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "parent_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "session_parent_idx",
+ "entityType": "indexes",
+ "table": "session"
+ },
+ {
+ "columns": [
+ {
+ "value": "session_id",
+ "isExpression": false
+ }
+ ],
+ "isUnique": false,
+ "where": null,
+ "origin": "manual",
+ "name": "todo_session_idx",
+ "entityType": "indexes",
+ "table": "todo"
+ }
+ ],
+ "renames": []
+}
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index d60bc8c308..4eddd59b66 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -7,7 +7,7 @@
"private": true,
"scripts": {
"typecheck": "tsgo --noEmit",
- "test": "bun test",
+ "test": "bun test --timeout 30000",
"build": "bun run script/build.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
@@ -15,11 +15,11 @@
"lint": "echo 'Running lint checks...' && bun test --coverage",
"format": "echo 'Formatting code...' && bun run --prettier --write src/**/*.ts",
"docs": "echo 'Generating documentation...' && find src -name '*.ts' -exec echo 'Processing: {}' \\;",
- "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'"
+ "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'",
+ "db": "bun drizzle-kit"
},
"bin": {
- "kilo": "./bin/kilo",
- "kilocode": "./bin/kilocode"
+ "opencode": "./bin/opencode"
},
"randomField": "this-is-a-random-value-12345",
"exports": {
@@ -43,6 +43,8 @@
"@types/turndown": "5.0.5",
"@types/yargs": "17.0.33",
"@typescript/native-preview": "catalog:",
+ "drizzle-kit": "1.0.0-beta.12-a5629fb",
+ "drizzle-orm": "1.0.0-beta.12-a5629fb",
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
@@ -52,12 +54,12 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.14.1",
- "@ai-sdk/amazon-bedrock": "3.0.74",
- "@ai-sdk/anthropic": "2.0.58",
+ "@ai-sdk/amazon-bedrock": "3.0.79",
+ "@ai-sdk/anthropic": "2.0.62",
"@ai-sdk/azure": "2.0.91",
"@ai-sdk/cerebras": "1.0.36",
"@ai-sdk/cohere": "2.0.22",
- "@ai-sdk/deepinfra": "1.0.33",
+ "@ai-sdk/deepinfra": "1.0.36",
"@ai-sdk/gateway": "2.0.30",
"@ai-sdk/google": "2.0.52",
"@ai-sdk/google-vertex": "3.0.98",
@@ -76,15 +78,13 @@
"@gitlab/opencode-gitlab-auth": "1.3.2",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
- "@kilocode/kilo-gateway": "workspace:*",
- "@kilocode/kilo-telemetry": "workspace:*",
- "@kilocode/plugin": "workspace:*",
- "@kilocode/sdk": "workspace:*",
"@modelcontextprotocol/sdk": "1.25.2",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
+ "@kilocode/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
+ "@kilocode/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.79",
@@ -103,6 +103,7 @@
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
"diff": "catalog:",
+ "drizzle-orm": "1.0.0-beta.12-a5629fb",
"fuzzysort": "3.1.0",
"gray-matter": "4.0.3",
"hono": "catalog:",
@@ -114,7 +115,6 @@
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
"remeda": "catalog:",
- "simple-git": "3.31.1",
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
@@ -125,7 +125,13 @@
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
- "zod-to-json-schema": "3.24.5"
+ "zod-to-json-schema": "3.24.5",
+ "@kilocode/kilo-gateway": "workspace:*",
+ "@kilocode/kilo-telemetry": "workspace:*",
+ "simple-git": "3.31.1"
+ },
+ "overrides": {
+ "drizzle-orm": "1.0.0-beta.12-a5629fb"
},
"peerDependencies": {}
}
diff --git a/packages/opencode/script/check-migrations.ts b/packages/opencode/script/check-migrations.ts
new file mode 100644
index 0000000000..f5eaf79323
--- /dev/null
+++ b/packages/opencode/script/check-migrations.ts
@@ -0,0 +1,16 @@
+#!/usr/bin/env bun
+
+import { $ } from "bun"
+
+// drizzle-kit check compares schema to migrations, exits non-zero if drift
+const result = await $`bun drizzle-kit check`.quiet().nothrow()
+
+if (result.exitCode !== 0) {
+ console.error("Schema has changes not captured in migrations!")
+ console.error("Run: bun drizzle-kit generate")
+ console.error("")
+ console.error(result.stderr.toString())
+ process.exit(1)
+}
+
+console.log("Migrations are up to date")
diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts
index 6e4ffcef99..270a1dae50 100644
--- a/packages/opencode/src/acp/agent.ts
+++ b/packages/opencode/src/acp/agent.ts
@@ -437,46 +437,68 @@ export namespace ACP {
return
}
}
+ return
+ }
- if (part.type === "text") {
- const delta = props.delta
- if (delta && part.ignored !== true) {
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "agent_message_chunk",
- content: {
- type: "text",
- text: delta,
- },
+ case "message.part.delta": {
+ const props = event.properties
+ const session = this.sessionManager.tryGet(props.sessionID)
+ if (!session) return
+ const sessionId = session.id
+
+ const message = await this.sdk.session
+ .message(
+ {
+ sessionID: props.sessionID,
+ messageID: props.messageID,
+ directory: session.cwd,
+ },
+ { throwOnError: true },
+ )
+ .then((x) => x.data)
+ .catch((error) => {
+ log.error("unexpected error when fetching message", { error })
+ return undefined
+ })
+
+ if (!message || message.info.role !== "assistant") return
+
+ const part = message.parts.find((p) => p.id === props.partID)
+ if (!part) return
+
+ if (part.type === "text" && props.field === "text" && part.ignored !== true) {
+ await this.connection
+ .sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "agent_message_chunk",
+ content: {
+ type: "text",
+ text: props.delta,
},
- })
- .catch((error) => {
- log.error("failed to send text to ACP", { error })
- })
- }
+ },
+ })
+ .catch((error) => {
+ log.error("failed to send text delta to ACP", { error })
+ })
return
}
- if (part.type === "reasoning") {
- const delta = props.delta
- if (delta) {
- await this.connection
- .sessionUpdate({
- sessionId,
- update: {
- sessionUpdate: "agent_thought_chunk",
- content: {
- type: "text",
- text: delta,
- },
+ if (part.type === "reasoning" && props.field === "text") {
+ await this.connection
+ .sessionUpdate({
+ sessionId,
+ update: {
+ sessionUpdate: "agent_thought_chunk",
+ content: {
+ type: "text",
+ text: props.delta,
},
- })
- .catch((error) => {
- log.error("failed to send reasoning to ACP", { error })
- })
- }
+ },
+ })
+ .catch((error) => {
+ log.error("failed to send reasoning delta to ACP", { error })
+ })
}
return
}
diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts
index 9968e70302..78040f0815 100644
--- a/packages/opencode/src/cli/cmd/import.ts
+++ b/packages/opencode/src/cli/cmd/import.ts
@@ -3,7 +3,8 @@ import type { Session as SDKSession, Message, Part } from "@kilocode/sdk/v2"
import { Session } from "../../session"
import { cmd } from "./cmd"
import { bootstrap } from "../bootstrap"
-import { Storage } from "../../storage/storage"
+import { Database } from "../../storage/db"
+import { SessionTable, MessageTable, PartTable } from "../../session/session.sql"
import { Instance } from "../../project/instance"
import { ShareNext } from "../../share/share-next"
import { EOL } from "os"
@@ -130,13 +131,35 @@ export const ImportCommand = cmd({
return
}
- await Storage.write(["session", Instance.project.id, exportData.info.id], exportData.info)
+ Database.use((db) => db.insert(SessionTable).values(Session.toRow(exportData.info)).onConflictDoNothing().run())
for (const msg of exportData.messages) {
- await Storage.write(["message", exportData.info.id, msg.info.id], msg.info)
+ Database.use((db) =>
+ db
+ .insert(MessageTable)
+ .values({
+ id: msg.info.id,
+ session_id: exportData.info.id,
+ time_created: msg.info.time?.created ?? Date.now(),
+ data: msg.info,
+ })
+ .onConflictDoNothing()
+ .run(),
+ )
for (const part of msg.parts) {
- await Storage.write(["part", msg.info.id, part.id], part)
+ Database.use((db) =>
+ db
+ .insert(PartTable)
+ .values({
+ id: part.id,
+ message_id: msg.info.id,
+ session_id: exportData.info.id,
+ data: part,
+ })
+ .onConflictDoNothing()
+ .run(),
+ )
}
}
diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts
index 28087bffeb..8db43f9f6a 100644
--- a/packages/opencode/src/cli/cmd/run.ts
+++ b/packages/opencode/src/cli/cmd/run.ts
@@ -275,6 +275,10 @@ export const RunCommand = cmd({
type: "string",
describe: "attach to a running opencode server (e.g., http://localhost:4096)",
})
+ .option("dir", {
+ type: "string",
+ describe: "directory to run in, path on remote server if attaching",
+ })
.option("port", {
type: "number",
describe: "port for the local server (defaults to random port if no value provided)",
@@ -294,14 +298,26 @@ export const RunCommand = cmd({
describe: "auto-approve all permissions (for autonomous/pipeline usage)",
default: false,
})
- // kilocode_change end
)
+ // kilocode_change end
},
handler: async (args) => {
let message = [...args.message, ...(args["--"] || [])]
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
.join(" ")
+ const directory = (() => {
+ if (!args.dir) return undefined
+ if (args.attach) return args.dir
+ try {
+ process.chdir(args.dir)
+ return process.cwd()
+ } catch {
+ UI.error("Failed to change directory to " + args.dir)
+ process.exit(1)
+ }
+ })()
+
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
if (args.file) {
const list = Array.isArray(args.file) ? args.file : [args.file]
@@ -399,20 +415,24 @@ export const RunCommand = cmd({
async function execute(sdk: OpencodeClient) {
function tool(part: ToolPart) {
- if (part.tool === "bash") return bash(props(part))
- if (part.tool === "glob") return glob(props(part))
- if (part.tool === "grep") return grep(props(part))
- if (part.tool === "list") return list(props(part))
- if (part.tool === "read") return read(props(part))
- if (part.tool === "write") return write(props(part))
- if (part.tool === "webfetch") return webfetch(props(part))
- if (part.tool === "edit") return edit(props(part))
- if (part.tool === "codesearch") return codesearch(props(part))
- if (part.tool === "websearch") return websearch(props(part))
- if (part.tool === "task") return task(props(part))
- if (part.tool === "todowrite") return todo(props(part))
- if (part.tool === "skill") return skill(props(part))
- return fallback(part)
+ try {
+ if (part.tool === "bash") return bash(props(part))
+ if (part.tool === "glob") return glob(props(part))
+ if (part.tool === "grep") return grep(props(part))
+ if (part.tool === "list") return list(props(part))
+ if (part.tool === "read") return read(props(part))
+ if (part.tool === "write") return write(props(part))
+ if (part.tool === "webfetch") return webfetch(props(part))
+ if (part.tool === "edit") return edit(props(part))
+ if (part.tool === "codesearch") return codesearch(props(part))
+ if (part.tool === "websearch") return websearch(props(part))
+ if (part.tool === "task") return task(props(part))
+ if (part.tool === "todowrite") return todo(props(part))
+ if (part.tool === "skill") return skill(props(part))
+ return fallback(part)
+ } catch {
+ return fallback(part)
+ }
}
function emit(type: string, data: Record) {
@@ -603,7 +623,7 @@ export const RunCommand = cmd({
}
if (args.attach) {
- const sdk = createOpencodeClient({ baseUrl: args.attach })
+ const sdk = createOpencodeClient({ baseUrl: args.attach, directory })
return await execute(sdk)
}
diff --git a/packages/opencode/src/cli/cmd/stats.ts b/packages/opencode/src/cli/cmd/stats.ts
index 9239bb90a6..04c1fe2ebc 100644
--- a/packages/opencode/src/cli/cmd/stats.ts
+++ b/packages/opencode/src/cli/cmd/stats.ts
@@ -2,7 +2,8 @@ import type { Argv } from "yargs"
import { cmd } from "./cmd"
import { Session } from "../../session"
import { bootstrap } from "../bootstrap"
-import { Storage } from "../../storage/storage"
+import { Database } from "../../storage/db"
+import { SessionTable } from "../../session/session.sql"
import { Project } from "../../project/project"
import { Instance } from "../../project/instance"
@@ -87,25 +88,8 @@ async function getCurrentProject(): Promise {
}
async function getAllSessions(): Promise {
- const sessions: Session.Info[] = []
-
- const projectKeys = await Storage.list(["project"])
- const projects = await Promise.all(projectKeys.map((key) => Storage.read(key)))
-
- for (const project of projects) {
- if (!project) continue
-
- const sessionKeys = await Storage.list(["session", project.id])
- const projectSessions = await Promise.all(sessionKeys.map((key) => Storage.read(key)))
-
- for (const session of projectSessions) {
- if (session) {
- sessions.push(session)
- }
- }
- }
-
- return sessions
+ const rows = Database.use((db) => db.select().from(SessionTable).all())
+ return rows.map((row) => Session.fromRow(row))
}
export async function aggregateSessionStats(days?: number, projectFilter?: string): Promise {
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index ba03391a6d..87cf87719c 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -299,6 +299,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
+ case "message.part.delta": {
+ const parts = store.part[event.properties.messageID]
+ if (!parts) break
+ const result = Binary.search(parts, event.properties.partID, (p) => p.id)
+ if (!result.found) break
+ setStore(
+ "part",
+ event.properties.messageID,
+ produce((draft) => {
+ const part = draft[result.index]
+ const field = event.properties.field as keyof typeof part
+ const existing = part[field] as string | undefined
+ ;(part[field] as string) = (existing ?? "") + event.properties.delta
+ }),
+ )
+ break
+ }
+
case "message.part.removed": {
const parts = store.part[event.properties.messageID]
const result = Binary.search(parts, event.properties.partID, (p) => p.id)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 54f152db7e..299d515ed3 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -2059,8 +2059,8 @@ function ApplyPatch(props: ToolProps) {
-
- apply_patch
+
+ Patch
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 063fa5b780..d649ad5420 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -31,6 +31,7 @@ import { Event } from "../server/event"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
+import { Control } from "@/control"
import { ModesMigrator } from "../kilocode/modes-migrator" // kilocode_change
import { RulesMigrator } from "../kilocode/rules-migrator" // kilocode_change
@@ -56,10 +57,23 @@ export namespace Config {
}
}
+ // kilocode_change start: Custom merge function that concatenates array fields instead of replacing them
+ function mergeConfigConcatArrays(target: Info, source: Info): Info {
+ const merged = mergeDeep(target, source)
+ if (target.plugin && source.plugin) {
+ merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
+ }
+ if (target.instructions && source.instructions) {
+ merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
+ }
+ return merged
+ }
+ // kilocode_change end
+
const managedConfigDir = process.env.KILO_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir()
// Custom merge function that concatenates array fields instead of replacing them
- function mergeConfigConcatArrays(target: Info, source: Info): Info {
+ function merge(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) {
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
@@ -174,20 +188,21 @@ export namespace Config {
const remoteConfig = wellknown.config ?? {}
// Add $schema to prevent load() from trying to write back to a non-existent file
if (!remoteConfig.$schema) remoteConfig.$schema = "https://kilo.ai/config.json" // kilocode_change
- result = mergeConfigConcatArrays(
- result,
- await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`),
- )
+ result = merge(result, await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`))
log.debug("loaded remote config from well-known", { url: key })
}
}
+ const token = await Control.token()
+ if (token) {
+ }
+
// Global user config overrides remote config.
- result = mergeConfigConcatArrays(result, await global())
+ result = merge(result, await global())
// Custom config path overrides global config.
if (Flag.KILO_CONFIG) {
- result = mergeConfigConcatArrays(result, await loadFile(Flag.KILO_CONFIG))
+ result = merge(result, await loadFile(Flag.KILO_CONFIG))
log.debug("loaded custom config", { path: Flag.KILO_CONFIG })
}
@@ -196,7 +211,7 @@ export namespace Config {
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
for (const resolved of found.toReversed()) {
- result = mergeConfigConcatArrays(result, await loadFile(resolved))
+ result = merge(result, await loadFile(resolved))
}
}
}
@@ -239,7 +254,7 @@ export namespace Config {
if (dir.endsWith(".opencode") || dir === Flag.KILO_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
- result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
+ result = merge(result, await loadFile(path.join(dir, file)))
// to satisfy the type checker
result.agent ??= {}
result.mode ??= {}
@@ -262,8 +277,8 @@ export namespace Config {
// Inline config content overrides all non-managed config sources.
if (Flag.KILO_CONFIG_CONTENT) {
- result = mergeConfigConcatArrays(result, JSON.parse(Flag.KILO_CONFIG_CONTENT))
- log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
+ result = merge(result, JSON.parse(Flag.KILO_CONFIG_CONTENT))
+ log.debug("loaded custom config from KILO_CONFIG_CONTENT")
}
// Load managed config files last (highest priority) - enterprise admin-controlled
@@ -272,7 +287,7 @@ export namespace Config {
// This way it only loads config file and not skills/plugins/commands
if (existsSync(managedConfigDir)) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
- result = mergeConfigConcatArrays(result, await loadFile(path.join(managedConfigDir, file)))
+ result = merge(result, await loadFile(path.join(managedConfigDir, file)))
}
}
diff --git a/packages/opencode/src/control/control.sql.ts b/packages/opencode/src/control/control.sql.ts
new file mode 100644
index 0000000000..7b805c1627
--- /dev/null
+++ b/packages/opencode/src/control/control.sql.ts
@@ -0,0 +1,22 @@
+import { sqliteTable, text, integer, primaryKey, uniqueIndex } from "drizzle-orm/sqlite-core"
+import { eq } from "drizzle-orm"
+import { Timestamps } from "@/storage/schema.sql"
+
+export const ControlAccountTable = sqliteTable(
+ "control_account",
+ {
+ email: text().notNull(),
+ url: text().notNull(),
+ access_token: text().notNull(),
+ refresh_token: text().notNull(),
+ token_expiry: integer(),
+ active: integer({ mode: "boolean" })
+ .notNull()
+ .$default(() => false),
+ ...Timestamps,
+ },
+ (table) => [
+ primaryKey({ columns: [table.email, table.url] }),
+ // uniqueIndex("control_account_active_idx").on(table.email).where(eq(table.active, true)),
+ ],
+)
diff --git a/packages/opencode/src/control/index.ts b/packages/opencode/src/control/index.ts
new file mode 100644
index 0000000000..f712e88281
--- /dev/null
+++ b/packages/opencode/src/control/index.ts
@@ -0,0 +1,67 @@
+import { eq, and } from "drizzle-orm"
+import { Database } from "@/storage/db"
+import { ControlAccountTable } from "./control.sql"
+import z from "zod"
+
+export * from "./control.sql"
+
+export namespace Control {
+ export const Account = z.object({
+ email: z.string(),
+ url: z.string(),
+ })
+ export type Account = z.infer
+
+ function fromRow(row: (typeof ControlAccountTable)["$inferSelect"]): Account {
+ return {
+ email: row.email,
+ url: row.url,
+ }
+ }
+
+ export function account(): Account | undefined {
+ const row = Database.use((db) =>
+ db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
+ )
+ return row ? fromRow(row) : undefined
+ }
+
+ export async function token(): Promise {
+ const row = Database.use((db) =>
+ db.select().from(ControlAccountTable).where(eq(ControlAccountTable.active, true)).get(),
+ )
+ if (!row) return undefined
+ if (row.token_expiry && row.token_expiry > Date.now()) return row.access_token
+
+ const res = await fetch(`${row.url}/oauth/token`, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: row.refresh_token,
+ }).toString(),
+ })
+
+ if (!res.ok) return
+
+ const json = (await res.json()) as {
+ access_token: string
+ refresh_token?: string
+ expires_in?: number
+ }
+
+ Database.use((db) =>
+ db
+ .update(ControlAccountTable)
+ .set({
+ access_token: json.access_token,
+ refresh_token: json.refresh_token ?? row.refresh_token,
+ token_expiry: json.expires_in ? Date.now() + json.expires_in * 1000 : undefined,
+ })
+ .where(and(eq(ControlAccountTable.email, row.email), eq(ControlAccountTable.url, row.url)))
+ .run(),
+ )
+
+ return json.access_token
+ }
+}
diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts
index 737c0d6f72..cde5c71433 100644
--- a/packages/opencode/src/index.ts
+++ b/packages/opencode/src/index.ts
@@ -39,10 +39,13 @@ if (!process.env[ENV_FEATURE]) {
const isServe = process.argv.includes("serve")
process.env[ENV_FEATURE] = isServe ? "unknown" : "cli"
}
-import { Global } from "./global"
import { Config } from "./config/config"
import { Auth } from "./auth"
// kilocode_change end
+import path from "path"
+import { Global } from "./global"
+import { JsonMigration } from "./storage/json-migration"
+import { Database } from "./storage/db"
process.on("unhandledRejection", (e) => {
Log.Default.error("rejection", {
@@ -115,6 +118,42 @@ const cli = yargs(hideBin(process.argv))
Telemetry.trackCliStart()
// kilocode_change end
+ const marker = path.join(Global.Path.data, "opencode.db")
+ if (!(await Bun.file(marker).exists())) {
+ console.log("Performing one time database migration, may take a few minutes...")
+ const tty = process.stdout.isTTY
+ const width = 36
+ const orange = "\x1b[38;5;214m"
+ const muted = "\x1b[0;2m"
+ const reset = "\x1b[0m"
+ let last = -1
+ if (tty) process.stdout.write("\x1b[?25l")
+ try {
+ await JsonMigration.run(Database.Client().$client, {
+ progress: (event) => {
+ const percent = Math.floor((event.current / event.total) * 100)
+ if (percent === last && event.current !== event.total) return
+ last = percent
+ if (tty) {
+ const fill = Math.round((percent / 100) * width)
+ const bar = `${"■".repeat(fill)}${"・".repeat(width - fill)}`
+ process.stdout.write(
+ `\r${orange}${bar} ${percent.toString().padStart(3)}%${reset} ${muted}${event.label.padEnd(12)} ${event.current}/${event.total}${reset}`,
+ )
+ if (event.current === event.total) process.stdout.write("\n")
+ } else {
+ console.log(`sqlite-migration:${percent}`)
+ }
+ },
+ })
+ } finally {
+ if (tty) process.stdout.write("\x1b[?25h")
+ else {
+ console.log(`sqlite-migration:done`)
+ }
+ }
+ console.log("Database migration complete.")
+ }
})
.usage("\n" + UI.logo())
.completion("completion", "generate shell completion script")
diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts
index 2481f104ed..1e1df62a3c 100644
--- a/packages/opencode/src/permission/next.ts
+++ b/packages/opencode/src/permission/next.ts
@@ -3,7 +3,8 @@ import { BusEvent } from "@/bus/bus-event"
import { Config } from "@/config/config"
import { Identifier } from "@/id/id"
import { Instance } from "@/project/instance"
-import { Storage } from "@/storage/storage"
+import { Database, eq } from "@/storage/db"
+import { PermissionTable } from "@/session/session.sql"
import { fn } from "@/util/fn"
import { Log } from "@/util/log"
import { Wildcard } from "@/util/wildcard"
@@ -105,9 +106,12 @@ export namespace PermissionNext {
),
}
- const state = Instance.state(async () => {
+ const state = Instance.state(() => {
const projectID = Instance.project.id
- const stored = await Storage.read(["permission", projectID]).catch(() => [] as Ruleset)
+ const row = Database.use((db) =>
+ db.select().from(PermissionTable).where(eq(PermissionTable.project_id, projectID)).get(),
+ )
+ const stored = row?.data ?? ([] as Ruleset)
const pending: Record<
string,
@@ -222,7 +226,8 @@ export namespace PermissionNext {
// TODO: we don't save the permission ruleset to disk yet until there's
// UI to manage it
- // await Storage.write(["permission", Instance.project.id], s.approved)
+ // db().insert(PermissionTable).values({ projectID: Instance.project.id, data: s.approved })
+ // .onConflictDoUpdate({ target: PermissionTable.projectID, set: { data: s.approved } }).run()
return
}
},
@@ -275,6 +280,7 @@ export namespace PermissionNext {
}
export async function list() {
- return state().then((x) => Object.values(x.pending).map((x) => x.info))
+ const s = await state()
+ return Object.values(s.pending).map((x) => x.info)
}
}
diff --git a/packages/opencode/src/project/project.sql.ts b/packages/opencode/src/project/project.sql.ts
new file mode 100644
index 0000000000..12373244f5
--- /dev/null
+++ b/packages/opencode/src/project/project.sql.ts
@@ -0,0 +1,15 @@
+import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
+import { Timestamps } from "@/storage/schema.sql"
+
+export const ProjectTable = sqliteTable("project", {
+ id: text().primaryKey(),
+ worktree: text().notNull(),
+ vcs: text(),
+ name: text(),
+ icon_url: text(),
+ icon_color: text(),
+ ...Timestamps,
+ time_initialized: integer(),
+ sandboxes: text({ mode: "json" }).notNull().$type(),
+ commands: text({ mode: "json" }).$type<{ start?: string }>(),
+})
diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts
index f636882d8c..11229d3b52 100644
--- a/packages/opencode/src/project/project.ts
+++ b/packages/opencode/src/project/project.ts
@@ -1,11 +1,11 @@
import z from "zod"
-import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import path from "path"
-import { Storage } from "../storage/storage"
+import { Database, eq } from "../storage/db"
+import { ProjectTable } from "./project.sql"
+import { SessionTable } from "../session/session.sql"
import { Log } from "../util/log"
import { Flag } from "@/flag/flag"
-import { Session } from "../session"
import { work } from "../util/queue"
import { fn } from "@opencode-ai/util/fn"
import { BusEvent } from "@/bus/bus-event"
@@ -50,10 +50,33 @@ export namespace Project {
Updated: BusEvent.define("project.updated", Info),
}
+ type Row = typeof ProjectTable.$inferSelect
+
+ export function fromRow(row: Row): Info {
+ const icon =
+ row.icon_url || row.icon_color
+ ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
+ : undefined
+ return {
+ id: row.id,
+ worktree: row.worktree,
+ vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
+ name: row.name ?? undefined,
+ icon,
+ time: {
+ created: row.time_created,
+ updated: row.time_updated,
+ initialized: row.time_initialized ?? undefined,
+ },
+ sandboxes: row.sandboxes,
+ commands: row.commands ?? undefined,
+ }
+ }
+
export async function fromDirectory(directory: string) {
log.info("fromDirectory", { directory })
- const { id, sandbox, worktree, vcs } = await iife(async () => {
+ const data = await iife(async () => {
const matches = Filesystem.up({ targets: [".git"], start: directory })
const dotgit = await matches.next().then((x) => x.value)
await matches.return()
@@ -169,47 +192,73 @@ export namespace Project {
}
})
- let existing = await Storage.read(["project", id]).catch(() => undefined)
- if (!existing) {
- existing = {
- id,
- worktree,
- vcs: vcs as Info["vcs"],
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
+ const existing = await iife(async () => {
+ if (row) return fromRow(row)
+ const fresh: Info = {
+ id: data.id,
+ worktree: data.worktree,
+ vcs: data.vcs as Info["vcs"],
sandboxes: [],
time: {
created: Date.now(),
updated: Date.now(),
},
}
- if (id !== "global") {
- await migrateFromGlobal(id, worktree)
+ if (data.id !== "global") {
+ await migrateFromGlobal(data.id, data.worktree)
}
- }
-
- // migrate old projects before sandboxes
- if (!existing.sandboxes) existing.sandboxes = []
+ return fresh
+ })
if (Flag.KILO_EXPERIMENTAL_ICON_DISCOVERY) discover(existing)
const result: Info = {
...existing,
- worktree,
- vcs: vcs as Info["vcs"],
+ worktree: data.worktree,
+ vcs: data.vcs as Info["vcs"],
time: {
...existing.time,
updated: Date.now(),
},
}
- if (sandbox !== result.worktree && !result.sandboxes.includes(sandbox)) result.sandboxes.push(sandbox)
+ if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
+ result.sandboxes.push(data.sandbox)
result.sandboxes = result.sandboxes.filter((x) => existsSync(x))
- await Storage.write(["project", id], result)
+ const insert = {
+ id: result.id,
+ worktree: result.worktree,
+ vcs: result.vcs ?? null,
+ name: result.name,
+ icon_url: result.icon?.url,
+ icon_color: result.icon?.color,
+ time_created: result.time.created,
+ time_updated: result.time.updated,
+ time_initialized: result.time.initialized,
+ sandboxes: result.sandboxes,
+ commands: result.commands,
+ }
+ const updateSet = {
+ worktree: result.worktree,
+ vcs: result.vcs ?? null,
+ name: result.name,
+ icon_url: result.icon?.url,
+ icon_color: result.icon?.color,
+ time_updated: result.time.updated,
+ time_initialized: result.time.initialized,
+ sandboxes: result.sandboxes,
+ commands: result.commands,
+ }
+ Database.use((db) =>
+ db.insert(ProjectTable).values(insert).onConflictDoUpdate({ target: ProjectTable.id, set: updateSet }).run(),
+ )
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: result,
},
})
- return { project: result, sandbox }
+ return { project: result, sandbox: data.sandbox }
}
export async function discover(input: Info) {
@@ -242,43 +291,54 @@ export namespace Project {
return
}
- async function migrateFromGlobal(newProjectID: string, worktree: string) {
- const globalProject = await Storage.read(["project", "global"]).catch(() => undefined)
- if (!globalProject) return
+ async function migrateFromGlobal(id: string, worktree: string) {
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, "global")).get())
+ if (!row) return
- const globalSessions = await Storage.list(["session", "global"]).catch(() => [])
- if (globalSessions.length === 0) return
+ const sessions = Database.use((db) =>
+ db.select().from(SessionTable).where(eq(SessionTable.project_id, "global")).all(),
+ )
+ if (sessions.length === 0) return
- log.info("migrating sessions from global", { newProjectID, worktree, count: globalSessions.length })
+ log.info("migrating sessions from global", { newProjectID: id, worktree, count: sessions.length })
- await work(10, globalSessions, async (key) => {
- const sessionID = key[key.length - 1]
- const session = await Storage.read(key).catch(() => undefined)
- if (!session) return
- if (session.directory && session.directory !== worktree) return
+ await work(10, sessions, async (row) => {
+ // Skip sessions that belong to a different directory
+ if (row.directory && row.directory !== worktree) return
- session.projectID = newProjectID
- log.info("migrating session", { sessionID, from: "global", to: newProjectID })
- await Storage.write(["session", newProjectID, sessionID], session)
- await Storage.remove(key)
+ log.info("migrating session", { sessionID: row.id, from: "global", to: id })
+ Database.use((db) => db.update(SessionTable).set({ project_id: id }).where(eq(SessionTable.id, row.id)).run())
}).catch((error) => {
- log.error("failed to migrate sessions from global to project", { error, projectId: newProjectID })
+ log.error("failed to migrate sessions from global to project", { error, projectId: id })
})
}
- export async function setInitialized(projectID: string) {
- await Storage.update(["project", projectID], (draft) => {
- draft.time.initialized = Date.now()
- })
+ export function setInitialized(id: string) {
+ Database.use((db) =>
+ db
+ .update(ProjectTable)
+ .set({
+ time_initialized: Date.now(),
+ })
+ .where(eq(ProjectTable.id, id))
+ .run(),
+ )
}
- export async function list() {
- const keys = await Storage.list(["project"])
- const projects = await Promise.all(keys.map((x) => Storage.read(x)))
- return projects.map((project) => ({
- ...project,
- sandboxes: project.sandboxes?.filter((x) => existsSync(x)),
- }))
+ export function list() {
+ return Database.use((db) =>
+ db
+ .select()
+ .from(ProjectTable)
+ .all()
+ .map((row) => fromRow(row)),
+ )
+ }
+
+ export function get(id: string): Info | undefined {
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
+ if (!row) return undefined
+ return fromRow(row)
}
export const update = fn(
@@ -289,77 +349,90 @@ export namespace Project {
commands: Info.shape.commands.optional(),
}),
async (input) => {
- const result = await Storage.update(["project", input.projectID], (draft) => {
- if (input.name !== undefined) draft.name = input.name
- if (input.icon !== undefined) {
- draft.icon = {
- ...draft.icon,
- }
- if (input.icon.url !== undefined) draft.icon.url = input.icon.url
- if (input.icon.override !== undefined) draft.icon.override = input.icon.override || undefined
- if (input.icon.color !== undefined) draft.icon.color = input.icon.color
- }
-
- if (input.commands?.start !== undefined) {
- const start = input.commands.start || undefined
- draft.commands = {
- ...(draft.commands ?? {}),
- }
- draft.commands.start = start
- if (!draft.commands.start) draft.commands = undefined
- }
-
- draft.time.updated = Date.now()
- })
+ const result = Database.use((db) =>
+ db
+ .update(ProjectTable)
+ .set({
+ name: input.name,
+ icon_url: input.icon?.url,
+ icon_color: input.icon?.color,
+ commands: input.commands,
+ time_updated: Date.now(),
+ })
+ .where(eq(ProjectTable.id, input.projectID))
+ .returning()
+ .get(),
+ )
+ if (!result) throw new Error(`Project not found: ${input.projectID}`)
+ const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
- properties: result,
+ properties: data,
},
})
- return result
+ return data
},
)
- export async function sandboxes(projectID: string) {
- const project = await Storage.read(["project", projectID]).catch(() => undefined)
- if (!project?.sandboxes) return []
+ export async function sandboxes(id: string) {
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
+ if (!row) return []
+ const data = fromRow(row)
const valid: string[] = []
- for (const dir of project.sandboxes) {
- const stat = await fs.stat(dir).catch(() => undefined)
+ for (const dir of data.sandboxes) {
+ const stat = await Bun.file(dir)
+ .stat()
+ .catch(() => undefined)
if (stat?.isDirectory()) valid.push(dir)
}
return valid
}
- export async function addSandbox(projectID: string, directory: string) {
- const result = await Storage.update(["project", projectID], (draft) => {
- const sandboxes = draft.sandboxes ?? []
- if (!sandboxes.includes(directory)) sandboxes.push(directory)
- draft.sandboxes = sandboxes
- draft.time.updated = Date.now()
- })
+ export async function addSandbox(id: string, directory: string) {
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
+ if (!row) throw new Error(`Project not found: ${id}`)
+ const sandboxes = [...row.sandboxes]
+ if (!sandboxes.includes(directory)) sandboxes.push(directory)
+ const result = Database.use((db) =>
+ db
+ .update(ProjectTable)
+ .set({ sandboxes, time_updated: Date.now() })
+ .where(eq(ProjectTable.id, id))
+ .returning()
+ .get(),
+ )
+ if (!result) throw new Error(`Project not found: ${id}`)
+ const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
- properties: result,
+ properties: data,
},
})
- return result
+ return data
}
- export async function removeSandbox(projectID: string, directory: string) {
- const result = await Storage.update(["project", projectID], (draft) => {
- const sandboxes = draft.sandboxes ?? []
- draft.sandboxes = sandboxes.filter((sandbox) => sandbox !== directory)
- draft.time.updated = Date.now()
- })
+ export async function removeSandbox(id: string, directory: string) {
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
+ if (!row) throw new Error(`Project not found: ${id}`)
+ const sandboxes = row.sandboxes.filter((s) => s !== directory)
+ const result = Database.use((db) =>
+ db
+ .update(ProjectTable)
+ .set({ sandboxes, time_updated: Date.now() })
+ .where(eq(ProjectTable.id, id))
+ .returning()
+ .get(),
+ )
+ if (!result) throw new Error(`Project not found: ${id}`)
+ const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
- properties: result,
+ properties: data,
},
})
- return result
+ return data
}
}
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index ad4b97c04f..2ffe434383 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -492,30 +492,22 @@ export namespace ProviderTransform {
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic
case "@ai-sdk/google-vertex/anthropic":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider
- // kilocode_change start
- // TODO: Enable when @ai-sdk/anthropic supports thinking.type: "adaptive"
- const ADAPTIVE_THINKING_ENABLED_ANTHROPIC = false
- if (ADAPTIVE_THINKING_ENABLED_ANTHROPIC && id.includes("claude-opus-4-6")) {
- return {
- low: {
- thinking: { type: "adaptive" },
- output_config: { effort: "low" },
- },
- medium: {
- thinking: { type: "adaptive" },
- output_config: { effort: "medium" },
- },
- high: {
- thinking: { type: "adaptive" },
- output_config: { effort: "high" },
- },
- max: {
- thinking: { type: "adaptive" },
- output_config: { effort: "max" },
- },
- }
+
+ if (model.api.id.includes("opus-4-6") || model.api.id.includes("opus-4.6")) {
+ const efforts = ["low", "medium", "high", "max"]
+ return Object.fromEntries(
+ efforts.map((effort) => [
+ effort,
+ {
+ thinking: {
+ type: "adaptive",
+ },
+ effort,
+ },
+ ]),
+ )
}
- // kilocode_change end
+
return {
high: {
thinking: {
@@ -533,6 +525,20 @@ export namespace ProviderTransform {
case "@ai-sdk/amazon-bedrock":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock
+ if (model.api.id.includes("opus-4-6") || model.api.id.includes("opus-4.6")) {
+ const efforts = ["low", "medium", "high", "max"]
+ return Object.fromEntries(
+ efforts.map((effort) => [
+ effort,
+ {
+ reasoningConfig: {
+ type: "adaptive",
+ maxReasoningEffort: effort,
+ },
+ },
+ ]),
+ )
+ }
// For Anthropic models on Bedrock, use reasoningConfig with budgetTokens
if (model.api.id.includes("anthropic")) {
return {
diff --git a/packages/opencode/src/server/error.ts b/packages/opencode/src/server/error.ts
index 26e2dfcb12..cc5fa96187 100644
--- a/packages/opencode/src/server/error.ts
+++ b/packages/opencode/src/server/error.ts
@@ -1,6 +1,6 @@
import { resolver } from "hono-openapi"
import z from "zod"
-import { Storage } from "../storage/storage"
+import { NotFoundError } from "../storage/db"
export const ERRORS = {
400: {
@@ -25,7 +25,7 @@ export const ERRORS = {
description: "Not found",
content: {
"application/json": {
- schema: resolver(Storage.NotFoundError.Schema),
+ schema: resolver(NotFoundError.Schema),
},
},
},
diff --git a/packages/opencode/src/server/routes/pty.ts b/packages/opencode/src/server/routes/pty.ts
index 10bf51cb99..21156190dc 100644
--- a/packages/opencode/src/server/routes/pty.ts
+++ b/packages/opencode/src/server/routes/pty.ts
@@ -3,7 +3,7 @@ import { describeRoute, validator, resolver } from "hono-openapi"
import { upgradeWebSocket } from "hono/bun"
import z from "zod"
import { Pty } from "@/pty"
-import { Storage } from "../../storage/storage"
+import { NotFoundError } from "../../storage/db"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -76,7 +76,7 @@ export const PtyRoutes = lazy(() =>
async (c) => {
const info = Pty.get(c.req.valid("param").ptyID)
if (!info) {
- throw new Storage.NotFoundError({ message: "Session not found" })
+ throw new NotFoundError({ message: "Session not found" })
}
return c.json(info)
},
diff --git a/packages/opencode/src/server/routes/session.ts b/packages/opencode/src/server/routes/session.ts
index 82e6f3121b..2cf5473f22 100644
--- a/packages/opencode/src/server/routes/session.ts
+++ b/packages/opencode/src/server/routes/session.ts
@@ -276,18 +276,15 @@ export const SessionRoutes = lazy(() =>
const sessionID = c.req.valid("param").sessionID
const updates = c.req.valid("json")
- const updatedSession = await Session.update(
- sessionID,
- (session) => {
- if (updates.title !== undefined) {
- session.title = updates.title
- }
- if (updates.time?.archived !== undefined) session.time.archived = updates.time.archived
- },
- { touch: false },
- )
+ let session = await Session.get(sessionID)
+ if (updates.title !== undefined) {
+ session = await Session.setTitle({ sessionID, title: updates.title })
+ }
+ if (updates.time?.archived !== undefined) {
+ session = await Session.setArchived({ sessionID, time: updates.time.archived })
+ }
- return c.json(updatedSession)
+ return c.json(session)
},
)
.post(
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index e883480e40..be42471a1f 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -33,7 +33,7 @@ import { ProviderRoutes } from "./routes/provider"
import { createKiloRoutes } from "@kilocode/kilo-gateway" // kilocode_change
import { lazy } from "../util/lazy"
import { InstanceBootstrap } from "../project/bootstrap"
-import { Storage } from "../storage/storage"
+import { NotFoundError } from "../storage/db"
import type { ContentfulStatusCode } from "hono/utils/http-status"
import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
@@ -68,7 +68,7 @@ export namespace Server {
})
if (err instanceof NamedError) {
let status: ContentfulStatusCode
- if (err instanceof Storage.NotFoundError) status = 404
+ if (err instanceof NotFoundError) status = 404
else if (err instanceof Provider.ModelNotFoundError) status = 400
else if (err.name.startsWith("Worktree")) status = 400
else status = 500
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index a5c1de2801..50021a0d25 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -10,7 +10,9 @@ import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
import { Installation } from "../installation"
-import { Storage } from "../storage/storage"
+import { Database, NotFoundError, eq, and, or, like } from "../storage/db"
+import { SessionTable, MessageTable, PartTable } from "./session.sql"
+import { Storage } from "@/storage/storage"
import { Log } from "../util/log"
import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance"
@@ -41,6 +43,64 @@ export namespace Session {
).test(title)
}
+ type SessionRow = typeof SessionTable.$inferSelect
+
+ export function fromRow(row: SessionRow): Info {
+ const summary =
+ row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null
+ ? {
+ additions: row.summary_additions ?? 0,
+ deletions: row.summary_deletions ?? 0,
+ files: row.summary_files ?? 0,
+ diffs: row.summary_diffs ?? undefined,
+ }
+ : undefined
+ const share = row.share_url ? { url: row.share_url } : undefined
+ const revert = row.revert ?? undefined
+ return {
+ id: row.id,
+ slug: row.slug,
+ projectID: row.project_id,
+ directory: row.directory,
+ parentID: row.parent_id ?? undefined,
+ title: row.title,
+ version: row.version,
+ summary,
+ share,
+ revert,
+ permission: row.permission ?? undefined,
+ time: {
+ created: row.time_created,
+ updated: row.time_updated,
+ compacting: row.time_compacting ?? undefined,
+ archived: row.time_archived ?? undefined,
+ },
+ }
+ }
+
+ export function toRow(info: Info) {
+ return {
+ id: info.id,
+ project_id: info.projectID,
+ parent_id: info.parentID,
+ slug: info.slug,
+ directory: info.directory,
+ title: info.title,
+ version: info.version,
+ share_url: info.share?.url,
+ summary_additions: info.summary?.additions,
+ summary_deletions: info.summary?.deletions,
+ summary_files: info.summary?.files,
+ summary_diffs: info.summary?.diffs,
+ revert: info.revert ?? null,
+ permission: info.permission,
+ time_created: info.time.created,
+ time_updated: info.time.updated,
+ time_compacting: info.time.compacting,
+ time_archived: info.time.archived,
+ }
+ }
+
function getForkedTitle(title: string): string {
const match = title.match(/^(.+) \(fork #(\d+)\)$/)
if (match) {
@@ -94,16 +154,6 @@ export namespace Session {
})
export type Info = z.output
- export const ShareInfo = z
- .object({
- secret: z.string(),
- url: z.string(),
- })
- .meta({
- ref: "SessionShare",
- })
- export type ShareInfo = z.output
-
export const Event = {
Created: BusEvent.define(
"session.created",
@@ -218,8 +268,17 @@ export namespace Session {
)
export const touch = fn(Identifier.schema("session"), async (sessionID) => {
- await update(sessionID, (draft) => {
- draft.time.updated = Date.now()
+ const now = Date.now()
+ Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({ time_updated: now })
+ .where(eq(SessionTable.id, sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
})
})
@@ -245,21 +304,19 @@ export namespace Session {
},
}
log.info("created", result)
- await Storage.write(["session", Instance.project.id, result.id], result)
- Bus.publish(Event.Created, {
- info: result,
+ Database.use((db) => {
+ db.insert(SessionTable).values(toRow(result)).run()
+ Database.effect(() =>
+ Bus.publish(Event.Created, {
+ info: result,
+ }),
+ )
})
const cfg = await Config.get()
if (!result.parentID && (Flag.KILO_AUTO_SHARE || cfg.share === "auto"))
- share(result.id)
- .then((share) => {
- update(result.id, (draft) => {
- draft.share = share
- })
- })
- .catch(() => {
- // Silently ignore sharing errors during session creation
- })
+ share(result.id).catch(() => {
+ // Silently ignore sharing errors during session creation
+ })
Bus.publish(Event.Updated, {
info: result,
})
@@ -274,12 +331,9 @@ export namespace Session {
}
export const get = fn(Identifier.schema("session"), async (id) => {
- const read = await Storage.read(["session", Instance.project.id, id])
- return read as Info
- })
-
- export const getShare = fn(Identifier.schema("session"), async (id) => {
- return Storage.read(["share", id])
+ const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
+ if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
+ return fromRow(row)
})
export const share = fn(Identifier.schema("session"), async (id) => {
@@ -289,47 +343,168 @@ export namespace Session {
}
const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions")
const share = await KiloSessions.share(id) // kilocode_change
- await update(
- id,
- (draft) => {
- draft.share = {
- url: share.url,
- }
- },
- { touch: false },
- )
+ Database.use((db) => {
+ const row = db.update(SessionTable).set({ share_url: share.url }).where(eq(SessionTable.id, id)).returning().get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ })
return share
})
export const unshare = fn(Identifier.schema("session"), async (id) => {
+ // Use ShareNext to remove the share (same as share function uses ShareNext to create)
const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions")
await KiloSessions.unshare(id) // kilocode_change
- await update(
- id,
- (draft) => {
- draft.share = undefined
- },
- { touch: false },
- )
+ Database.use((db) => {
+ const row = db.update(SessionTable).set({ share_url: null }).where(eq(SessionTable.id, id)).returning().get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${id}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ })
})
- export async function update(id: string, editor: (session: Info) => void, options?: { touch?: boolean }) {
- const project = Instance.project
- const result = await Storage.update(["session", project.id, id], (draft) => {
- editor(draft)
- if (options?.touch !== false) {
- draft.time.updated = Date.now()
- }
+ export const setTitle = fn(
+ z.object({
+ sessionID: Identifier.schema("session"),
+ title: z.string(),
+ }),
+ async (input) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({ title: input.title })
+ .where(eq(SessionTable.id, input.sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
+ })
+ },
+ )
+
+ export const setArchived = fn(
+ z.object({
+ sessionID: Identifier.schema("session"),
+ time: z.number().optional(),
+ }),
+ async (input) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({ time_archived: input.time })
+ .where(eq(SessionTable.id, input.sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
+ })
+ },
+ )
+
+ export const setPermission = fn(
+ z.object({
+ sessionID: Identifier.schema("session"),
+ permission: PermissionNext.Ruleset,
+ }),
+ async (input) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({ permission: input.permission, time_updated: Date.now() })
+ .where(eq(SessionTable.id, input.sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
+ })
+ },
+ )
+
+ export const setRevert = fn(
+ z.object({
+ sessionID: Identifier.schema("session"),
+ revert: Info.shape.revert,
+ summary: Info.shape.summary,
+ }),
+ async (input) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({
+ revert: input.revert ?? null,
+ summary_additions: input.summary?.additions,
+ summary_deletions: input.summary?.deletions,
+ summary_files: input.summary?.files,
+ time_updated: Date.now(),
+ })
+ .where(eq(SessionTable.id, input.sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
+ })
+ },
+ )
+
+ export const clearRevert = fn(Identifier.schema("session"), async (sessionID) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({
+ revert: null,
+ time_updated: Date.now(),
+ })
+ .where(eq(SessionTable.id, sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
})
- Bus.publish(Event.Updated, {
- info: result,
- })
- return result
- }
+ })
+
+ export const setSummary = fn(
+ z.object({
+ sessionID: Identifier.schema("session"),
+ summary: Info.shape.summary,
+ }),
+ async (input) => {
+ return Database.use((db) => {
+ const row = db
+ .update(SessionTable)
+ .set({
+ summary_additions: input.summary?.additions,
+ summary_deletions: input.summary?.deletions,
+ summary_files: input.summary?.files,
+ time_updated: Date.now(),
+ })
+ .where(eq(SessionTable.id, input.sessionID))
+ .returning()
+ .get()
+ if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
+ const info = fromRow(row)
+ Database.effect(() => Bus.publish(Event.Updated, { info }))
+ return info
+ })
+ },
+ )
export const diff = fn(Identifier.schema("session"), async (sessionID) => {
- const diffs = await Storage.read(["session_diff", sessionID])
- return diffs ?? []
+ try {
+ return await Storage.read(["session_diff", sessionID])
+ } catch {
+ return []
+ }
})
export const messages = fn(
@@ -348,25 +523,37 @@ export namespace Session {
},
)
- export async function* list() {
+ export function* list() {
const project = Instance.project
- for (const item of await Storage.list(["session", project.id])) {
- const session = await Storage.read(item).catch(() => undefined)
- if (!session) continue
- yield session
+ const rel = path.relative(Instance.worktree, Instance.directory)
+ const suffix = path.sep + rel
+ const rows = Database.use((db) =>
+ db
+ .select()
+ .from(SessionTable)
+ .where(
+ and(
+ eq(SessionTable.project_id, project.id),
+ or(eq(SessionTable.directory, Instance.directory), like(SessionTable.directory, `%${suffix}`)),
+ ),
+ )
+ .all(),
+ )
+ for (const row of rows) {
+ yield fromRow(row)
}
}
export const children = fn(Identifier.schema("session"), async (parentID) => {
const project = Instance.project
- const result = [] as Session.Info[]
- for (const item of await Storage.list(["session", project.id])) {
- const session = await Storage.read(item).catch(() => undefined)
- if (!session) continue
- if (session.parentID !== parentID) continue
- result.push(session)
- }
- return result
+ const rows = Database.use((db) =>
+ db
+ .select()
+ .from(SessionTable)
+ .where(and(eq(SessionTable.project_id, project.id), eq(SessionTable.parent_id, parentID)))
+ .all(),
+ )
+ return rows.map(fromRow)
})
export const remove = fn(Identifier.schema("session"), async (sessionID) => {
@@ -378,15 +565,14 @@ export namespace Session {
}
const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions")
await KiloSessions.remove(sessionID).catch(() => {}) // kilocode_change
- for (const msg of await Storage.list(["message", sessionID])) {
- for (const part of await Storage.list(["part", msg.at(-1)!])) {
- await Storage.remove(part)
- }
- await Storage.remove(msg)
- }
- await Storage.remove(["session", project.id, sessionID])
- Bus.publish(Event.Deleted, {
- info: session,
+ // CASCADE delete handles messages and parts automatically
+ Database.use((db) => {
+ db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()
+ Database.effect(() =>
+ Bus.publish(Event.Deleted, {
+ info: session,
+ }),
+ )
})
} catch (e) {
log.error(e)
@@ -394,9 +580,23 @@ export namespace Session {
})
export const updateMessage = fn(MessageV2.Info, async (msg) => {
- await Storage.write(["message", msg.sessionID, msg.id], msg)
- Bus.publish(MessageV2.Event.Updated, {
- info: msg,
+ const time_created = msg.role === "user" ? msg.time.created : msg.time.created
+ const { id, sessionID, ...data } = msg
+ Database.use((db) => {
+ db.insert(MessageTable)
+ .values({
+ id,
+ session_id: sessionID,
+ time_created,
+ data,
+ })
+ .onConflictDoUpdate({ target: MessageTable.id, set: { data } })
+ .run()
+ Database.effect(() =>
+ Bus.publish(MessageV2.Event.Updated, {
+ info: msg,
+ }),
+ )
})
return msg
})
@@ -407,10 +607,15 @@ export namespace Session {
messageID: Identifier.schema("message"),
}),
async (input) => {
- await Storage.remove(["message", input.sessionID, input.messageID])
- Bus.publish(MessageV2.Event.Removed, {
- sessionID: input.sessionID,
- messageID: input.messageID,
+ // CASCADE delete handles parts automatically
+ Database.use((db) => {
+ db.delete(MessageTable).where(eq(MessageTable.id, input.messageID)).run()
+ Database.effect(() =>
+ Bus.publish(MessageV2.Event.Removed, {
+ sessionID: input.sessionID,
+ messageID: input.messageID,
+ }),
+ )
})
return input.messageID
},
@@ -423,39 +628,58 @@ export namespace Session {
partID: Identifier.schema("part"),
}),
async (input) => {
- await Storage.remove(["part", input.messageID, input.partID])
- Bus.publish(MessageV2.Event.PartRemoved, {
- sessionID: input.sessionID,
- messageID: input.messageID,
- partID: input.partID,
+ Database.use((db) => {
+ db.delete(PartTable).where(eq(PartTable.id, input.partID)).run()
+ Database.effect(() =>
+ Bus.publish(MessageV2.Event.PartRemoved, {
+ sessionID: input.sessionID,
+ messageID: input.messageID,
+ partID: input.partID,
+ }),
+ )
})
return input.partID
},
)
- const UpdatePartInput = z.union([
- MessageV2.Part,
- z.object({
- part: MessageV2.TextPart,
- delta: z.string(),
- }),
- z.object({
- part: MessageV2.ReasoningPart,
- delta: z.string(),
- }),
- ])
+ const UpdatePartInput = MessageV2.Part
- export const updatePart = fn(UpdatePartInput, async (input) => {
- const part = "delta" in input ? input.part : input
- const delta = "delta" in input ? input.delta : undefined
- await Storage.write(["part", part.messageID, part.id], part)
- Bus.publish(MessageV2.Event.PartUpdated, {
- part,
- delta,
+ export const updatePart = fn(UpdatePartInput, async (part) => {
+ const { id, messageID, sessionID, ...data } = part
+ const time = Date.now()
+ Database.use((db) => {
+ db.insert(PartTable)
+ .values({
+ id,
+ message_id: messageID,
+ session_id: sessionID,
+ time_created: time,
+ data,
+ })
+ .onConflictDoUpdate({ target: PartTable.id, set: { data } })
+ .run()
+ Database.effect(() =>
+ Bus.publish(MessageV2.Event.PartUpdated, {
+ part,
+ }),
+ )
})
return part
})
+ export const updatePartDelta = fn(
+ z.object({
+ sessionID: z.string(),
+ messageID: z.string(),
+ partID: z.string(),
+ field: z.string(),
+ delta: z.string(),
+ }),
+ async (input) => {
+ Bus.publish(MessageV2.Event.PartDelta, input)
+ },
+ )
+
export const getUsage = fn(
z.object({
model: z.custom(),
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index 70763548c6..178751a222 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -6,6 +6,10 @@ import { Identifier } from "../id/id"
import { LSP } from "../lsp"
import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn"
+import { Database, eq, desc, inArray } from "@/storage/db"
+import { MessageTable, PartTable } from "./session.sql"
+import { ProviderTransform } from "@/provider/transform"
+import { STATUS_CODES } from "http"
import { Storage } from "@/storage/storage"
import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife"
@@ -456,7 +460,16 @@ export namespace MessageV2 {
"message.part.updated",
z.object({
part: Part,
- delta: z.string().optional(),
+ }),
+ ),
+ PartDelta: BusEvent.define(
+ "message.part.delta",
+ z.object({
+ sessionID: z.string(),
+ messageID: z.string(),
+ partID: z.string(),
+ field: z.string(),
+ delta: z.string(),
}),
),
PartRemoved: BusEvent.define(
@@ -701,23 +714,65 @@ export namespace MessageV2 {
}
export const stream = fn(Identifier.schema("session"), async function* (sessionID) {
- const list = await Array.fromAsync(await Storage.list(["message", sessionID]))
- for (let i = list.length - 1; i >= 0; i--) {
- yield await get({
- sessionID,
- messageID: list[i][2],
- })
+ const size = 50
+ let offset = 0
+ while (true) {
+ const rows = Database.use((db) =>
+ db
+ .select()
+ .from(MessageTable)
+ .where(eq(MessageTable.session_id, sessionID))
+ .orderBy(desc(MessageTable.time_created))
+ .limit(size)
+ .offset(offset)
+ .all(),
+ )
+ if (rows.length === 0) break
+
+ const ids = rows.map((row) => row.id)
+ const partsByMessage = new Map()
+ if (ids.length > 0) {
+ const partRows = Database.use((db) =>
+ db
+ .select()
+ .from(PartTable)
+ .where(inArray(PartTable.message_id, ids))
+ .orderBy(PartTable.message_id, PartTable.id)
+ .all(),
+ )
+ for (const row of partRows) {
+ const part = {
+ ...row.data,
+ id: row.id,
+ sessionID: row.session_id,
+ messageID: row.message_id,
+ } as MessageV2.Part
+ const list = partsByMessage.get(row.message_id)
+ if (list) list.push(part)
+ else partsByMessage.set(row.message_id, [part])
+ }
+ }
+
+ for (const row of rows) {
+ const info = { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info
+ yield {
+ info,
+ parts: partsByMessage.get(row.id) ?? [],
+ }
+ }
+
+ offset += rows.length
+ if (rows.length < size) break
}
})
- export const parts = fn(Identifier.schema("message"), async (messageID) => {
- const result = [] as MessageV2.Part[]
- for (const item of await Storage.list(["part", messageID])) {
- const read = await Storage.read(item)
- result.push(read)
- }
- result.sort((a, b) => (a.id > b.id ? 1 : -1))
- return result
+ export const parts = fn(Identifier.schema("message"), async (message_id) => {
+ const rows = Database.use((db) =>
+ db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(),
+ )
+ return rows.map(
+ (row) => ({ ...row.data, id: row.id, sessionID: row.session_id, messageID: row.message_id }) as MessageV2.Part,
+ )
})
export const get = fn(
@@ -726,8 +781,11 @@ export namespace MessageV2 {
messageID: Identifier.schema("message"),
}),
async (input): Promise => {
+ const row = Database.use((db) => db.select().from(MessageTable).where(eq(MessageTable.id, input.messageID)).get())
+ if (!row) throw new Error(`Message not found: ${input.messageID}`)
+ const info = { ...row.data, id: row.id, sessionID: row.session_id } as MessageV2.Info
return {
- info: await Storage.read(["message", input.sessionID, input.messageID]),
+ info,
parts: await parts(input.messageID),
}
},
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index 57c038afa2..cb55e1bb6e 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -65,17 +65,19 @@ export namespace SessionProcessor {
if (value.id in reasoningMap) {
continue
}
- reasoningMap[value.id] = {
+ const reasoningPart = {
id: Identifier.ascending("part"),
messageID: input.assistantMessage.id,
sessionID: input.assistantMessage.sessionID,
- type: "reasoning",
+ type: "reasoning" as const,
text: "",
time: {
start: Date.now(),
},
metadata: value.providerMetadata,
}
+ reasoningMap[value.id] = reasoningPart
+ await Session.updatePart(reasoningPart)
break
case "reasoning-delta":
@@ -83,7 +85,13 @@ export namespace SessionProcessor {
const part = reasoningMap[value.id]
part.text += value.text
if (value.providerMetadata) part.metadata = value.providerMetadata
- if (part.text) await Session.updatePart({ part, delta: value.text })
+ await Session.updatePartDelta({
+ sessionID: part.sessionID,
+ messageID: part.messageID,
+ partID: part.id,
+ field: "text",
+ delta: value.text,
+ })
}
break
@@ -311,17 +319,20 @@ export namespace SessionProcessor {
},
metadata: value.providerMetadata,
}
+ await Session.updatePart(currentText)
break
case "text-delta":
if (currentText) {
currentText.text += value.text
if (value.providerMetadata) currentText.metadata = value.providerMetadata
- if (currentText.text)
- await Session.updatePart({
- part: currentText,
- delta: value.text,
- })
+ await Session.updatePartDelta({
+ sessionID: currentText.sessionID,
+ messageID: currentText.messageID,
+ partID: currentText.id,
+ field: "text",
+ delta: value.text,
+ })
}
break
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index ae6248a0ef..4ee5e97508 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -175,9 +175,7 @@ export namespace SessionPrompt {
}
if (permissions.length > 0) {
session.permission = permissions
- await Session.update(session.id, (draft) => {
- draft.permission = permissions
- })
+ await Session.setPermission({ sessionID: session.id, permission: permissions })
}
if (input.noReply === true) {
@@ -1978,21 +1976,16 @@ NOTE: At any point in time through this workflow you should feel free to ask the
],
})
const text = await result.text.catch((err) => log.error("failed to generate title", { error: err }))
- if (text)
- return Session.update(
- input.session.id,
- (draft) => {
- const cleaned = text
- .replace(/[\s\S]*?<\/think>\s*/g, "")
- .split("\n")
- .map((line) => line.trim())
- .find((line) => line.length > 0)
- if (!cleaned) return
+ if (text) {
+ const cleaned = text
+ .replace(/[\s\S]*?<\/think>\s*/g, "")
+ .split("\n")
+ .map((line) => line.trim())
+ .find((line) => line.length > 0)
+ if (!cleaned) return
- const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
- draft.title = title
- },
- { touch: false },
- )
+ const title = cleaned.length > 100 ? cleaned.substring(0, 97) + "..." : cleaned
+ return Session.setTitle({ sessionID: input.session.id, title })
+ }
}
}
diff --git a/packages/opencode/src/session/revert.ts b/packages/opencode/src/session/revert.ts
index 7afe44e2ce..ef9c7e2aac 100644
--- a/packages/opencode/src/session/revert.ts
+++ b/packages/opencode/src/session/revert.ts
@@ -4,8 +4,9 @@ import { Snapshot } from "../snapshot"
import { MessageV2 } from "./message-v2"
import { Session } from "."
import { Log } from "../util/log"
-import { splitWhen } from "remeda"
-import { Storage } from "../storage/storage"
+import { Database, eq } from "../storage/db"
+import { MessageTable, PartTable } from "./session.sql"
+import { Storage } from "@/storage/storage"
import { Bus } from "../bus"
import { SessionPrompt } from "./prompt"
import { SessionSummary } from "./summary"
@@ -65,13 +66,14 @@ export namespace SessionRevert {
sessionID: input.sessionID,
diff: diffs,
})
- return Session.update(input.sessionID, (draft) => {
- draft.revert = revert
- draft.summary = {
+ return Session.setRevert({
+ sessionID: input.sessionID,
+ revert,
+ summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
files: diffs.length,
- }
+ },
})
}
return session
@@ -83,39 +85,54 @@ export namespace SessionRevert {
const session = await Session.get(input.sessionID)
if (!session.revert) return session
if (session.revert.snapshot) await Snapshot.restore(session.revert.snapshot)
- const next = await Session.update(input.sessionID, (draft) => {
- draft.revert = undefined
- })
- return next
+ return Session.clearRevert(input.sessionID)
}
export async function cleanup(session: Session.Info) {
if (!session.revert) return
const sessionID = session.id
- let msgs = await Session.messages({ sessionID })
+ const msgs = await Session.messages({ sessionID })
const messageID = session.revert.messageID
- const [preserve, remove] = splitWhen(msgs, (x) => x.info.id === messageID)
- msgs = preserve
+ const preserve = [] as MessageV2.WithParts[]
+ const remove = [] as MessageV2.WithParts[]
+ let target: MessageV2.WithParts | undefined
+ for (const msg of msgs) {
+ if (msg.info.id < messageID) {
+ preserve.push(msg)
+ continue
+ }
+ if (msg.info.id > messageID) {
+ remove.push(msg)
+ continue
+ }
+ if (session.revert.partID) {
+ preserve.push(msg)
+ target = msg
+ continue
+ }
+ remove.push(msg)
+ }
for (const msg of remove) {
- await Storage.remove(["message", sessionID, msg.info.id])
+ Database.use((db) => db.delete(MessageTable).where(eq(MessageTable.id, msg.info.id)).run())
await Bus.publish(MessageV2.Event.Removed, { sessionID: sessionID, messageID: msg.info.id })
}
- const last = preserve.at(-1)
- if (session.revert.partID && last) {
+ if (session.revert.partID && target) {
const partID = session.revert.partID
- const [preserveParts, removeParts] = splitWhen(last.parts, (x) => x.id === partID)
- last.parts = preserveParts
- for (const part of removeParts) {
- await Storage.remove(["part", last.info.id, part.id])
- await Bus.publish(MessageV2.Event.PartRemoved, {
- sessionID: sessionID,
- messageID: last.info.id,
- partID: part.id,
- })
+ const removeStart = target.parts.findIndex((part) => part.id === partID)
+ if (removeStart >= 0) {
+ const preserveParts = target.parts.slice(0, removeStart)
+ const removeParts = target.parts.slice(removeStart)
+ target.parts = preserveParts
+ for (const part of removeParts) {
+ Database.use((db) => db.delete(PartTable).where(eq(PartTable.id, part.id)).run())
+ await Bus.publish(MessageV2.Event.PartRemoved, {
+ sessionID: sessionID,
+ messageID: target.info.id,
+ partID: part.id,
+ })
+ }
}
}
- await Session.update(sessionID, (draft) => {
- draft.revert = undefined
- })
+ await Session.clearRevert(sessionID)
}
}
diff --git a/packages/opencode/src/session/session.sql.ts b/packages/opencode/src/session/session.sql.ts
new file mode 100644
index 0000000000..9c5c72c4c5
--- /dev/null
+++ b/packages/opencode/src/session/session.sql.ts
@@ -0,0 +1,88 @@
+import { sqliteTable, text, integer, index, primaryKey } from "drizzle-orm/sqlite-core"
+import { ProjectTable } from "../project/project.sql"
+import type { MessageV2 } from "./message-v2"
+import type { Snapshot } from "@/snapshot"
+import type { PermissionNext } from "@/permission/next"
+import { Timestamps } from "@/storage/schema.sql"
+
+type PartData = Omit
+type InfoData = Omit
+
+export const SessionTable = sqliteTable(
+ "session",
+ {
+ id: text().primaryKey(),
+ project_id: text()
+ .notNull()
+ .references(() => ProjectTable.id, { onDelete: "cascade" }),
+ parent_id: text(),
+ slug: text().notNull(),
+ directory: text().notNull(),
+ title: text().notNull(),
+ version: text().notNull(),
+ share_url: text(),
+ summary_additions: integer(),
+ summary_deletions: integer(),
+ summary_files: integer(),
+ summary_diffs: text({ mode: "json" }).$type(),
+ revert: text({ mode: "json" }).$type<{ messageID: string; partID?: string; snapshot?: string; diff?: string }>(),
+ permission: text({ mode: "json" }).$type(),
+ ...Timestamps,
+ time_compacting: integer(),
+ time_archived: integer(),
+ },
+ (table) => [index("session_project_idx").on(table.project_id), index("session_parent_idx").on(table.parent_id)],
+)
+
+export const MessageTable = sqliteTable(
+ "message",
+ {
+ id: text().primaryKey(),
+ session_id: text()
+ .notNull()
+ .references(() => SessionTable.id, { onDelete: "cascade" }),
+ ...Timestamps,
+ data: text({ mode: "json" }).notNull().$type(),
+ },
+ (table) => [index("message_session_idx").on(table.session_id)],
+)
+
+export const PartTable = sqliteTable(
+ "part",
+ {
+ id: text().primaryKey(),
+ message_id: text()
+ .notNull()
+ .references(() => MessageTable.id, { onDelete: "cascade" }),
+ session_id: text().notNull(),
+ ...Timestamps,
+ data: text({ mode: "json" }).notNull().$type(),
+ },
+ (table) => [index("part_message_idx").on(table.message_id), index("part_session_idx").on(table.session_id)],
+)
+
+export const TodoTable = sqliteTable(
+ "todo",
+ {
+ session_id: text()
+ .notNull()
+ .references(() => SessionTable.id, { onDelete: "cascade" }),
+ content: text().notNull(),
+ status: text().notNull(),
+ priority: text().notNull(),
+ position: integer().notNull(),
+ ...Timestamps,
+ },
+ (table) => [
+ primaryKey({ columns: [table.session_id, table.position] }),
+ index("todo_session_idx").on(table.session_id),
+ ],
+)
+
+export const PermissionTable = sqliteTable("permission", {
+ project_id: text()
+ .primaryKey()
+ .references(() => ProjectTable.id, { onDelete: "cascade" }),
+ ...Timestamps,
+ data: text({ mode: "json" }).notNull().$type(),
+})
diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts
index b92fc9979f..c3e14ddd69 100644
--- a/packages/opencode/src/session/summary.ts
+++ b/packages/opencode/src/session/summary.ts
@@ -90,12 +90,13 @@ export namespace SessionSummary {
async function summarizeSession(input: { sessionID: string; messages: MessageV2.WithParts[] }) {
const diffs = await computeDiff({ messages: input.messages })
- await Session.update(input.sessionID, (draft) => {
- draft.summary = {
+ await Session.setSummary({
+ sessionID: input.sessionID,
+ summary: {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
files: diffs.length,
- }
+ },
})
await Storage.write(["session_diff", input.sessionID], diffs)
Bus.publish(Session.Event.Diff, {
diff --git a/packages/opencode/src/session/todo.ts b/packages/opencode/src/session/todo.ts
index aa7df7e981..ec2bcdda3c 100644
--- a/packages/opencode/src/session/todo.ts
+++ b/packages/opencode/src/session/todo.ts
@@ -1,7 +1,8 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import z from "zod"
-import { Storage } from "../storage/storage"
+import { Database, eq, asc } from "../storage/db"
+import { TodoTable } from "./session.sql"
export namespace Todo {
export const Info = z
@@ -9,7 +10,6 @@ export namespace Todo {
content: z.string().describe("Brief description of the task"),
status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"),
priority: z.string().describe("Priority level of the task: high, medium, low"),
- id: z.string().describe("Unique identifier for the todo item"),
})
.meta({ ref: "Todo" })
export type Info = z.infer
@@ -24,14 +24,33 @@ export namespace Todo {
),
}
- export async function update(input: { sessionID: string; todos: Info[] }) {
- await Storage.write(["todo", input.sessionID], input.todos)
+ export function update(input: { sessionID: string; todos: Info[] }) {
+ Database.transaction((db) => {
+ db.delete(TodoTable).where(eq(TodoTable.session_id, input.sessionID)).run()
+ if (input.todos.length === 0) return
+ db.insert(TodoTable)
+ .values(
+ input.todos.map((todo, position) => ({
+ session_id: input.sessionID,
+ content: todo.content,
+ status: todo.status,
+ priority: todo.priority,
+ position,
+ })),
+ )
+ .run()
+ })
Bus.publish(Event.Updated, input)
}
- export async function get(sessionID: string) {
- return Storage.read(["todo", sessionID])
- .then((x) => x || [])
- .catch(() => [])
+ export function get(sessionID: string) {
+ const rows = Database.use((db) =>
+ db.select().from(TodoTable).where(eq(TodoTable.session_id, sessionID)).orderBy(asc(TodoTable.position)).all(),
+ )
+ return rows.map((row) => ({
+ content: row.content,
+ status: row.status,
+ priority: row.priority,
+ }))
}
}
diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts
index 1298419f5e..14ad579359 100644
--- a/packages/opencode/src/share/share-next.ts
+++ b/packages/opencode/src/share/share-next.ts
@@ -4,7 +4,8 @@ import { ulid } from "ulid"
import { Provider } from "@/provider/provider"
import { Session } from "@/session"
import { MessageV2 } from "@/session/message-v2"
-import { Storage } from "@/storage/storage"
+import { Database, eq } from "@/storage/db"
+import { SessionShareTable } from "./share.sql"
import { Log } from "@/util/log"
import type * as SDK from "@kilocode/sdk/v2"
@@ -77,17 +78,26 @@ export namespace ShareNext {
})
.then((x) => x.json())
.then((x) => x as { id: string; url: string; secret: string })
- await Storage.write(["session_share", sessionID], result)
+ Database.use((db) =>
+ db
+ .insert(SessionShareTable)
+ .values({ session_id: sessionID, id: result.id, secret: result.secret, url: result.url })
+ .onConflictDoUpdate({
+ target: SessionShareTable.session_id,
+ set: { id: result.id, secret: result.secret, url: result.url },
+ })
+ .run(),
+ )
fullSync(sessionID)
return result
}
function get(sessionID: string) {
- return Storage.read<{
- id: string
- secret: string
- url: string
- }>(["session_share", sessionID])
+ const row = Database.use((db) =>
+ db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).get(),
+ )
+ if (!row) return
+ return { id: row.id, secret: row.secret, url: row.url }
}
type Data =
@@ -132,7 +142,7 @@ export namespace ShareNext {
const queued = queue.get(sessionID)
if (!queued) return
queue.delete(sessionID)
- const share = await get(sessionID).catch(() => undefined)
+ const share = get(sessionID)
if (!share) return
await fetch(`${await url()}/api/share/${share.id}/sync`, {
@@ -152,7 +162,7 @@ export namespace ShareNext {
export async function remove(sessionID: string) {
if (disabled) return
log.info("removing share", { sessionID })
- const share = await get(sessionID)
+ const share = get(sessionID)
if (!share) return
await fetch(`${await url()}/api/share/${share.id}`, {
method: "DELETE",
@@ -163,7 +173,7 @@ export namespace ShareNext {
secret: share.secret,
}),
})
- await Storage.remove(["session_share", sessionID])
+ Database.use((db) => db.delete(SessionShareTable).where(eq(SessionShareTable.session_id, sessionID)).run())
}
async function fullSync(sessionID: string) {
diff --git a/packages/opencode/src/share/share.sql.ts b/packages/opencode/src/share/share.sql.ts
new file mode 100644
index 0000000000..268d41a6f6
--- /dev/null
+++ b/packages/opencode/src/share/share.sql.ts
@@ -0,0 +1,13 @@
+import { sqliteTable, text } from "drizzle-orm/sqlite-core"
+import { SessionTable } from "../session/session.sql"
+import { Timestamps } from "@/storage/schema.sql"
+
+export const SessionShareTable = sqliteTable("session_share", {
+ session_id: text()
+ .primaryKey()
+ .references(() => SessionTable.id, { onDelete: "cascade" }),
+ id: text().notNull(),
+ secret: text().notNull(),
+ url: text().notNull(),
+ ...Timestamps,
+})
diff --git a/packages/opencode/src/sql.d.ts b/packages/opencode/src/sql.d.ts
new file mode 100644
index 0000000000..28cb1e2649
--- /dev/null
+++ b/packages/opencode/src/sql.d.ts
@@ -0,0 +1,4 @@
+declare module "*.sql" {
+ const content: string
+ export default content
+}
diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts
new file mode 100644
index 0000000000..50aa76384e
--- /dev/null
+++ b/packages/opencode/src/storage/db.ts
@@ -0,0 +1,140 @@
+import { Database as BunDatabase } from "bun:sqlite"
+import { drizzle, type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
+import { migrate } from "drizzle-orm/bun-sqlite/migrator"
+import { type SQLiteTransaction } from "drizzle-orm/sqlite-core"
+export * from "drizzle-orm"
+import { Context } from "../util/context"
+import { lazy } from "../util/lazy"
+import { Global } from "../global"
+import { Log } from "../util/log"
+import { NamedError } from "@opencode-ai/util/error"
+import z from "zod"
+import path from "path"
+import { readFileSync, readdirSync } from "fs"
+import * as schema from "./schema"
+
+declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number }[] | undefined
+
+export const NotFoundError = NamedError.create(
+ "NotFoundError",
+ z.object({
+ message: z.string(),
+ }),
+)
+
+const log = Log.create({ service: "db" })
+
+export namespace Database {
+ type Schema = typeof schema
+ export type Transaction = SQLiteTransaction<"sync", void, Schema>
+
+ type Client = SQLiteBunDatabase
+
+ type Journal = { sql: string; timestamp: number }[]
+
+ function time(tag: string) {
+ const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(tag)
+ if (!match) return 0
+ return Date.UTC(
+ Number(match[1]),
+ Number(match[2]) - 1,
+ Number(match[3]),
+ Number(match[4]),
+ Number(match[5]),
+ Number(match[6]),
+ )
+ }
+
+ function migrations(dir: string): Journal {
+ const dirs = readdirSync(dir, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => entry.name)
+
+ const sql = dirs
+ .map((name) => {
+ const file = path.join(dir, name, "migration.sql")
+ if (!Bun.file(file).size) return
+ return {
+ sql: readFileSync(file, "utf-8"),
+ timestamp: time(name),
+ }
+ })
+ .filter(Boolean) as Journal
+
+ return sql.sort((a, b) => a.timestamp - b.timestamp)
+ }
+
+ export const Client = lazy(() => {
+ log.info("opening database", { path: path.join(Global.Path.data, "opencode.db") })
+
+ const sqlite = new BunDatabase(path.join(Global.Path.data, "opencode.db"), { create: true })
+
+ sqlite.run("PRAGMA journal_mode = WAL")
+ sqlite.run("PRAGMA synchronous = NORMAL")
+ sqlite.run("PRAGMA busy_timeout = 5000")
+ sqlite.run("PRAGMA cache_size = -64000")
+ sqlite.run("PRAGMA foreign_keys = ON")
+
+ const db = drizzle({ client: sqlite, schema })
+
+ // Apply schema migrations
+ const entries =
+ typeof OPENCODE_MIGRATIONS !== "undefined"
+ ? OPENCODE_MIGRATIONS
+ : migrations(path.join(import.meta.dirname, "../../migration"))
+ if (entries.length > 0) {
+ log.info("applying migrations", {
+ count: entries.length,
+ mode: typeof OPENCODE_MIGRATIONS !== "undefined" ? "bundled" : "dev",
+ })
+ migrate(db, entries)
+ }
+
+ return db
+ })
+
+ export type TxOrDb = Transaction | Client
+
+ const ctx = Context.create<{
+ tx: TxOrDb
+ effects: (() => void | Promise)[]
+ }>("database")
+
+ export function use(callback: (trx: TxOrDb) => T): T {
+ try {
+ return callback(ctx.use().tx)
+ } catch (err) {
+ if (err instanceof Context.NotFound) {
+ const effects: (() => void | Promise)[] = []
+ const result = ctx.provide({ effects, tx: Client() }, () => callback(Client()))
+ for (const effect of effects) effect()
+ return result
+ }
+ throw err
+ }
+ }
+
+ export function effect(fn: () => any | Promise) {
+ try {
+ ctx.use().effects.push(fn)
+ } catch {
+ fn()
+ }
+ }
+
+ export function transaction(callback: (tx: TxOrDb) => T): T {
+ try {
+ return callback(ctx.use().tx)
+ } catch (err) {
+ if (err instanceof Context.NotFound) {
+ const effects: (() => void | Promise)[] = []
+ const result = Client().transaction((tx) => {
+ return ctx.provide({ tx, effects }, () => callback(tx))
+ })
+ for (const effect of effects) effect()
+ return result
+ }
+ throw err
+ }
+ }
+}
diff --git a/packages/opencode/src/storage/json-migration.ts b/packages/opencode/src/storage/json-migration.ts
new file mode 100644
index 0000000000..89d561188c
--- /dev/null
+++ b/packages/opencode/src/storage/json-migration.ts
@@ -0,0 +1,437 @@
+import { Database } from "bun:sqlite"
+import { drizzle } from "drizzle-orm/bun-sqlite"
+import { Global } from "../global"
+import { Log } from "../util/log"
+import { ProjectTable } from "../project/project.sql"
+import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
+import { SessionShareTable } from "../share/share.sql"
+import path from "path"
+import { existsSync } from "fs"
+
+export namespace JsonMigration {
+ const log = Log.create({ service: "json-migration" })
+
+ export type Progress = {
+ current: number
+ total: number
+ label: string
+ }
+
+ type Options = {
+ progress?: (event: Progress) => void
+ }
+
+ export async function run(sqlite: Database, options?: Options) {
+ const storageDir = path.join(Global.Path.data, "storage")
+
+ if (!existsSync(storageDir)) {
+ log.info("storage directory does not exist, skipping migration")
+ return {
+ projects: 0,
+ sessions: 0,
+ messages: 0,
+ parts: 0,
+ todos: 0,
+ permissions: 0,
+ shares: 0,
+ errors: [] as string[],
+ }
+ }
+
+ log.info("starting json to sqlite migration", { storageDir })
+ const start = performance.now()
+
+ const db = drizzle({ client: sqlite })
+
+ // Optimize SQLite for bulk inserts
+ sqlite.exec("PRAGMA journal_mode = WAL")
+ sqlite.exec("PRAGMA synchronous = OFF")
+ sqlite.exec("PRAGMA cache_size = 10000")
+ sqlite.exec("PRAGMA temp_store = MEMORY")
+ const stats = {
+ projects: 0,
+ sessions: 0,
+ messages: 0,
+ parts: 0,
+ todos: 0,
+ permissions: 0,
+ shares: 0,
+ errors: [] as string[],
+ }
+ const orphans = {
+ sessions: 0,
+ todos: 0,
+ permissions: 0,
+ shares: 0,
+ }
+ const errs = stats.errors
+
+ const batchSize = 1000
+ const now = Date.now()
+
+ async function list(pattern: string) {
+ const items: string[] = []
+ const scan = new Bun.Glob(pattern)
+ for await (const file of scan.scan({ cwd: storageDir, absolute: true })) {
+ items.push(file)
+ }
+ return items
+ }
+
+ async function read(files: string[], start: number, end: number) {
+ const count = end - start
+ const tasks = new Array(count)
+ for (let i = 0; i < count; i++) {
+ tasks[i] = Bun.file(files[start + i]).json()
+ }
+ const results = await Promise.allSettled(tasks)
+ const items = new Array(count)
+ for (let i = 0; i < results.length; i++) {
+ const result = results[i]
+ if (result.status === "fulfilled") {
+ items[i] = result.value
+ continue
+ }
+ errs.push(`failed to read ${files[start + i]}: ${result.reason}`)
+ }
+ return items
+ }
+
+ function insert(values: any[], table: any, label: string) {
+ if (values.length === 0) return 0
+ try {
+ db.insert(table).values(values).onConflictDoNothing().run()
+ return values.length
+ } catch (e) {
+ errs.push(`failed to migrate ${label} batch: ${e}`)
+ return 0
+ }
+ }
+
+ // Pre-scan all files upfront to avoid repeated glob operations
+ log.info("scanning files...")
+ const [projectFiles, sessionFiles, messageFiles, partFiles, todoFiles, permFiles, shareFiles] = await Promise.all([
+ list("project/*.json"),
+ list("session/*/*.json"),
+ list("message/*/*.json"),
+ list("part/*/*.json"),
+ list("todo/*.json"),
+ list("permission/*.json"),
+ list("session_share/*.json"),
+ ])
+
+ log.info("file scan complete", {
+ projects: projectFiles.length,
+ sessions: sessionFiles.length,
+ messages: messageFiles.length,
+ parts: partFiles.length,
+ todos: todoFiles.length,
+ permissions: permFiles.length,
+ shares: shareFiles.length,
+ })
+
+ const total = Math.max(
+ 1,
+ projectFiles.length +
+ sessionFiles.length +
+ messageFiles.length +
+ partFiles.length +
+ todoFiles.length +
+ permFiles.length +
+ shareFiles.length,
+ )
+ const progress = options?.progress
+ let current = 0
+ const step = (label: string, count: number) => {
+ current = Math.min(total, current + count)
+ progress?.({ current, total, label })
+ }
+
+ progress?.({ current, total, label: "starting" })
+
+ sqlite.exec("BEGIN TRANSACTION")
+
+ // Migrate projects first (no FK deps)
+ const projectIds = new Set()
+ const projectValues = [] as any[]
+ for (let i = 0; i < projectFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, projectFiles.length)
+ const batch = await read(projectFiles, i, end)
+ projectValues.length = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ if (!data?.id) {
+ errs.push(`project missing id: ${projectFiles[i + j]}`)
+ continue
+ }
+ projectIds.add(data.id)
+ projectValues.push({
+ id: data.id,
+ worktree: data.worktree ?? "/",
+ vcs: data.vcs,
+ name: data.name ?? undefined,
+ icon_url: data.icon?.url,
+ icon_color: data.icon?.color,
+ time_created: data.time?.created ?? now,
+ time_updated: data.time?.updated ?? now,
+ time_initialized: data.time?.initialized,
+ sandboxes: data.sandboxes ?? [],
+ commands: data.commands,
+ })
+ }
+ stats.projects += insert(projectValues, ProjectTable, "project")
+ step("projects", end - i)
+ }
+ log.info("migrated projects", { count: stats.projects, duration: Math.round(performance.now() - start) })
+
+ // Migrate sessions (depends on projects)
+ const sessionIds = new Set()
+ const sessionValues = [] as any[]
+ for (let i = 0; i < sessionFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, sessionFiles.length)
+ const batch = await read(sessionFiles, i, end)
+ sessionValues.length = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ if (!data?.id || !data?.projectID) {
+ errs.push(`session missing id or projectID: ${sessionFiles[i + j]}`)
+ continue
+ }
+ if (!projectIds.has(data.projectID)) {
+ orphans.sessions++
+ continue
+ }
+ sessionIds.add(data.id)
+ sessionValues.push({
+ id: data.id,
+ project_id: data.projectID,
+ parent_id: data.parentID ?? null,
+ slug: data.slug ?? "",
+ directory: data.directory ?? "",
+ title: data.title ?? "",
+ version: data.version ?? "",
+ share_url: data.share?.url ?? null,
+ summary_additions: data.summary?.additions ?? null,
+ summary_deletions: data.summary?.deletions ?? null,
+ summary_files: data.summary?.files ?? null,
+ summary_diffs: data.summary?.diffs ?? null,
+ revert: data.revert ?? null,
+ permission: data.permission ?? null,
+ time_created: data.time?.created ?? now,
+ time_updated: data.time?.updated ?? now,
+ time_compacting: data.time?.compacting ?? null,
+ time_archived: data.time?.archived ?? null,
+ })
+ }
+ stats.sessions += insert(sessionValues, SessionTable, "session")
+ step("sessions", end - i)
+ }
+ log.info("migrated sessions", { count: stats.sessions })
+ if (orphans.sessions > 0) {
+ log.warn("skipped orphaned sessions", { count: orphans.sessions })
+ }
+
+ // Migrate messages using pre-scanned file map
+ const allMessageFiles = [] as string[]
+ const allMessageSessions = [] as string[]
+ const messageSessions = new Map()
+ for (const file of messageFiles) {
+ const sessionID = path.basename(path.dirname(file))
+ if (!sessionIds.has(sessionID)) continue
+ allMessageFiles.push(file)
+ allMessageSessions.push(sessionID)
+ }
+
+ for (let i = 0; i < allMessageFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, allMessageFiles.length)
+ const batch = await read(allMessageFiles, i, end)
+ const values = new Array(batch.length)
+ let count = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const file = allMessageFiles[i + j]
+ const id = data.id ?? path.basename(file, ".json")
+ if (!id) {
+ errs.push(`message missing id: ${file}`)
+ continue
+ }
+ const sessionID = allMessageSessions[i + j]
+ messageSessions.set(id, sessionID)
+ const rest = data
+ delete rest.id
+ delete rest.sessionID
+ values[count++] = {
+ id,
+ session_id: sessionID,
+ time_created: data.time?.created ?? now,
+ time_updated: data.time?.updated ?? now,
+ data: rest,
+ }
+ }
+ values.length = count
+ stats.messages += insert(values, MessageTable, "message")
+ step("messages", end - i)
+ }
+ log.info("migrated messages", { count: stats.messages })
+
+ // Migrate parts using pre-scanned file map
+ for (let i = 0; i < partFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, partFiles.length)
+ const batch = await read(partFiles, i, end)
+ const values = new Array(batch.length)
+ let count = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const file = partFiles[i + j]
+ const id = data.id ?? path.basename(file, ".json")
+ const messageID = data.messageID ?? path.basename(path.dirname(file))
+ if (!id || !messageID) {
+ errs.push(`part missing id/messageID/sessionID: ${file}`)
+ continue
+ }
+ const sessionID = messageSessions.get(messageID)
+ if (!sessionID) {
+ errs.push(`part missing message session: ${file}`)
+ continue
+ }
+ if (!sessionIds.has(sessionID)) continue
+ const rest = data
+ delete rest.id
+ delete rest.messageID
+ delete rest.sessionID
+ values[count++] = {
+ id,
+ message_id: messageID,
+ session_id: sessionID,
+ time_created: data.time?.created ?? now,
+ time_updated: data.time?.updated ?? now,
+ data: rest,
+ }
+ }
+ values.length = count
+ stats.parts += insert(values, PartTable, "part")
+ step("parts", end - i)
+ }
+ log.info("migrated parts", { count: stats.parts })
+
+ // Migrate todos
+ const todoSessions = todoFiles.map((file) => path.basename(file, ".json"))
+ for (let i = 0; i < todoFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, todoFiles.length)
+ const batch = await read(todoFiles, i, end)
+ const values = [] as any[]
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const sessionID = todoSessions[i + j]
+ if (!sessionIds.has(sessionID)) {
+ orphans.todos++
+ continue
+ }
+ if (!Array.isArray(data)) {
+ errs.push(`todo not an array: ${todoFiles[i + j]}`)
+ continue
+ }
+ for (let position = 0; position < data.length; position++) {
+ const todo = data[position]
+ if (!todo?.content || !todo?.status || !todo?.priority) continue
+ values.push({
+ session_id: sessionID,
+ content: todo.content,
+ status: todo.status,
+ priority: todo.priority,
+ position,
+ time_created: now,
+ time_updated: now,
+ })
+ }
+ }
+ stats.todos += insert(values, TodoTable, "todo")
+ step("todos", end - i)
+ }
+ log.info("migrated todos", { count: stats.todos })
+ if (orphans.todos > 0) {
+ log.warn("skipped orphaned todos", { count: orphans.todos })
+ }
+
+ // Migrate permissions
+ const permProjects = permFiles.map((file) => path.basename(file, ".json"))
+ const permValues = [] as any[]
+ for (let i = 0; i < permFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, permFiles.length)
+ const batch = await read(permFiles, i, end)
+ permValues.length = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const projectID = permProjects[i + j]
+ if (!projectIds.has(projectID)) {
+ orphans.permissions++
+ continue
+ }
+ permValues.push({ project_id: projectID, data })
+ }
+ stats.permissions += insert(permValues, PermissionTable, "permission")
+ step("permissions", end - i)
+ }
+ log.info("migrated permissions", { count: stats.permissions })
+ if (orphans.permissions > 0) {
+ log.warn("skipped orphaned permissions", { count: orphans.permissions })
+ }
+
+ // Migrate session shares
+ const shareSessions = shareFiles.map((file) => path.basename(file, ".json"))
+ const shareValues = [] as any[]
+ for (let i = 0; i < shareFiles.length; i += batchSize) {
+ const end = Math.min(i + batchSize, shareFiles.length)
+ const batch = await read(shareFiles, i, end)
+ shareValues.length = 0
+ for (let j = 0; j < batch.length; j++) {
+ const data = batch[j]
+ if (!data) continue
+ const sessionID = shareSessions[i + j]
+ if (!sessionIds.has(sessionID)) {
+ orphans.shares++
+ continue
+ }
+ if (!data?.id || !data?.secret || !data?.url) {
+ errs.push(`session_share missing id/secret/url: ${shareFiles[i + j]}`)
+ continue
+ }
+ shareValues.push({ session_id: sessionID, id: data.id, secret: data.secret, url: data.url })
+ }
+ stats.shares += insert(shareValues, SessionShareTable, "session_share")
+ step("shares", end - i)
+ }
+ log.info("migrated session shares", { count: stats.shares })
+ if (orphans.shares > 0) {
+ log.warn("skipped orphaned session shares", { count: orphans.shares })
+ }
+
+ sqlite.exec("COMMIT")
+
+ log.info("json migration complete", {
+ projects: stats.projects,
+ sessions: stats.sessions,
+ messages: stats.messages,
+ parts: stats.parts,
+ todos: stats.todos,
+ permissions: stats.permissions,
+ shares: stats.shares,
+ errorCount: stats.errors.length,
+ duration: Math.round(performance.now() - start),
+ })
+
+ if (stats.errors.length > 0) {
+ log.warn("migration errors", { errors: stats.errors.slice(0, 20) })
+ }
+
+ progress?.({ current: total, total, label: "complete" })
+
+ return stats
+ }
+}
diff --git a/packages/opencode/src/storage/schema.sql.ts b/packages/opencode/src/storage/schema.sql.ts
new file mode 100644
index 0000000000..ead3518dee
--- /dev/null
+++ b/packages/opencode/src/storage/schema.sql.ts
@@ -0,0 +1,10 @@
+import { integer } from "drizzle-orm/sqlite-core"
+
+export const Timestamps = {
+ time_created: integer()
+ .notNull()
+ .$default(() => Date.now()),
+ time_updated: integer()
+ .notNull()
+ .$onUpdate(() => Date.now()),
+}
diff --git a/packages/opencode/src/storage/schema.ts b/packages/opencode/src/storage/schema.ts
new file mode 100644
index 0000000000..7961b0e380
--- /dev/null
+++ b/packages/opencode/src/storage/schema.ts
@@ -0,0 +1,4 @@
+export { ControlAccountTable } from "../control/control.sql"
+export { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../session/session.sql"
+export { SessionShareTable } from "../share/share.sql"
+export { ProjectTable } from "../project/project.sql"
diff --git a/packages/opencode/src/util/lazy.ts b/packages/opencode/src/util/lazy.ts
index 0cc6d8d5c4..55643dc6a7 100644
--- a/packages/opencode/src/util/lazy.ts
+++ b/packages/opencode/src/util/lazy.ts
@@ -4,9 +4,14 @@ export function lazy(fn: () => T) {
const result = (): T => {
if (loaded) return value as T
- loaded = true
- value = fn()
- return value as T
+ try {
+ value = fn()
+ loaded = true
+ return value as T
+ } catch (e) {
+ // Don't mark as loaded if initialization failed
+ throw e
+ }
}
result.reset = () => {
diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index 85d7f6d0e8..d85a0843fb 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/packages/opencode/src/worktree/index.ts
@@ -7,7 +7,8 @@ import { Global } from "../global"
import { Instance } from "../project/instance"
import { InstanceBootstrap } from "../project/bootstrap"
import { Project } from "../project/project"
-import { Storage } from "../storage/storage"
+import { Database, eq } from "../storage/db"
+import { ProjectTable } from "../project/project.sql"
import { fn } from "../util/fn"
import { Log } from "../util/log"
import { BusEvent } from "@/bus/bus-event"
@@ -307,7 +308,8 @@ export namespace Worktree {
}
async function runStartScripts(directory: string, input: { projectID: string; extra?: string }) {
- const project = await Storage.read(["project", input.projectID]).catch(() => undefined)
+ const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get())
+ const project = row ? Project.fromRow(row) : undefined
const startup = project?.commands?.start?.trim() ?? ""
const ok = await runStartScript(directory, startup, "project")
if (!ok) return false
diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts
index d9fec6b29e..d7fc855892 100644
--- a/packages/opencode/test/acp/event-subscription.test.ts
+++ b/packages/opencode/test/acp/event-subscription.test.ts
@@ -122,12 +122,20 @@ function createFakeAgent() {
messages: async () => {
return { data: [] }
},
- message: async () => {
+ message: async (params?: any) => {
+ // Return a message with parts that can be looked up by partID
return {
data: {
info: {
role: "assistant",
},
+ parts: [
+ {
+ id: params?.messageID ? `${params.messageID}_part` : "part_1",
+ type: "text",
+ text: "",
+ },
+ ],
},
}
},
@@ -195,7 +203,7 @@ function createFakeAgent() {
}
describe("acp.agent event subscription", () => {
- test("routes message.part.updated by the event sessionID (no cross-session pollution)", async () => {
+ test("routes message.part.delta by the event sessionID (no cross-session pollution)", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
@@ -209,14 +217,12 @@ describe("acp.agent event subscription", () => {
controller.push({
directory: cwd,
payload: {
- type: "message.part.updated",
+ type: "message.part.delta",
properties: {
- part: {
- sessionID: sessionB,
- messageID: "msg_1",
- type: "text",
- synthetic: false,
- },
+ sessionID: sessionB,
+ messageID: "msg_1",
+ partID: "msg_1_part",
+ field: "text",
delta: "hello",
},
},
@@ -232,7 +238,7 @@ describe("acp.agent event subscription", () => {
})
})
- test("keeps concurrent sessions isolated when message.part.updated events are interleaved", async () => {
+ test("keeps concurrent sessions isolated when message.part.delta events are interleaved", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
@@ -250,14 +256,12 @@ describe("acp.agent event subscription", () => {
controller.push({
directory: cwd,
payload: {
- type: "message.part.updated",
+ type: "message.part.delta",
properties: {
- part: {
- sessionID: sessionId,
- messageID,
- type: "text",
- synthetic: false,
- },
+ sessionID: sessionId,
+ messageID,
+ partID: `${messageID}_part`,
+ field: "text",
delta,
},
},
@@ -404,14 +408,12 @@ describe("acp.agent event subscription", () => {
controller.push({
directory: cwd,
payload: {
- type: "message.part.updated",
+ type: "message.part.delta",
properties: {
- part: {
- sessionID: sessionB,
- messageID: "msg_b",
- type: "text",
- synthetic: false,
- },
+ sessionID: sessionB,
+ messageID: "msg_b",
+ partID: "msg_b_part",
+ field: "text",
delta: "session_b_message",
},
},
diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts
index cf62150566..593b258906 100644
--- a/packages/opencode/test/kilocode/plan-followup.test.ts
+++ b/packages/opencode/test/kilocode/plan-followup.test.ts
@@ -283,8 +283,8 @@ describe("plan follow-up", () => {
await Todo.update({
sessionID: seeded.sessionID,
todos: [
- { id: "1", content: "Add API endpoint", status: "completed", priority: "high" },
- { id: "2", content: "Write tests", status: "pending", priority: "medium" },
+ { content: "Add API endpoint", status: "completed", priority: "high" },
+ { content: "Write tests", status: "pending", priority: "medium" },
],
})
@@ -338,8 +338,8 @@ describe("plan follow-up", () => {
const newTodos = await Todo.get(newSessionID)
expect(newTodos).toHaveLength(2)
- expect(newTodos).toContainEqual({ id: "1", content: "Add API endpoint", status: "completed", priority: "high" })
- expect(newTodos).toContainEqual({ id: "2", content: "Write tests", status: "pending", priority: "medium" })
+ expect(newTodos).toContainEqual({ content: "Add API endpoint", status: "completed", priority: "high" })
+ expect(newTodos).toContainEqual({ content: "Write tests", status: "pending", priority: "medium" })
SessionPrompt.cancel(newSessionID)
}))
@@ -477,10 +477,10 @@ describe("plan follow-up", () => {
test("formatTodos - formats todos with status icons", () => {
const todos: Todo.Info[] = [
- { id: "1", content: "Set up project", status: "completed", priority: "high" },
- { id: "2", content: "Write code", status: "in_progress", priority: "high" },
- { id: "3", content: "Add tests", status: "pending", priority: "medium" },
- { id: "4", content: "Dropped task", status: "cancelled", priority: "low" },
+ { content: "Set up project", status: "completed", priority: "high" },
+ { content: "Write code", status: "in_progress", priority: "high" },
+ { content: "Add tests", status: "pending", priority: "medium" },
+ { content: "Dropped task", status: "cancelled", priority: "low" },
]
const result = formatTodos(todos)
expect(result).toBe("- [x] Set up project\n- [~] Write code\n- [ ] Add tests\n- [-] Dropped task")
diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts
index 29f1efa401..add3332048 100644
--- a/packages/opencode/test/permission/next.test.ts
+++ b/packages/opencode/test/permission/next.test.ts
@@ -2,7 +2,6 @@ import { test, expect } from "bun:test"
import os from "os"
import { PermissionNext } from "../../src/permission/next"
import { Instance } from "../../src/project/instance"
-import { Storage } from "../../src/storage/storage"
import { tmpdir } from "../fixture/fixture"
// fromConfig tests
diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts
index 7e34839d42..a832935627 100644
--- a/packages/opencode/test/preload.ts
+++ b/packages/opencode/test/preload.ts
@@ -6,11 +6,19 @@ import fs from "fs/promises"
import fsSync from "fs"
import { afterAll } from "bun:test"
+// Set XDG env vars FIRST, before any src/ imports
const dir = path.join(os.tmpdir(), "opencode-test-data-" + process.pid)
await fs.mkdir(dir, { recursive: true })
afterAll(() => {
fsSync.rmSync(dir, { recursive: true, force: true })
})
+
+process.env["XDG_DATA_HOME"] = path.join(dir, "share")
+process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
+process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
+process.env["XDG_STATE_HOME"] = path.join(dir, "state")
+process.env["OPENCODE_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
+
// Set test home directory to isolate tests from user's actual home directory
// This prevents tests from picking up real user configs/skills from ~/.claude/skills
const testHome = path.join(dir, "home")
@@ -21,12 +29,6 @@ process.env["KILO_TEST_HOME"] = testHome
const testManagedConfigDir = path.join(dir, "managed")
process.env["KILO_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir
-process.env["XDG_DATA_HOME"] = path.join(dir, "share")
-process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
-process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
-process.env["XDG_STATE_HOME"] = path.join(dir, "state")
-process.env["KILO_MODELS_PATH"] = path.join(import.meta.dir, "tool", "fixtures", "models-api.json")
-
// Write the cache version file to prevent global/index.ts from clearing the cache
const cacheDir = path.join(dir, "cache", "opencode")
await fs.mkdir(cacheDir, { recursive: true })
diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts
index 581c63b567..19f9821c42 100644
--- a/packages/opencode/test/project/project.test.ts
+++ b/packages/opencode/test/project/project.test.ts
@@ -1,10 +1,10 @@
import { describe, expect, mock, test } from "bun:test"
-import type { Project as ProjectNS } from "../../src/project/project"
+import { Project } from "../../src/project/project"
import { Log } from "../../src/util/log"
-import { Storage } from "../../src/storage/storage"
import { $ } from "bun"
import path from "path"
import { tmpdir } from "../fixture/fixture"
+import { GlobalBus } from "../../src/bus/global"
Log.init({ print: false })
@@ -152,38 +152,51 @@ describe("Project.fromDirectory with worktrees", () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
- const worktreePath = path.join(tmp.path, "..", "worktree-test")
- await $`git worktree add ${worktreePath} -b test-branch`.cwd(tmp.path).quiet()
+ const worktreePath = path.join(tmp.path, "..", path.basename(tmp.path) + "-worktree")
+ try {
+ await $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp.path).quiet()
- const { project, sandbox } = await p.fromDirectory(worktreePath)
+ const { project, sandbox } = await p.fromDirectory(worktreePath)
- expect(project.worktree).toBe(tmp.path)
- expect(sandbox).toBe(worktreePath)
- expect(project.sandboxes).toContain(worktreePath)
- expect(project.sandboxes).not.toContain(tmp.path)
-
- await $`git worktree remove ${worktreePath}`.cwd(tmp.path).quiet()
+ expect(project.worktree).toBe(tmp.path)
+ expect(sandbox).toBe(worktreePath)
+ expect(project.sandboxes).toContain(worktreePath)
+ expect(project.sandboxes).not.toContain(tmp.path)
+ } finally {
+ await $`git worktree remove ${worktreePath}`
+ .cwd(tmp.path)
+ .quiet()
+ .catch(() => {})
+ }
})
test("should accumulate multiple worktrees in sandboxes", async () => {
const p = await loadProject()
await using tmp = await tmpdir({ git: true })
- const worktree1 = path.join(tmp.path, "..", "worktree-1")
- const worktree2 = path.join(tmp.path, "..", "worktree-2")
- await $`git worktree add ${worktree1} -b branch-1`.cwd(tmp.path).quiet()
- await $`git worktree add ${worktree2} -b branch-2`.cwd(tmp.path).quiet()
+ const worktree1 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt1")
+ const worktree2 = path.join(tmp.path, "..", path.basename(tmp.path) + "-wt2")
+ try {
+ await $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp.path).quiet()
+ await $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp.path).quiet()
- await p.fromDirectory(worktree1)
- const { project } = await p.fromDirectory(worktree2)
+ await p.fromDirectory(worktree1)
+ const { project } = await p.fromDirectory(worktree2)
- expect(project.worktree).toBe(tmp.path)
- expect(project.sandboxes).toContain(worktree1)
- expect(project.sandboxes).toContain(worktree2)
- expect(project.sandboxes).not.toContain(tmp.path)
-
- await $`git worktree remove ${worktree1}`.cwd(tmp.path).quiet()
- await $`git worktree remove ${worktree2}`.cwd(tmp.path).quiet()
+ expect(project.worktree).toBe(tmp.path)
+ expect(project.sandboxes).toContain(worktree1)
+ expect(project.sandboxes).toContain(worktree2)
+ expect(project.sandboxes).not.toContain(tmp.path)
+ } finally {
+ await $`git worktree remove ${worktree1}`
+ .cwd(tmp.path)
+ .quiet()
+ .catch(() => {})
+ await $`git worktree remove ${worktree2}`
+ .cwd(tmp.path)
+ .quiet()
+ .catch(() => {})
+ }
})
})
@@ -198,11 +211,12 @@ describe("Project.discover", () => {
await p.discover(project)
- const updated = await Storage.read(["project", project.id])
- expect(updated.icon).toBeDefined()
- expect(updated.icon?.url).toStartWith("data:")
- expect(updated.icon?.url).toContain("base64")
- expect(updated.icon?.color).toBeUndefined()
+ const updated = Project.get(project.id)
+ expect(updated).toBeDefined()
+ expect(updated!.icon).toBeDefined()
+ expect(updated!.icon?.url).toStartWith("data:")
+ expect(updated!.icon?.url).toContain("base64")
+ expect(updated!.icon?.color).toBeUndefined()
})
test("should not discover non-image files", async () => {
@@ -214,7 +228,120 @@ describe("Project.discover", () => {
await p.discover(project)
- const updated = await Storage.read(["project", project.id])
- expect(updated.icon).toBeUndefined()
+ const updated = Project.get(project.id)
+ expect(updated).toBeDefined()
+ expect(updated!.icon).toBeUndefined()
+ })
+})
+
+describe("Project.update", () => {
+ test("should update name", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const updated = await Project.update({
+ projectID: project.id,
+ name: "New Project Name",
+ })
+
+ expect(updated.name).toBe("New Project Name")
+
+ const fromDb = Project.get(project.id)
+ expect(fromDb?.name).toBe("New Project Name")
+ })
+
+ test("should update icon url", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const updated = await Project.update({
+ projectID: project.id,
+ icon: { url: "https://example.com/icon.png" },
+ })
+
+ expect(updated.icon?.url).toBe("https://example.com/icon.png")
+
+ const fromDb = Project.get(project.id)
+ expect(fromDb?.icon?.url).toBe("https://example.com/icon.png")
+ })
+
+ test("should update icon color", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const updated = await Project.update({
+ projectID: project.id,
+ icon: { color: "#ff0000" },
+ })
+
+ expect(updated.icon?.color).toBe("#ff0000")
+
+ const fromDb = Project.get(project.id)
+ expect(fromDb?.icon?.color).toBe("#ff0000")
+ })
+
+ test("should update commands", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const updated = await Project.update({
+ projectID: project.id,
+ commands: { start: "npm run dev" },
+ })
+
+ expect(updated.commands?.start).toBe("npm run dev")
+
+ const fromDb = Project.get(project.id)
+ expect(fromDb?.commands?.start).toBe("npm run dev")
+ })
+
+ test("should throw error when project not found", async () => {
+ await using tmp = await tmpdir({ git: true })
+
+ await expect(
+ Project.update({
+ projectID: "nonexistent-project-id",
+ name: "Should Fail",
+ }),
+ ).rejects.toThrow("Project not found: nonexistent-project-id")
+ })
+
+ test("should emit GlobalBus event on update", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ let eventFired = false
+ let eventPayload: any = null
+
+ GlobalBus.on("event", (data) => {
+ eventFired = true
+ eventPayload = data
+ })
+
+ await Project.update({
+ projectID: project.id,
+ name: "Updated Name",
+ })
+
+ expect(eventFired).toBe(true)
+ expect(eventPayload.payload.type).toBe("project.updated")
+ expect(eventPayload.payload.properties.name).toBe("Updated Name")
+ })
+
+ test("should update multiple fields at once", async () => {
+ await using tmp = await tmpdir({ git: true })
+ const { project } = await Project.fromDirectory(tmp.path)
+
+ const updated = await Project.update({
+ projectID: project.id,
+ name: "Multi Update",
+ icon: { url: "https://example.com/favicon.ico", color: "#00ff00" },
+ commands: { start: "make start" },
+ })
+
+ expect(updated.name).toBe("Multi Update")
+ expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
+ expect(updated.icon?.color).toBe("#00ff00")
+ expect(updated.commands?.start).toBe("make start")
})
})
diff --git a/packages/opencode/test/storage/json-migration.test.ts b/packages/opencode/test/storage/json-migration.test.ts
new file mode 100644
index 0000000000..ff05d6d059
--- /dev/null
+++ b/packages/opencode/test/storage/json-migration.test.ts
@@ -0,0 +1,687 @@
+import { describe, test, expect, beforeEach, afterEach } from "bun:test"
+import { Database } from "bun:sqlite"
+import { drizzle } from "drizzle-orm/bun-sqlite"
+import { migrate } from "drizzle-orm/bun-sqlite/migrator"
+import path from "path"
+import fs from "fs/promises"
+import { readFileSync, readdirSync } from "fs"
+import { JsonMigration } from "../../src/storage/json-migration"
+import { Global } from "../../src/global"
+import { ProjectTable } from "../../src/project/project.sql"
+import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../../src/session/session.sql"
+import { SessionShareTable } from "../../src/share/share.sql"
+
+// Test fixtures
+const fixtures = {
+ project: {
+ id: "proj_test123abc",
+ name: "Test Project",
+ worktree: "/test/path",
+ vcs: "git" as const,
+ sandboxes: [],
+ },
+ session: {
+ id: "ses_test456def",
+ projectID: "proj_test123abc",
+ slug: "test-session",
+ directory: "/test/path",
+ title: "Test Session",
+ version: "1.0.0",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ },
+ message: {
+ id: "msg_test789ghi",
+ sessionID: "ses_test456def",
+ role: "user" as const,
+ agent: "default",
+ model: { providerID: "openai", modelID: "gpt-4" },
+ time: { created: 1700000000000 },
+ },
+ part: {
+ id: "prt_testabc123",
+ messageID: "msg_test789ghi",
+ sessionID: "ses_test456def",
+ type: "text" as const,
+ text: "Hello, world!",
+ },
+}
+
+// Helper to create test storage directory structure
+async function setupStorageDir() {
+ const storageDir = path.join(Global.Path.data, "storage")
+ await fs.rm(storageDir, { recursive: true, force: true })
+ await fs.mkdir(path.join(storageDir, "project"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "session", "proj_test123abc"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "message", "ses_test456def"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "part", "msg_test789ghi"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "session_diff"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "todo"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "permission"), { recursive: true })
+ await fs.mkdir(path.join(storageDir, "session_share"), { recursive: true })
+ // Create legacy marker to indicate JSON storage exists
+ await Bun.write(path.join(storageDir, "migration"), "1")
+ return storageDir
+}
+
+async function writeProject(storageDir: string, project: Record) {
+ await Bun.write(path.join(storageDir, "project", `${project.id}.json`), JSON.stringify(project))
+}
+
+async function writeSession(storageDir: string, projectID: string, session: Record) {
+ await Bun.write(path.join(storageDir, "session", projectID, `${session.id}.json`), JSON.stringify(session))
+}
+
+// Helper to create in-memory test database with schema
+function createTestDb() {
+ const sqlite = new Database(":memory:")
+ sqlite.exec("PRAGMA foreign_keys = ON")
+
+ // Apply schema migrations using drizzle migrate
+ const dir = path.join(import.meta.dirname, "../../migration")
+ const entries = readdirSync(dir, { withFileTypes: true })
+ const migrations = entries
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => ({
+ sql: readFileSync(path.join(dir, entry.name, "migration.sql"), "utf-8"),
+ timestamp: Number(entry.name.split("_")[0]),
+ }))
+ .sort((a, b) => a.timestamp - b.timestamp)
+ migrate(drizzle({ client: sqlite }), migrations)
+
+ return sqlite
+}
+
+describe("JSON to SQLite migration", () => {
+ let storageDir: string
+ let sqlite: Database
+
+ beforeEach(async () => {
+ storageDir = await setupStorageDir()
+ sqlite = createTestDb()
+ })
+
+ afterEach(async () => {
+ sqlite.close()
+ await fs.rm(storageDir, { recursive: true, force: true })
+ })
+
+ test("migrates project", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/test/path",
+ vcs: "git",
+ name: "Test Project",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ sandboxes: ["/test/sandbox"],
+ })
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.projects).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const projects = db.select().from(ProjectTable).all()
+ expect(projects.length).toBe(1)
+ expect(projects[0].id).toBe("proj_test123abc")
+ expect(projects[0].worktree).toBe("/test/path")
+ expect(projects[0].name).toBe("Test Project")
+ expect(projects[0].sandboxes).toEqual(["/test/sandbox"])
+ })
+
+ test("migrates project with commands", async () => {
+ await writeProject(storageDir, {
+ id: "proj_with_commands",
+ worktree: "/test/path",
+ vcs: "git",
+ name: "Project With Commands",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ sandboxes: ["/test/sandbox"],
+ commands: { start: "npm run dev" },
+ })
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.projects).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const projects = db.select().from(ProjectTable).all()
+ expect(projects.length).toBe(1)
+ expect(projects[0].id).toBe("proj_with_commands")
+ expect(projects[0].commands).toEqual({ start: "npm run dev" })
+ })
+
+ test("migrates project without commands field", async () => {
+ await writeProject(storageDir, {
+ id: "proj_no_commands",
+ worktree: "/test/path",
+ vcs: "git",
+ name: "Project Without Commands",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ sandboxes: [],
+ })
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.projects).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const projects = db.select().from(ProjectTable).all()
+ expect(projects.length).toBe(1)
+ expect(projects[0].id).toBe("proj_no_commands")
+ expect(projects[0].commands).toBeNull()
+ })
+
+ test("migrates session with individual columns", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/test/path",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+
+ await writeSession(storageDir, "proj_test123abc", {
+ id: "ses_test456def",
+ projectID: "proj_test123abc",
+ slug: "test-session",
+ directory: "/test/dir",
+ title: "Test Session Title",
+ version: "1.0.0",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ summary: { additions: 10, deletions: 5, files: 3 },
+ share: { url: "https://example.com/share" },
+ })
+
+ await JsonMigration.run(sqlite)
+
+ const db = drizzle({ client: sqlite })
+ const sessions = db.select().from(SessionTable).all()
+ expect(sessions.length).toBe(1)
+ expect(sessions[0].id).toBe("ses_test456def")
+ expect(sessions[0].project_id).toBe("proj_test123abc")
+ expect(sessions[0].slug).toBe("test-session")
+ expect(sessions[0].title).toBe("Test Session Title")
+ expect(sessions[0].summary_additions).toBe(10)
+ expect(sessions[0].summary_deletions).toBe(5)
+ expect(sessions[0].share_url).toBe("https://example.com/share")
+ })
+
+ test("migrates messages and parts", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+ await Bun.write(
+ path.join(storageDir, "message", "ses_test456def", "msg_test789ghi.json"),
+ JSON.stringify({ ...fixtures.message }),
+ )
+ await Bun.write(
+ path.join(storageDir, "part", "msg_test789ghi", "prt_testabc123.json"),
+ JSON.stringify({ ...fixtures.part }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.messages).toBe(1)
+ expect(stats?.parts).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const messages = db.select().from(MessageTable).all()
+ expect(messages.length).toBe(1)
+ expect(messages[0].id).toBe("msg_test789ghi")
+
+ const parts = db.select().from(PartTable).all()
+ expect(parts.length).toBe(1)
+ expect(parts[0].id).toBe("prt_testabc123")
+ })
+
+ test("migrates legacy parts without ids in body", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+ await Bun.write(
+ path.join(storageDir, "message", "ses_test456def", "msg_test789ghi.json"),
+ JSON.stringify({
+ role: "user",
+ agent: "default",
+ model: { providerID: "openai", modelID: "gpt-4" },
+ time: { created: 1700000000000 },
+ }),
+ )
+ await Bun.write(
+ path.join(storageDir, "part", "msg_test789ghi", "prt_testabc123.json"),
+ JSON.stringify({
+ type: "text",
+ text: "Hello, world!",
+ }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.messages).toBe(1)
+ expect(stats?.parts).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const messages = db.select().from(MessageTable).all()
+ expect(messages.length).toBe(1)
+ expect(messages[0].id).toBe("msg_test789ghi")
+ expect(messages[0].session_id).toBe("ses_test456def")
+ expect(messages[0].data).not.toHaveProperty("id")
+ expect(messages[0].data).not.toHaveProperty("sessionID")
+
+ const parts = db.select().from(PartTable).all()
+ expect(parts.length).toBe(1)
+ expect(parts[0].id).toBe("prt_testabc123")
+ expect(parts[0].message_id).toBe("msg_test789ghi")
+ expect(parts[0].session_id).toBe("ses_test456def")
+ expect(parts[0].data).not.toHaveProperty("id")
+ expect(parts[0].data).not.toHaveProperty("messageID")
+ expect(parts[0].data).not.toHaveProperty("sessionID")
+ })
+
+ test("skips orphaned sessions (no parent project)", async () => {
+ await Bun.write(
+ path.join(storageDir, "session", "proj_test123abc", "ses_orphan.json"),
+ JSON.stringify({
+ id: "ses_orphan",
+ projectID: "proj_nonexistent",
+ slug: "orphan",
+ directory: "/",
+ title: "Orphan",
+ version: "1.0.0",
+ time: { created: Date.now(), updated: Date.now() },
+ }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.sessions).toBe(0)
+ })
+
+ test("is idempotent (running twice doesn't duplicate)", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+
+ await JsonMigration.run(sqlite)
+ await JsonMigration.run(sqlite)
+
+ const db = drizzle({ client: sqlite })
+ const projects = db.select().from(ProjectTable).all()
+ expect(projects.length).toBe(1) // Still only 1 due to onConflictDoNothing
+ })
+
+ test("migrates todos", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+
+ // Create todo file (named by sessionID, contains array of todos)
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_test456def.json"),
+ JSON.stringify([
+ {
+ id: "todo_1",
+ content: "First todo",
+ status: "pending",
+ priority: "high",
+ },
+ {
+ id: "todo_2",
+ content: "Second todo",
+ status: "completed",
+ priority: "medium",
+ },
+ ]),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.todos).toBe(2)
+
+ const db = drizzle({ client: sqlite })
+ const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
+ expect(todos.length).toBe(2)
+ expect(todos[0].content).toBe("First todo")
+ expect(todos[0].status).toBe("pending")
+ expect(todos[0].priority).toBe("high")
+ expect(todos[0].position).toBe(0)
+ expect(todos[1].content).toBe("Second todo")
+ expect(todos[1].position).toBe(1)
+ })
+
+ test("todos are ordered by position", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_test456def.json"),
+ JSON.stringify([
+ { content: "Third", status: "pending", priority: "low" },
+ { content: "First", status: "pending", priority: "high" },
+ { content: "Second", status: "in_progress", priority: "medium" },
+ ]),
+ )
+
+ await JsonMigration.run(sqlite)
+
+ const db = drizzle({ client: sqlite })
+ const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
+
+ expect(todos.length).toBe(3)
+ expect(todos[0].content).toBe("Third")
+ expect(todos[0].position).toBe(0)
+ expect(todos[1].content).toBe("First")
+ expect(todos[1].position).toBe(1)
+ expect(todos[2].content).toBe("Second")
+ expect(todos[2].position).toBe(2)
+ })
+
+ test("migrates permissions", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+
+ // Create permission file (named by projectID, contains array of rules)
+ const permissionData = [
+ { permission: "file.read", pattern: "/test/file1.ts", action: "allow" as const },
+ { permission: "file.write", pattern: "/test/file2.ts", action: "ask" as const },
+ { permission: "command.run", pattern: "npm install", action: "deny" as const },
+ ]
+ await Bun.write(path.join(storageDir, "permission", "proj_test123abc.json"), JSON.stringify(permissionData))
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.permissions).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const permissions = db.select().from(PermissionTable).all()
+ expect(permissions.length).toBe(1)
+ expect(permissions[0].project_id).toBe("proj_test123abc")
+ expect(permissions[0].data).toEqual(permissionData)
+ })
+
+ test("migrates session shares", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+
+ // Create session share file (named by sessionID)
+ await Bun.write(
+ path.join(storageDir, "session_share", "ses_test456def.json"),
+ JSON.stringify({
+ id: "share_123",
+ secret: "supersecretkey",
+ url: "https://share.example.com/ses_test456def",
+ }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats?.shares).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ const shares = db.select().from(SessionShareTable).all()
+ expect(shares.length).toBe(1)
+ expect(shares[0].session_id).toBe("ses_test456def")
+ expect(shares[0].id).toBe("share_123")
+ expect(shares[0].secret).toBe("supersecretkey")
+ expect(shares[0].url).toBe("https://share.example.com/ses_test456def")
+ })
+
+ test("returns empty stats when storage directory does not exist", async () => {
+ await fs.rm(storageDir, { recursive: true, force: true })
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats.projects).toBe(0)
+ expect(stats.sessions).toBe(0)
+ expect(stats.messages).toBe(0)
+ expect(stats.parts).toBe(0)
+ expect(stats.todos).toBe(0)
+ expect(stats.permissions).toBe(0)
+ expect(stats.shares).toBe(0)
+ expect(stats.errors).toEqual([])
+ })
+
+ test("continues when a JSON file is unreadable and records an error", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await Bun.write(path.join(storageDir, "project", "broken.json"), "{ invalid json")
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats.projects).toBe(1)
+ expect(stats.errors.some((x) => x.includes("failed to read") && x.includes("broken.json"))).toBe(true)
+
+ const db = drizzle({ client: sqlite })
+ const projects = db.select().from(ProjectTable).all()
+ expect(projects.length).toBe(1)
+ expect(projects[0].id).toBe("proj_test123abc")
+ })
+
+ test("skips invalid todo entries while preserving source positions", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_test456def.json"),
+ JSON.stringify([
+ { content: "keep-0", status: "pending", priority: "high" },
+ { content: "drop-1", priority: "low" },
+ { content: "keep-2", status: "completed", priority: "medium" },
+ ]),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+ expect(stats.todos).toBe(2)
+
+ const db = drizzle({ client: sqlite })
+ const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
+ expect(todos.length).toBe(2)
+ expect(todos[0].content).toBe("keep-0")
+ expect(todos[0].position).toBe(0)
+ expect(todos[1].content).toBe("keep-2")
+ expect(todos[1].position).toBe(2)
+ })
+
+ test("skips orphaned todos, permissions, and shares", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/",
+ time: { created: Date.now(), updated: Date.now() },
+ sandboxes: [],
+ })
+ await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
+
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_test456def.json"),
+ JSON.stringify([{ content: "valid", status: "pending", priority: "high" }]),
+ )
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_missing.json"),
+ JSON.stringify([{ content: "orphan", status: "pending", priority: "high" }]),
+ )
+
+ await Bun.write(
+ path.join(storageDir, "permission", "proj_test123abc.json"),
+ JSON.stringify([{ permission: "file.read" }]),
+ )
+ await Bun.write(
+ path.join(storageDir, "permission", "proj_missing.json"),
+ JSON.stringify([{ permission: "file.write" }]),
+ )
+
+ await Bun.write(
+ path.join(storageDir, "session_share", "ses_test456def.json"),
+ JSON.stringify({ id: "share_ok", secret: "secret", url: "https://ok.example.com" }),
+ )
+ await Bun.write(
+ path.join(storageDir, "session_share", "ses_missing.json"),
+ JSON.stringify({ id: "share_missing", secret: "secret", url: "https://missing.example.com" }),
+ )
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats.todos).toBe(1)
+ expect(stats.permissions).toBe(1)
+ expect(stats.shares).toBe(1)
+
+ const db = drizzle({ client: sqlite })
+ expect(db.select().from(TodoTable).all().length).toBe(1)
+ expect(db.select().from(PermissionTable).all().length).toBe(1)
+ expect(db.select().from(SessionShareTable).all().length).toBe(1)
+ })
+
+ test("handles mixed corruption and partial validity in one migration run", async () => {
+ await writeProject(storageDir, {
+ id: "proj_test123abc",
+ worktree: "/ok",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ sandboxes: [],
+ })
+ await Bun.write(
+ path.join(storageDir, "project", "proj_missing_id.json"),
+ JSON.stringify({ worktree: "/bad", sandboxes: [] }),
+ )
+ await Bun.write(path.join(storageDir, "project", "proj_broken.json"), "{ nope")
+
+ await writeSession(storageDir, "proj_test123abc", {
+ id: "ses_test456def",
+ projectID: "proj_test123abc",
+ slug: "ok",
+ directory: "/ok",
+ title: "Ok",
+ version: "1",
+ time: { created: 1700000000000, updated: 1700000001000 },
+ })
+ await Bun.write(
+ path.join(storageDir, "session", "proj_test123abc", "ses_missing_project.json"),
+ JSON.stringify({
+ id: "ses_missing_project",
+ slug: "bad",
+ directory: "/bad",
+ title: "Bad",
+ version: "1",
+ }),
+ )
+ await Bun.write(
+ path.join(storageDir, "session", "proj_test123abc", "ses_orphan.json"),
+ JSON.stringify({
+ id: "ses_orphan",
+ projectID: "proj_missing",
+ slug: "orphan",
+ directory: "/bad",
+ title: "Orphan",
+ version: "1",
+ }),
+ )
+
+ await Bun.write(
+ path.join(storageDir, "message", "ses_test456def", "msg_ok.json"),
+ JSON.stringify({ role: "user", time: { created: 1700000000000 } }),
+ )
+ await Bun.write(path.join(storageDir, "message", "ses_test456def", "msg_broken.json"), "{ nope")
+ await Bun.write(
+ path.join(storageDir, "message", "ses_missing", "msg_orphan.json"),
+ JSON.stringify({ role: "user", time: { created: 1700000000000 } }),
+ )
+
+ await Bun.write(
+ path.join(storageDir, "part", "msg_ok", "part_ok.json"),
+ JSON.stringify({ type: "text", text: "ok" }),
+ )
+ await Bun.write(
+ path.join(storageDir, "part", "msg_missing", "part_missing_message.json"),
+ JSON.stringify({ type: "text", text: "bad" }),
+ )
+ await Bun.write(path.join(storageDir, "part", "msg_ok", "part_broken.json"), "{ nope")
+
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_test456def.json"),
+ JSON.stringify([
+ { content: "ok", status: "pending", priority: "high" },
+ { content: "skip", status: "pending" },
+ ]),
+ )
+ await Bun.write(
+ path.join(storageDir, "todo", "ses_missing.json"),
+ JSON.stringify([{ content: "orphan", status: "pending", priority: "high" }]),
+ )
+ await Bun.write(path.join(storageDir, "todo", "ses_broken.json"), "{ nope")
+
+ await Bun.write(
+ path.join(storageDir, "permission", "proj_test123abc.json"),
+ JSON.stringify([{ permission: "file.read" }]),
+ )
+ await Bun.write(
+ path.join(storageDir, "permission", "proj_missing.json"),
+ JSON.stringify([{ permission: "file.write" }]),
+ )
+ await Bun.write(path.join(storageDir, "permission", "proj_broken.json"), "{ nope")
+
+ await Bun.write(
+ path.join(storageDir, "session_share", "ses_test456def.json"),
+ JSON.stringify({ id: "share_ok", secret: "secret", url: "https://ok.example.com" }),
+ )
+ await Bun.write(
+ path.join(storageDir, "session_share", "ses_missing.json"),
+ JSON.stringify({ id: "share_orphan", secret: "secret", url: "https://missing.example.com" }),
+ )
+ await Bun.write(path.join(storageDir, "session_share", "ses_broken.json"), "{ nope")
+
+ const stats = await JsonMigration.run(sqlite)
+
+ expect(stats.projects).toBe(1)
+ expect(stats.sessions).toBe(1)
+ expect(stats.messages).toBe(1)
+ expect(stats.parts).toBe(1)
+ expect(stats.todos).toBe(1)
+ expect(stats.permissions).toBe(1)
+ expect(stats.shares).toBe(1)
+ expect(stats.errors.length).toBeGreaterThanOrEqual(6)
+
+ const db = drizzle({ client: sqlite })
+ expect(db.select().from(ProjectTable).all().length).toBe(1)
+ expect(db.select().from(SessionTable).all().length).toBe(1)
+ expect(db.select().from(MessageTable).all().length).toBe(1)
+ expect(db.select().from(PartTable).all().length).toBe(1)
+ expect(db.select().from(TodoTable).all().length).toBe(1)
+ expect(db.select().from(PermissionTable).all().length).toBe(1)
+ expect(db.select().from(SessionShareTable).all().length).toBe(1)
+ })
+})
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index bc057ba244..e449c27a64 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -525,7 +525,17 @@ export type EventMessagePartUpdated = {
type: "message.part.updated"
properties: {
part: Part
- delta?: string
+ }
+}
+
+export type EventMessagePartDelta = {
+ type: "message.part.delta"
+ properties: {
+ sessionID: string
+ messageID: string
+ partID: string
+ field: string
+ delta: string
}
}
@@ -699,10 +709,6 @@ export type Todo = {
* Priority level of the task: high, medium, low
*/
priority: string
- /**
- * Unique identifier for the todo item
- */
- id: string
}
export type EventTodoUpdated = {
@@ -967,6 +973,7 @@ export type Event =
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
+ | EventMessagePartDelta
| EventMessagePartRemoved
| EventPermissionAsked
| EventPermissionReplied
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index ab3654da20..4ef3e49b93 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -7804,12 +7804,40 @@
"properties": {
"part": {
"$ref": "#/components/schemas/Part"
+ }
+ },
+ "required": ["part"]
+ }
+ },
+ "required": ["type", "properties"]
+ },
+ "Event.message.part.delta": {
+ "type": "object",
+ "properties": {
+ "type": {
+ "type": "string",
+ "const": "message.part.delta"
+ },
+ "properties": {
+ "type": "object",
+ "properties": {
+ "sessionID": {
+ "type": "string"
+ },
+ "messageID": {
+ "type": "string"
+ },
+ "partID": {
+ "type": "string"
+ },
+ "field": {
+ "type": "string"
},
"delta": {
"type": "string"
}
},
- "required": ["part"]
+ "required": ["sessionID", "messageID", "partID", "field", "delta"]
}
},
"required": ["type", "properties"]
@@ -8227,13 +8255,9 @@
"priority": {
"description": "Priority level of the task: high, medium, low",
"type": "string"
- },
- "id": {
- "description": "Unique identifier for the todo item",
- "type": "string"
}
},
- "required": ["content", "status", "priority", "id"]
+ "required": ["content", "status", "priority"]
},
"Event.todo.updated": {
"type": "object",
@@ -8946,6 +8970,9 @@
{
"$ref": "#/components/schemas/Event.message.part.updated"
},
+ {
+ "$ref": "#/components/schemas/Event.message.part.delta"
+ },
{
"$ref": "#/components/schemas/Event.message.part.removed"
},
diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx
index abe0d7ca9e..837cc53376 100644
--- a/packages/ui/src/components/code.tsx
+++ b/packages/ui/src/components/code.tsx
@@ -1,10 +1,27 @@
-import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs"
+import {
+ DEFAULT_VIRTUAL_FILE_METRICS,
+ type FileContents,
+ File,
+ FileOptions,
+ LineAnnotation,
+ type SelectedLineRange,
+ type VirtualFileMetrics,
+ VirtualizedFile,
+ Virtualizer,
+} from "@pierre/diffs"
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Portal } from "solid-js/web"
import { createDefaultOptions, styleVariables } from "../pierre"
import { getWorkerPool } from "../pierre/worker"
import { Icon } from "./icon"
+const VIRTUALIZE_BYTES = 500_000
+const codeMetrics = {
+ ...DEFAULT_VIRTUAL_FILE_METRICS,
+ lineHeight: 24,
+ fileGap: 0,
+} satisfies Partial
+
type SelectionSide = "additions" | "deletions"
export type CodeProps = FileOptions & {
@@ -160,16 +177,28 @@ export function Code(props: CodeProps) {
const [findPos, setFindPos] = createSignal<{ top: number; right: number }>({ top: 8, right: 8 })
- const file = createMemo(
- () =>
- new File(
- {
- ...createDefaultOptions("unified"),
- ...others,
- },
- getWorkerPool("unified"),
- ),
- )
+ let instance: File | VirtualizedFile | undefined
+ let virtualizer: Virtualizer | undefined
+ let virtualRoot: Document | HTMLElement | undefined
+
+ const bytes = createMemo(() => {
+ const value = local.file.contents as unknown
+ if (typeof value === "string") return value.length
+ if (Array.isArray(value)) {
+ return value.reduce(
+ (acc, part) => acc + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
+ 0,
+ )
+ }
+ if (value == null) return 0
+ return String(value).length
+ })
+ const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
+
+ const options = createMemo(() => ({
+ ...createDefaultOptions("unified"),
+ ...others,
+ }))
const getRoot = () => {
const host = container.querySelector("diffs-container")
@@ -577,6 +606,14 @@ export function Code(props: CodeProps) {
}
const applySelection = (range: SelectedLineRange | null) => {
+ const current = instance
+ if (!current) return false
+
+ if (virtual()) {
+ current.setSelectedLines(range)
+ return true
+ }
+
const root = getRoot()
if (!root) return false
@@ -584,7 +621,7 @@ export function Code(props: CodeProps) {
if (root.querySelectorAll("[data-line]").length < lines) return false
if (!range) {
- file().setSelectedLines(null)
+ current.setSelectedLines(null)
return true
}
@@ -592,12 +629,12 @@ export function Code(props: CodeProps) {
const end = Math.max(range.start, range.end)
if (start < 1 || end > lines) {
- file().setSelectedLines(null)
+ current.setSelectedLines(null)
return true
}
if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
- file().setSelectedLines(null)
+ current.setSelectedLines(null)
return true
}
@@ -608,7 +645,7 @@ export function Code(props: CodeProps) {
return { start: range.start, end: range.end }
})()
- file().setSelectedLines(normalized)
+ current.setSelectedLines(normalized)
return true
}
@@ -619,9 +656,12 @@ export function Code(props: CodeProps) {
const token = renderToken
- const lines = lineCount()
+ const lines = virtual() ? undefined : lineCount()
- const isReady = (root: ShadowRoot) => root.querySelectorAll("[data-line]").length >= lines
+ const isReady = (root: ShadowRoot) =>
+ virtual()
+ ? root.querySelector("[data-line]") != null
+ : root.querySelectorAll("[data-line]").length >= (lines ?? 0)
const notify = () => {
if (token !== renderToken) return
@@ -844,20 +884,41 @@ export function Code(props: CodeProps) {
}
createEffect(() => {
- const current = file()
+ const opts = options()
+ const workerPool = getWorkerPool("unified")
+ const isVirtual = virtual()
- onCleanup(() => {
- current.cleanUp()
- })
- })
-
- createEffect(() => {
observer?.disconnect()
observer = undefined
+ instance?.cleanUp()
+ instance = undefined
+
+ if (!isVirtual && virtualizer) {
+ virtualizer.cleanUp()
+ virtualizer = undefined
+ virtualRoot = undefined
+ }
+
+ const v = (() => {
+ if (!isVirtual) return
+ if (typeof document === "undefined") return
+
+ const root = getScrollParent(wrapper) ?? document
+ if (virtualizer && virtualRoot === root) return virtualizer
+
+ virtualizer?.cleanUp()
+ virtualizer = new Virtualizer()
+ virtualRoot = root
+ virtualizer.setup(root, root instanceof Document ? undefined : wrapper)
+ return virtualizer
+ })()
+
+ instance = isVirtual && v ? new VirtualizedFile(opts, v, codeMetrics, workerPool) : new File(opts, workerPool)
+
container.innerHTML = ""
const value = text()
- file().render({
+ instance.render({
file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents: value },
lineAnnotations: local.annotations,
containerWrapper: container,
@@ -910,6 +971,13 @@ export function Code(props: CodeProps) {
onCleanup(() => {
observer?.disconnect()
+ instance?.cleanUp()
+ instance = undefined
+
+ virtualizer?.cleanUp()
+ virtualizer = undefined
+ virtualRoot = undefined
+
clearOverlayScroll()
clearOverlay()
if (findCurrent === host) {
diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx
index 0966db75e0..0002232b01 100644
--- a/packages/ui/src/components/diff.tsx
+++ b/packages/ui/src/components/diff.tsx
@@ -1,5 +1,5 @@
-import { checksum } from "@opencode-ai/util/encode"
-import { FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
+import { sampledChecksum } from "@opencode-ai/util/encode"
+import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs"
import { createMediaQuery } from "@solid-primitives/media"
import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
@@ -78,14 +78,29 @@ export function Diff(props: DiffProps) {
const mobile = createMediaQuery("(max-width: 640px)")
- const options = createMemo(() => {
- const opts = {
+ const large = createMemo(() => {
+ const before = typeof local.before?.contents === "string" ? local.before.contents : ""
+ const after = typeof local.after?.contents === "string" ? local.after.contents : ""
+ return Math.max(before.length, after.length) > 500_000
+ })
+
+ const largeOptions = {
+ lineDiffType: "none",
+ maxLineDiffLength: 0,
+ tokenizeMaxLineLength: 1,
+ } satisfies Pick, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength">
+
+ const options = createMemo>(() => {
+ const base = {
...createDefaultOptions(props.diffStyle),
...others,
}
- if (!mobile()) return opts
+
+ const perf = large() ? { ...base, ...largeOptions } : base
+ if (!mobile()) return perf
+
return {
- ...opts,
+ ...perf,
disableLineNumbers: true,
}
})
@@ -528,12 +543,17 @@ export function Diff(props: DiffProps) {
createEffect(() => {
const opts = options()
- const workerPool = getWorkerPool(props.diffStyle)
+ const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
const virtualizer = getVirtualizer()
const annotations = local.annotations
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
+ const cacheKey = (contents: string) => {
+ if (!large()) return sampledChecksum(contents, contents.length)
+ return sampledChecksum(contents)
+ }
+
instance?.cleanUp()
instance = virtualizer
? new VirtualizedFileDiff(opts, virtualizer, virtualMetrics, workerPool)
@@ -545,12 +565,12 @@ export function Diff(props: DiffProps) {
oldFile: {
...local.before,
contents: beforeContents,
- cacheKey: checksum(beforeContents),
+ cacheKey: cacheKey(beforeContents),
},
newFile: {
...local.after,
contents: afterContents,
- cacheKey: checksum(afterContents),
+ cacheKey: cacheKey(afterContents),
},
lineAnnotations: annotations,
containerWrapper: container,
diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css
index 53ae7d2b5a..32353946a4 100644
--- a/packages/ui/src/components/markdown.css
+++ b/packages/ui/src/components/markdown.css
@@ -224,3 +224,8 @@
display: block;
}
}
+
+[data-component="markdown"] a.external-link:hover > code {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx
index 608db818f5..4c3d562841 100644
--- a/packages/ui/src/components/markdown.tsx
+++ b/packages/ui/src/components/markdown.tsx
@@ -49,6 +49,19 @@ type CopyLabels = {
copied: string
}
+const urlPattern = /^https?:\/\/[^\s<>()`"']+$/
+
+function codeUrl(text: string) {
+ const href = text.trim().replace(/[),.;!?]+$/, "")
+ if (!urlPattern.test(href)) return
+ try {
+ const url = new URL(href)
+ return url.toString()
+ } catch {
+ return
+ }
+}
+
function createIcon(path: string, slot: string) {
const icon = document.createElement("div")
icon.setAttribute("data-component", "icon")
@@ -110,9 +123,39 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
wrapper.appendChild(createCopyButton(labels))
}
+ const markCodeLinks = () => {
+ const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
+ for (const code of codeNodes) {
+ const href = codeUrl(code.textContent ?? "")
+ const parentLink =
+ code.parentElement instanceof HTMLAnchorElement && code.parentElement.classList.contains("external-link")
+ ? code.parentElement
+ : null
+
+ if (!href) {
+ if (parentLink) parentLink.replaceWith(code)
+ continue
+ }
+
+ if (parentLink) {
+ parentLink.href = href
+ continue
+ }
+
+ const link = document.createElement("a")
+ link.href = href
+ link.className = "external-link"
+ link.target = "_blank"
+ link.rel = "noopener noreferrer"
+ code.parentNode?.replaceChild(link, code)
+ link.appendChild(code)
+ }
+ }
+
const handleClick = async (event: MouseEvent) => {
const target = event.target
if (!(target instanceof Element)) return
+
const button = target.closest('[data-slot="markdown-copy-button"]')
if (!(button instanceof HTMLButtonElement)) return
const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
@@ -132,6 +175,7 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
for (const block of blocks) {
ensureWrapper(block)
}
+ markCodeLinks()
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
for (const button of buttons) {
diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css
index 30bfe3b712..46473b75e5 100644
--- a/packages/ui/src/components/session-review.css
+++ b/packages/ui/src/components/session-review.css
@@ -222,4 +222,30 @@
--line-comment-popover-z: 30;
--line-comment-open-z: 6;
}
+
+ [data-slot="session-review-large-diff"] {
+ padding: 12px;
+ background: var(--background-stronger);
+ }
+
+ [data-slot="session-review-large-diff-title"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ font-weight: var(--font-weight-medium);
+ color: var(--text-strong);
+ margin-bottom: 4px;
+ }
+
+ [data-slot="session-review-large-diff-meta"] {
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-small);
+ color: var(--text-weak);
+ word-break: break-word;
+ }
+
+ [data-slot="session-review-large-diff-actions"] {
+ display: flex;
+ gap: 8px;
+ margin-top: 10px;
+ }
}
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index 6ab33d7233..3696187c3e 100644
--- a/packages/ui/src/components/session-review.tsx
+++ b/packages/ui/src/components/session-review.tsx
@@ -17,6 +17,26 @@ import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { type SelectedLineRange } from "@pierre/diffs"
import { Dynamic } from "solid-js/web"
+const MAX_DIFF_LINES = 20_000
+const MAX_DIFF_BYTES = 2_000_000
+
+function linesOver(text: string, max: number) {
+ let lines = 1
+ for (let i = 0; i < text.length; i++) {
+ if (text.charCodeAt(i) !== 10) continue
+ lines++
+ if (lines > max) return true
+ }
+ return lines > max
+}
+
+function formatBytes(bytes: number) {
+ if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"
+ if (bytes < 1024) return `${bytes} B`
+ if (bytes < 1024 * 1024) return `${Math.round((bytes / 1024) * 10) / 10} KB`
+ return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB`
+}
+
export type SessionReviewDiffStyle = "unified" | "split"
export type SessionReviewComment = {
@@ -326,12 +346,28 @@ export const SessionReview = (props: SessionReviewProps) => {
{(diff) => {
let wrapper: HTMLDivElement | undefined
+ const expanded = createMemo(() => open().includes(diff.file))
+ const [force, setForce] = createSignal(false)
+
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file))
const commentedLines = createMemo(() => comments().map((c) => c.selection))
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
+ const tooLarge = createMemo(() => {
+ if (!expanded()) return false
+ if (force()) return false
+ if (isImageFile(diff.file)) return false
+
+ const before = beforeText()
+ const after = afterText()
+
+ if (before.length > MAX_DIFF_BYTES || after.length > MAX_DIFF_BYTES) return true
+ if (linesOver(before, MAX_DIFF_LINES) || linesOver(after, MAX_DIFF_LINES)) return true
+ return false
+ })
+
const isAdded = () => diff.status === "added" || (beforeText().length === 0 && afterText().length > 0)
const isDeleted = () =>
diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0)
@@ -571,94 +607,114 @@ export const SessionReview = (props: SessionReviewProps) => {
scheduleAnchors()
}}
>
-
-
-
-
})
-
-
-
-
-
- {i18n.t("ui.sessionReview.change.removed")}
-
-
-
-
-
-
- {imageStatus() === "loading" ? "Loading..." : "Image"}
-
-
-
-
- {
- props.onDiffRendered?.()
- scheduleAnchors()
- }}
- enableLineSelection={props.onLineComment != null}
- onLineSelected={handleLineSelected}
- onLineSelectionEnd={handleLineSelectionEnd}
- selectedLines={selectedLines()}
- commentedLines={commentedLines()}
- before={{
- name: diff.file!,
- contents: typeof diff.before === "string" ? diff.before : "",
- }}
- after={{
- name: diff.file!,
- contents: typeof diff.after === "string" ? diff.after : "",
- }}
- />
-
-
-
-
- {(comment) => (
-
-
-
- {(range) => (
-
- setCommenting(null)}
- onSubmit={(comment) => {
- props.onLineComment?.({
- file: diff.file,
- selection: range(),
- comment,
- preview: selectionPreview(diff, range()),
- })
- setCommenting(null)
+
+
+
+
+
})
+
+
+
+
+
+ {i18n.t("ui.sessionReview.change.removed")}
+
+
+
+
+
+
+ {imageStatus() === "loading"
+ ? i18n.t("ui.sessionReview.image.loading")
+ : i18n.t("ui.sessionReview.image.placeholder")}
+
+
+
+
+
+
+ {i18n.t("ui.sessionReview.largeDiff.title")}
+
+
+ Limit: {MAX_DIFF_LINES.toLocaleString()} lines / {formatBytes(MAX_DIFF_BYTES)}.
+ Current: {formatBytes(Math.max(beforeText().length, afterText().length))}.
+
+
+
+
+
+
+
+ {
+ props.onDiffRendered?.()
+ scheduleAnchors()
+ }}
+ enableLineSelection={props.onLineComment != null}
+ onLineSelected={handleLineSelected}
+ onLineSelectionEnd={handleLineSelectionEnd}
+ selectedLines={selectedLines()}
+ commentedLines={commentedLines()}
+ before={{
+ name: diff.file!,
+ contents: typeof diff.before === "string" ? diff.before : "",
+ }}
+ after={{
+ name: diff.file!,
+ contents: typeof diff.after === "string" ? diff.after : "",
}}
/>
-
- )}
+
+
+
+
+ {(comment) => (
+
+
+
+ {(range) => (
+
+ setCommenting(null)}
+ onSubmit={(comment) => {
+ props.onLineComment?.({
+ file: diff.file,
+ selection: range(),
+ comment,
+ preview: selectionPreview(diff, range()),
+ })
+ setCommenting(null)
+ }}
+ />
+
+ )}
+
diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts
index 36030998bf..8d281e0d47 100644
--- a/packages/ui/src/i18n/ar.ts
+++ b/packages/ui/src/i18n/ar.ts
@@ -8,6 +8,11 @@ export const dict = {
"ui.sessionReview.change.added": "مضاف",
"ui.sessionReview.change.removed": "محذوف",
"ui.sessionReview.change.modified": "معدل",
+ "ui.sessionReview.image.loading": "جار التحميل...",
+ "ui.sessionReview.image.placeholder": "صورة",
+ "ui.sessionReview.largeDiff.title": "Diff كبير جدا لعرضه",
+ "ui.sessionReview.largeDiff.meta": "الحد: {{lines}} سطر / {{limit}}. الحالي: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "اعرض على أي حال",
"ui.lineComment.label.prefix": "تعليق على ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts
index 3092c9735b..b51811f6fe 100644
--- a/packages/ui/src/i18n/br.ts
+++ b/packages/ui/src/i18n/br.ts
@@ -8,6 +8,11 @@ export const dict = {
"ui.sessionReview.change.added": "Adicionado",
"ui.sessionReview.change.removed": "Removido",
"ui.sessionReview.change.modified": "Modificado",
+ "ui.sessionReview.image.loading": "Carregando...",
+ "ui.sessionReview.image.placeholder": "Imagem",
+ "ui.sessionReview.largeDiff.title": "Diff grande demais para renderizar",
+ "ui.sessionReview.largeDiff.meta": "Limite: {{lines}} linhas / {{limit}}. Atual: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Renderizar mesmo assim",
"ui.lineComment.label.prefix": "Comentar em ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts
index 6bdae375d0..19aba94e1d 100644
--- a/packages/ui/src/i18n/bs.ts
+++ b/packages/ui/src/i18n/bs.ts
@@ -12,6 +12,11 @@ export const dict = {
"ui.sessionReview.change.added": "Dodano",
"ui.sessionReview.change.removed": "Uklonjeno",
"ui.sessionReview.change.modified": "Izmijenjeno",
+ "ui.sessionReview.image.loading": "Učitavanje...",
+ "ui.sessionReview.image.placeholder": "Slika",
+ "ui.sessionReview.largeDiff.title": "Diff je prevelik za prikaz",
+ "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} linija / {{limit}}. Trenutno: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Prikaži svejedno",
"ui.lineComment.label.prefix": "Komentar na ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts
index 799249e8a8..8081922054 100644
--- a/packages/ui/src/i18n/da.ts
+++ b/packages/ui/src/i18n/da.ts
@@ -9,6 +9,11 @@ export const dict = {
"ui.sessionReview.change.added": "Tilføjet",
"ui.sessionReview.change.removed": "Fjernet",
"ui.sessionReview.change.modified": "Ændret",
+ "ui.sessionReview.image.loading": "Indlæser...",
+ "ui.sessionReview.image.placeholder": "Billede",
+ "ui.sessionReview.largeDiff.title": "Diff er for stor til at blive vist",
+ "ui.sessionReview.largeDiff.meta": "Grænse: {{lines}} linjer / {{limit}}. Nuværende: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Vis alligevel",
"ui.lineComment.label.prefix": "Kommenter på ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Kommenterer på ",
diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts
index ef03895b38..d230243311 100644
--- a/packages/ui/src/i18n/de.ts
+++ b/packages/ui/src/i18n/de.ts
@@ -13,6 +13,11 @@ export const dict = {
"ui.sessionReview.change.added": "Hinzugefügt",
"ui.sessionReview.change.removed": "Entfernt",
"ui.sessionReview.change.modified": "Geändert",
+ "ui.sessionReview.image.loading": "Wird geladen...",
+ "ui.sessionReview.image.placeholder": "Bild",
+ "ui.sessionReview.largeDiff.title": "Diff zu groß zum Rendern",
+ "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} Zeilen / {{limit}}. Aktuell: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Trotzdem rendern",
"ui.lineComment.label.prefix": "Kommentar zu ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Kommentiere ",
diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts
index d44d1d3d87..fa678fd539 100644
--- a/packages/ui/src/i18n/en.ts
+++ b/packages/ui/src/i18n/en.ts
@@ -8,6 +8,11 @@ export const dict = {
"ui.sessionReview.change.added": "Added",
"ui.sessionReview.change.removed": "Removed",
"ui.sessionReview.change.modified": "Modified",
+ "ui.sessionReview.image.loading": "Loading...",
+ "ui.sessionReview.image.placeholder": "Image",
+ "ui.sessionReview.largeDiff.title": "Diff too large to render",
+ "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} lines / {{limit}}. Current: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Render anyway",
"ui.lineComment.label.prefix": "Comment on ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts
index 67f1e63ac8..0727de0294 100644
--- a/packages/ui/src/i18n/es.ts
+++ b/packages/ui/src/i18n/es.ts
@@ -8,6 +8,11 @@ export const dict = {
"ui.sessionReview.change.added": "Añadido",
"ui.sessionReview.change.removed": "Eliminado",
"ui.sessionReview.change.modified": "Modificado",
+ "ui.sessionReview.image.loading": "Cargando...",
+ "ui.sessionReview.image.placeholder": "Imagen",
+ "ui.sessionReview.largeDiff.title": "Diff demasiado grande para renderizar",
+ "ui.sessionReview.largeDiff.meta": "Límite: {{lines}} líneas / {{limit}}. Actual: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Renderizar de todos modos",
"ui.lineComment.label.prefix": "Comentar en ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts
index 72b9f15ffd..13d10f2fab 100644
--- a/packages/ui/src/i18n/fr.ts
+++ b/packages/ui/src/i18n/fr.ts
@@ -8,6 +8,11 @@ export const dict = {
"ui.sessionReview.change.added": "Ajouté",
"ui.sessionReview.change.removed": "Supprimé",
"ui.sessionReview.change.modified": "Modifié",
+ "ui.sessionReview.image.loading": "Chargement...",
+ "ui.sessionReview.image.placeholder": "Image",
+ "ui.sessionReview.largeDiff.title": "Diff trop volumineux pour être affiché",
+ "ui.sessionReview.largeDiff.meta": "Limite : {{lines}} lignes / {{limit}}. Actuel : {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Afficher quand même",
"ui.lineComment.label.prefix": "Commenter sur ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts
index a0fef4500a..9886c7b6c8 100644
--- a/packages/ui/src/i18n/ja.ts
+++ b/packages/ui/src/i18n/ja.ts
@@ -9,6 +9,11 @@ export const dict = {
"ui.sessionReview.change.added": "追加",
"ui.sessionReview.change.removed": "削除",
"ui.sessionReview.change.modified": "変更",
+ "ui.sessionReview.image.loading": "読み込み中...",
+ "ui.sessionReview.image.placeholder": "画像",
+ "ui.sessionReview.largeDiff.title": "差分が大きすぎて表示できません",
+ "ui.sessionReview.largeDiff.meta": "上限: {{lines}} 行 / {{limit}}。現在: {{current}}。",
+ "ui.sessionReview.largeDiff.renderAnyway": "それでも表示する",
"ui.lineComment.label.prefix": "",
"ui.lineComment.label.suffix": "へのコメント",
"ui.lineComment.editorLabel.prefix": "",
diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts
index 6d37e883b1..cc3c2eba22 100644
--- a/packages/ui/src/i18n/ko.ts
+++ b/packages/ui/src/i18n/ko.ts
@@ -8,6 +8,11 @@ export const dict = {
"ui.sessionReview.change.added": "추가됨",
"ui.sessionReview.change.removed": "삭제됨",
"ui.sessionReview.change.modified": "수정됨",
+ "ui.sessionReview.image.loading": "로딩 중...",
+ "ui.sessionReview.image.placeholder": "이미지",
+ "ui.sessionReview.largeDiff.title": "차이가 너무 커서 렌더링할 수 없습니다",
+ "ui.sessionReview.largeDiff.meta": "제한: {{lines}}줄 / {{limit}}. 현재: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "그래도 렌더링",
"ui.lineComment.label.prefix": "",
"ui.lineComment.label.suffix": "에 댓글 달기",
diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts
index b8cc230d46..81a207409d 100644
--- a/packages/ui/src/i18n/no.ts
+++ b/packages/ui/src/i18n/no.ts
@@ -11,6 +11,11 @@ export const dict: Record
= {
"ui.sessionReview.change.added": "Lagt til",
"ui.sessionReview.change.removed": "Fjernet",
"ui.sessionReview.change.modified": "Endret",
+ "ui.sessionReview.image.loading": "Laster...",
+ "ui.sessionReview.image.placeholder": "Bilde",
+ "ui.sessionReview.largeDiff.title": "Diff er for stor til å gjengi",
+ "ui.sessionReview.largeDiff.meta": "Grense: {{lines}} linjer / {{limit}}. Nåværende: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Gjengi likevel",
"ui.lineComment.label.prefix": "Kommenter på ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts
index 79a818f44e..95be1173e3 100644
--- a/packages/ui/src/i18n/pl.ts
+++ b/packages/ui/src/i18n/pl.ts
@@ -9,6 +9,11 @@ export const dict = {
"ui.sessionReview.change.added": "Dodano",
"ui.sessionReview.change.removed": "Usunięto",
"ui.sessionReview.change.modified": "Zmodyfikowano",
+ "ui.sessionReview.image.loading": "Ładowanie...",
+ "ui.sessionReview.image.placeholder": "Obraz",
+ "ui.sessionReview.largeDiff.title": "Diff jest zbyt duży, aby go wyrenderować",
+ "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} linii / {{limit}}. Obecnie: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Renderuj mimo to",
"ui.lineComment.label.prefix": "Komentarz do ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Komentowanie: ",
diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts
index fbec348d3c..77fcea198d 100644
--- a/packages/ui/src/i18n/ru.ts
+++ b/packages/ui/src/i18n/ru.ts
@@ -9,6 +9,11 @@ export const dict = {
"ui.sessionReview.change.added": "Добавлено",
"ui.sessionReview.change.removed": "Удалено",
"ui.sessionReview.change.modified": "Изменено",
+ "ui.sessionReview.image.loading": "Загрузка...",
+ "ui.sessionReview.image.placeholder": "Изображение",
+ "ui.sessionReview.largeDiff.title": "Diff слишком большой для отображения",
+ "ui.sessionReview.largeDiff.meta": "Лимит: {{lines}} строк / {{limit}}. Текущий: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "Отобразить всё равно",
"ui.lineComment.label.prefix": "Комментарий к ",
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Комментирование: ",
diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts
index 09f529c25a..dc4769fbf7 100644
--- a/packages/ui/src/i18n/th.ts
+++ b/packages/ui/src/i18n/th.ts
@@ -8,6 +8,11 @@ export const dict = {
"ui.sessionReview.change.added": "เพิ่ม",
"ui.sessionReview.change.removed": "ลบ",
"ui.sessionReview.change.modified": "แก้ไข",
+ "ui.sessionReview.image.loading": "กำลังโหลด...",
+ "ui.sessionReview.image.placeholder": "รูปภาพ",
+ "ui.sessionReview.largeDiff.title": "Diff มีขนาดใหญ่เกินไปจนไม่สามารถแสดงผลได้",
+ "ui.sessionReview.largeDiff.meta": "ขีดจำกัด: {{lines}} บรรทัด / {{limit}}. ปัจจุบัน: {{current}}.",
+ "ui.sessionReview.largeDiff.renderAnyway": "แสดงผลต่อไป",
"ui.lineComment.label.prefix": "แสดงความคิดเห็นบน ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts
index fe572cb5d3..891ba7a234 100644
--- a/packages/ui/src/i18n/zh.ts
+++ b/packages/ui/src/i18n/zh.ts
@@ -12,6 +12,11 @@ export const dict = {
"ui.sessionReview.change.added": "已添加",
"ui.sessionReview.change.removed": "已移除",
"ui.sessionReview.change.modified": "已修改",
+ "ui.sessionReview.image.loading": "加载中...",
+ "ui.sessionReview.image.placeholder": "图片",
+ "ui.sessionReview.largeDiff.title": "差异过大,无法渲染",
+ "ui.sessionReview.largeDiff.meta": "限制:{{lines}} 行 / {{limit}}。当前:{{current}}。",
+ "ui.sessionReview.largeDiff.renderAnyway": "仍然渲染",
"ui.lineComment.label.prefix": "评论 ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts
index dab0e48abd..a88f5e9906 100644
--- a/packages/ui/src/i18n/zht.ts
+++ b/packages/ui/src/i18n/zht.ts
@@ -12,6 +12,11 @@ export const dict = {
"ui.sessionReview.change.added": "已新增",
"ui.sessionReview.change.removed": "已移除",
"ui.sessionReview.change.modified": "已修改",
+ "ui.sessionReview.image.loading": "載入中...",
+ "ui.sessionReview.image.placeholder": "圖片",
+ "ui.sessionReview.largeDiff.title": "差異過大,無法渲染",
+ "ui.sessionReview.largeDiff.meta": "限制:{{lines}} 行 / {{limit}}。目前:{{current}}。",
+ "ui.sessionReview.largeDiff.renderAnyway": "仍然渲染",
"ui.lineComment.label.prefix": "評論 ",
"ui.lineComment.label.suffix": "",
diff --git a/packages/util/src/encode.ts b/packages/util/src/encode.ts
index dbac3a7cba..91a947d03d 100644
--- a/packages/util/src/encode.ts
+++ b/packages/util/src/encode.ts
@@ -29,3 +29,24 @@ export function checksum(content: string): string | undefined {
}
return (hash >>> 0).toString(36)
}
+
+export function sampledChecksum(content: string, limit = 500_000): string | undefined {
+ if (!content) return undefined
+ if (content.length <= limit) return checksum(content)
+
+ const size = 4096
+ const points = [
+ 0,
+ Math.floor(content.length * 0.25),
+ Math.floor(content.length * 0.5),
+ Math.floor(content.length * 0.75),
+ content.length - size,
+ ]
+ const hashes = points
+ .map((point) => {
+ const start = Math.max(0, Math.min(content.length - size, point - Math.floor(size / 2)))
+ return checksum(content.slice(start, start + size)) ?? ""
+ })
+ .join(":")
+ return `${content.length}:${hashes}`
+}