Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c90281c0d6 | ||
|
|
7e971d8302 | ||
|
|
54b3b3fe05 | ||
|
|
9d012b0621 | ||
|
|
fbb0a93e12 | ||
|
|
e2e7a8d722 | ||
|
|
ce7923adaf | ||
|
|
a26d53151b | ||
|
|
5eaef6b758 | ||
|
|
c5c38cad9c | ||
|
|
9918f389e7 | ||
|
|
dd8c424806 | ||
|
|
078d8a07cf | ||
|
|
1ee712e549 | ||
|
|
55315bdffa | ||
|
|
882b8e1e75 | ||
|
|
95edbc0ae6 | ||
|
|
11cd4fb639 | ||
|
|
9c16bd1e30 | ||
|
|
5e9d5c734e | ||
|
|
b382d1a467 | ||
|
|
23f31475e7 | ||
|
|
c0eab9e442 | ||
|
|
8a1e85d0c8 | ||
|
|
2793502db2 | ||
|
|
9f7bd0246c | ||
|
|
a6a4350d10 | ||
|
|
471b9f4dc4 | ||
|
|
24fb9b1296 | ||
|
|
3573019916 | ||
|
|
fc5b353144 |
@@ -29,7 +29,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -117,7 +117,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -144,7 +144,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.64",
|
"@ai-sdk/anthropic": "3.0.64",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -192,7 +192,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop-electron": {
|
"packages/desktop-electron": {
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"electron-context-menu": "4.1.2",
|
"electron-context-menu": "4.1.2",
|
||||||
@@ -268,7 +268,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/shared": "workspace:*",
|
"@opencode-ai/shared": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -297,7 +297,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -313,7 +313,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@agentclientprotocol/sdk": "0.16.1",
|
"@agentclientprotocol/sdk": "0.16.1",
|
||||||
"@ai-sdk/alibaba": "1.0.17",
|
"@ai-sdk/alibaba": "1.0.17",
|
||||||
"@ai-sdk/amazon-bedrock": "4.0.95",
|
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||||
"@ai-sdk/anthropic": "3.0.71",
|
"@ai-sdk/anthropic": "3.0.71",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.49",
|
||||||
"@ai-sdk/cerebras": "2.0.41",
|
"@ai-sdk/cerebras": "2.0.41",
|
||||||
@@ -458,7 +458,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
@@ -493,7 +493,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cross-spawn": "catalog:",
|
"cross-spawn": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -508,7 +508,7 @@
|
|||||||
},
|
},
|
||||||
"packages/shared": {
|
"packages/shared": {
|
||||||
"name": "@opencode-ai/shared",
|
"name": "@opencode-ai/shared",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -532,7 +532,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -567,7 +567,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -616,7 +616,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -740,7 +740,7 @@
|
|||||||
|
|
||||||
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
|
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.95", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qJKWEy+cNx3bLSJi/XpIVhv0P8KO0JFB1SvEroNWN8gKm820SIglBmXS10DTeXJdM5PPbQX4i/wJj5BHEk2LRQ=="],
|
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.96", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Mc4Ias2jRMD1jOB6xWtKNPdhECeuCZyIlbr9EAGfBnyBt++sS13ziZh9qv9TdyMCAZJ7xoQcpbchoRJcKwPdpA=="],
|
||||||
|
|
||||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
|
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
|
||||||
|
|
||||||
|
|||||||
@@ -236,7 +236,6 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
SALESFORCE_INSTANCE_URL,
|
SALESFORCE_INSTANCE_URL,
|
||||||
ZEN_BLACK_PRICE,
|
ZEN_BLACK_PRICE,
|
||||||
ZEN_LITE_PRICE,
|
ZEN_LITE_PRICE,
|
||||||
new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"),
|
|
||||||
new sst.Secret("ZEN_LIMITS"),
|
new sst.Secret("ZEN_LIMITS"),
|
||||||
new sst.Secret("ZEN_SESSION_SECRET"),
|
new sst.Secret("ZEN_SESSION_SECRET"),
|
||||||
...ZEN_MODELS,
|
...ZEN_MODELS,
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-GjpBQhvGLTM6NWX29b/mS+KjrQPl0w9VjQHH5jaK9SM=",
|
"x86_64-linux": "sha256-Spc0qzg4he0d0GwHcqj7uBmvK4DIF1tEbsZ6M+pxpWc=",
|
||||||
"aarch64-linux": "sha256-F5h9p+iZ8CASdUYaYR7O22NwBRa/iT+ZinUxO8lbPTc=",
|
"aarch64-linux": "sha256-gwz/PKBbT+72hr7vUG28cdx4Z7/Sf06PNMr9JBjAYg0=",
|
||||||
"aarch64-darwin": "sha256-jWo5yvCtjVKRf9i5XUcTTaLtj2+G6+T1Td2llO/cT5I=",
|
"aarch64-darwin": "sha256-Lj8p9P/QzLqxiM1OVSwcbtTsms8AcW3A6H0575ERufw=",
|
||||||
"x86_64-darwin": "sha256-LzV+5/8P2mkiFHmt+a8zDeJjRbU8z9nssSA4tzv1HxA="
|
"x86_64-darwin": "sha256-y0e+TnXj6wKDqSC5hQAWjpKadaFvL6GJ6Mba5anBM+Y="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -558,6 +558,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "الاستخدام الحالي لـ",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "الاستخدام الحالي لـ",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "هو $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "هو $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "استرداد قسيمة",
|
||||||
|
"workspace.redeem.subtitle": "استرد رمز القسيمة للحصول على رصيد أو مزايا.",
|
||||||
|
"workspace.redeem.placeholder": "أدخل رمز القسيمة",
|
||||||
|
"workspace.redeem.redeem": "استرداد",
|
||||||
|
"workspace.redeem.redeeming": "جارٍ الاسترداد...",
|
||||||
|
"workspace.redeem.success": "تم استرداد القسيمة بنجاح.",
|
||||||
|
|
||||||
"workspace.reload.title": "إعادة الشحن التلقائي",
|
"workspace.reload.title": "إعادة الشحن التلقائي",
|
||||||
"workspace.reload.disabled.before": "إعادة الشحن التلقائي",
|
"workspace.reload.disabled.before": "إعادة الشحن التلقائي",
|
||||||
"workspace.reload.disabled.state": "معطّل",
|
"workspace.reload.disabled.state": "معطّل",
|
||||||
|
|||||||
@@ -567,6 +567,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Uso atual para",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Uso atual para",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "é $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "é $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Resgatar Cupom",
|
||||||
|
"workspace.redeem.subtitle": "Resgate um código de cupom para receber créditos ou vantagens.",
|
||||||
|
"workspace.redeem.placeholder": "Digite o código do cupom",
|
||||||
|
"workspace.redeem.redeem": "Resgatar",
|
||||||
|
"workspace.redeem.redeeming": "Resgatando...",
|
||||||
|
"workspace.redeem.success": "Cupom resgatado com sucesso.",
|
||||||
|
|
||||||
"workspace.reload.title": "Recarga Automática",
|
"workspace.reload.title": "Recarga Automática",
|
||||||
"workspace.reload.disabled.before": "A recarga automática está",
|
"workspace.reload.disabled.before": "A recarga automática está",
|
||||||
"workspace.reload.disabled.state": "desativada",
|
"workspace.reload.disabled.state": "desativada",
|
||||||
|
|||||||
@@ -563,6 +563,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Nuværende brug for",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Nuværende brug for",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Indløs kupon",
|
||||||
|
"workspace.redeem.subtitle": "Indløs en kuponkode for at få kreditter eller fordele.",
|
||||||
|
"workspace.redeem.placeholder": "Indtast kuponkode",
|
||||||
|
"workspace.redeem.redeem": "Indløs",
|
||||||
|
"workspace.redeem.redeeming": "Indløser...",
|
||||||
|
"workspace.redeem.success": "Kuponen blev indløst.",
|
||||||
|
|
||||||
"workspace.reload.title": "Automatisk genopfyldning",
|
"workspace.reload.title": "Automatisk genopfyldning",
|
||||||
"workspace.reload.disabled.before": "Automatisk genopfyldning er",
|
"workspace.reload.disabled.before": "Automatisk genopfyldning er",
|
||||||
"workspace.reload.disabled.state": "deaktiveret",
|
"workspace.reload.disabled.state": "deaktiveret",
|
||||||
|
|||||||
@@ -566,6 +566,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktuelle Nutzung für",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktuelle Nutzung für",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "ist $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "ist $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Gutschein einlösen",
|
||||||
|
"workspace.redeem.subtitle": "Löse einen Gutscheincode ein, um Guthaben oder Vorteile zu erhalten.",
|
||||||
|
"workspace.redeem.placeholder": "Gutscheincode eingeben",
|
||||||
|
"workspace.redeem.redeem": "Einlösen",
|
||||||
|
"workspace.redeem.redeeming": "Wird eingelöst...",
|
||||||
|
"workspace.redeem.success": "Gutschein erfolgreich eingelöst.",
|
||||||
|
|
||||||
"workspace.reload.title": "Auto-Reload",
|
"workspace.reload.title": "Auto-Reload",
|
||||||
"workspace.reload.disabled.before": "Auto-Reload ist",
|
"workspace.reload.disabled.before": "Auto-Reload ist",
|
||||||
"workspace.reload.disabled.state": "deaktiviert",
|
"workspace.reload.disabled.state": "deaktiviert",
|
||||||
|
|||||||
@@ -559,6 +559,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Current usage for",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Current usage for",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "is $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "is $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Redeem Coupon",
|
||||||
|
"workspace.redeem.subtitle": "Redeem a coupon code to claim credits or perks.",
|
||||||
|
"workspace.redeem.placeholder": "Enter coupon code",
|
||||||
|
"workspace.redeem.redeem": "Redeem",
|
||||||
|
"workspace.redeem.redeeming": "Redeeming...",
|
||||||
|
"workspace.redeem.success": "Coupon redeemed successfully.",
|
||||||
|
|
||||||
"workspace.reload.title": "Auto Reload",
|
"workspace.reload.title": "Auto Reload",
|
||||||
"workspace.reload.disabled.before": "Auto reload is",
|
"workspace.reload.disabled.before": "Auto reload is",
|
||||||
"workspace.reload.disabled.state": "disabled",
|
"workspace.reload.disabled.state": "disabled",
|
||||||
|
|||||||
@@ -567,6 +567,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Uso actual para",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Uso actual para",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "es $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "es $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Canjear cupón",
|
||||||
|
"workspace.redeem.subtitle": "Canjea un código de cupón para obtener crédito o beneficios.",
|
||||||
|
"workspace.redeem.placeholder": "Introduce el código del cupón",
|
||||||
|
"workspace.redeem.redeem": "Canjear",
|
||||||
|
"workspace.redeem.redeeming": "Canjeando...",
|
||||||
|
"workspace.redeem.success": "Cupón canjeado correctamente.",
|
||||||
|
|
||||||
"workspace.reload.title": "Auto Recarga",
|
"workspace.reload.title": "Auto Recarga",
|
||||||
"workspace.reload.disabled.before": "La auto recarga está",
|
"workspace.reload.disabled.before": "La auto recarga está",
|
||||||
"workspace.reload.disabled.state": "deshabilitada",
|
"workspace.reload.disabled.state": "deshabilitada",
|
||||||
|
|||||||
@@ -569,6 +569,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "L'utilisation actuelle pour",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "L'utilisation actuelle pour",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "est de",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "est de",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Utiliser un coupon",
|
||||||
|
"workspace.redeem.subtitle": "Utilisez un code promo pour obtenir du crédit ou des avantages.",
|
||||||
|
"workspace.redeem.placeholder": "Saisissez le code promo",
|
||||||
|
"workspace.redeem.redeem": "Utiliser",
|
||||||
|
"workspace.redeem.redeeming": "Utilisation...",
|
||||||
|
"workspace.redeem.success": "Coupon utilisé avec succès.",
|
||||||
|
|
||||||
"workspace.reload.title": "Rechargement automatique",
|
"workspace.reload.title": "Rechargement automatique",
|
||||||
"workspace.reload.disabled.before": "Le rechargement automatique est",
|
"workspace.reload.disabled.before": "Le rechargement automatique est",
|
||||||
"workspace.reload.disabled.state": "désactivé",
|
"workspace.reload.disabled.state": "désactivé",
|
||||||
|
|||||||
@@ -565,6 +565,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Utilizzo attuale per",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Utilizzo attuale per",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "è $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "è $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Riscatta Coupon",
|
||||||
|
"workspace.redeem.subtitle": "Riscatta un codice coupon per ottenere credito o vantaggi.",
|
||||||
|
"workspace.redeem.placeholder": "Inserisci il codice coupon",
|
||||||
|
"workspace.redeem.redeem": "Riscatta",
|
||||||
|
"workspace.redeem.redeeming": "Riscatto in corso...",
|
||||||
|
"workspace.redeem.success": "Coupon riscattato con successo.",
|
||||||
|
|
||||||
"workspace.reload.title": "Ricarica Auto",
|
"workspace.reload.title": "Ricarica Auto",
|
||||||
"workspace.reload.disabled.before": "La ricarica auto è",
|
"workspace.reload.disabled.before": "La ricarica auto è",
|
||||||
"workspace.reload.disabled.state": "disabilitata",
|
"workspace.reload.disabled.state": "disabilitata",
|
||||||
|
|||||||
@@ -564,6 +564,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "現在の使用状況(",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "現在の使用状況(",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": ")は $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": ")は $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "クーポンを利用",
|
||||||
|
"workspace.redeem.subtitle": "クーポンコードを利用して、クレジットや特典を受け取ります。",
|
||||||
|
"workspace.redeem.placeholder": "クーポンコードを入力",
|
||||||
|
"workspace.redeem.redeem": "利用する",
|
||||||
|
"workspace.redeem.redeeming": "利用中...",
|
||||||
|
"workspace.redeem.success": "クーポンを利用しました。",
|
||||||
|
|
||||||
"workspace.reload.title": "自動チャージ",
|
"workspace.reload.title": "自動チャージ",
|
||||||
"workspace.reload.disabled.before": "自動チャージは",
|
"workspace.reload.disabled.before": "自動チャージは",
|
||||||
"workspace.reload.disabled.state": "無効",
|
"workspace.reload.disabled.state": "無効",
|
||||||
|
|||||||
@@ -558,6 +558,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "현재",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "현재",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "사용량: $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "사용량: $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "쿠폰 사용",
|
||||||
|
"workspace.redeem.subtitle": "쿠폰 코드를 사용해 크레딧이나 혜택을 받으세요.",
|
||||||
|
"workspace.redeem.placeholder": "쿠폰 코드를 입력하세요",
|
||||||
|
"workspace.redeem.redeem": "사용",
|
||||||
|
"workspace.redeem.redeeming": "사용 중...",
|
||||||
|
"workspace.redeem.success": "쿠폰을 성공적으로 사용했습니다.",
|
||||||
|
|
||||||
"workspace.reload.title": "자동 충전",
|
"workspace.reload.title": "자동 충전",
|
||||||
"workspace.reload.disabled.before": "자동 충전이",
|
"workspace.reload.disabled.before": "자동 충전이",
|
||||||
"workspace.reload.disabled.state": "비활성화",
|
"workspace.reload.disabled.state": "비활성화",
|
||||||
|
|||||||
@@ -564,6 +564,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Gjeldende forbruk for",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Gjeldende forbruk for",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "er $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Løs inn kupong",
|
||||||
|
"workspace.redeem.subtitle": "Løs inn en kupongkode for å få kreditt eller fordeler.",
|
||||||
|
"workspace.redeem.placeholder": "Skriv inn kupongkode",
|
||||||
|
"workspace.redeem.redeem": "Løs inn",
|
||||||
|
"workspace.redeem.redeeming": "Løser inn...",
|
||||||
|
"workspace.redeem.success": "Kupongen ble løst inn.",
|
||||||
|
|
||||||
"workspace.reload.title": "Auto-påfyll",
|
"workspace.reload.title": "Auto-påfyll",
|
||||||
"workspace.reload.disabled.before": "Auto-påfyll er",
|
"workspace.reload.disabled.before": "Auto-påfyll er",
|
||||||
"workspace.reload.disabled.state": "deaktivert",
|
"workspace.reload.disabled.state": "deaktivert",
|
||||||
|
|||||||
@@ -565,6 +565,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktualne użycie za",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Aktualne użycie za",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "wynosi $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "wynosi $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Zrealizuj kupon",
|
||||||
|
"workspace.redeem.subtitle": "Zrealizuj kod kuponu, aby otrzymać środki lub korzyści.",
|
||||||
|
"workspace.redeem.placeholder": "Wpisz kod kuponu",
|
||||||
|
"workspace.redeem.redeem": "Zrealizuj",
|
||||||
|
"workspace.redeem.redeeming": "Realizowanie...",
|
||||||
|
"workspace.redeem.success": "Kupon został zrealizowany.",
|
||||||
|
|
||||||
"workspace.reload.title": "Automatyczne doładowanie",
|
"workspace.reload.title": "Automatyczne doładowanie",
|
||||||
"workspace.reload.disabled.before": "Automatyczne doładowanie jest",
|
"workspace.reload.disabled.before": "Automatyczne doładowanie jest",
|
||||||
"workspace.reload.disabled.state": "wyłączone",
|
"workspace.reload.disabled.state": "wyłączone",
|
||||||
|
|||||||
@@ -571,6 +571,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Текущее использование за",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Текущее использование за",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "составляет $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "составляет $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Активировать купон",
|
||||||
|
"workspace.redeem.subtitle": "Активируйте код купона, чтобы получить кредит или бонусы.",
|
||||||
|
"workspace.redeem.placeholder": "Введите код купона",
|
||||||
|
"workspace.redeem.redeem": "Активировать",
|
||||||
|
"workspace.redeem.redeeming": "Активация...",
|
||||||
|
"workspace.redeem.success": "Купон успешно активирован.",
|
||||||
|
|
||||||
"workspace.reload.title": "Автопополнение",
|
"workspace.reload.title": "Автопополнение",
|
||||||
"workspace.reload.disabled.before": "Автопополнение",
|
"workspace.reload.disabled.before": "Автопополнение",
|
||||||
"workspace.reload.disabled.state": "отключено",
|
"workspace.reload.disabled.state": "отключено",
|
||||||
|
|||||||
@@ -560,6 +560,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "การใช้งานปัจจุบันสำหรับ",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "การใช้งานปัจจุบันสำหรับ",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "คือ $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "คือ $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "แลกคูปอง",
|
||||||
|
"workspace.redeem.subtitle": "แลกรหัสคูปองเพื่อรับเครดิตหรือสิทธิพิเศษ",
|
||||||
|
"workspace.redeem.placeholder": "กรอกรหัสคูปอง",
|
||||||
|
"workspace.redeem.redeem": "แลก",
|
||||||
|
"workspace.redeem.redeeming": "กำลังแลก...",
|
||||||
|
"workspace.redeem.success": "แลกคูปองสำเร็จ",
|
||||||
|
|
||||||
"workspace.reload.title": "โหลดซ้ำอัตโนมัติ",
|
"workspace.reload.title": "โหลดซ้ำอัตโนมัติ",
|
||||||
"workspace.reload.disabled.before": "การโหลดซ้ำอัตโนมัติ",
|
"workspace.reload.disabled.before": "การโหลดซ้ำอัตโนมัติ",
|
||||||
"workspace.reload.disabled.state": "ปิดใช้งานอยู่",
|
"workspace.reload.disabled.state": "ปิดใช้งานอยู่",
|
||||||
|
|||||||
@@ -567,6 +567,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "Şu anki kullanım",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "Şu anki kullanım",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "$",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "$",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "Kupon Kullan",
|
||||||
|
"workspace.redeem.subtitle": "Kredi veya avantajlardan yararlanmak için bir kupon kodu kullanın.",
|
||||||
|
"workspace.redeem.placeholder": "Kupon kodunu girin",
|
||||||
|
"workspace.redeem.redeem": "Kullan",
|
||||||
|
"workspace.redeem.redeeming": "Kullanılıyor...",
|
||||||
|
"workspace.redeem.success": "Kupon başarıyla kullanıldı.",
|
||||||
|
|
||||||
"workspace.reload.title": "Otomatik Yeniden Yükleme",
|
"workspace.reload.title": "Otomatik Yeniden Yükleme",
|
||||||
"workspace.reload.disabled.before": "Otomatik yeniden yükleme:",
|
"workspace.reload.disabled.before": "Otomatik yeniden yükleme:",
|
||||||
"workspace.reload.disabled.state": "devre dışı",
|
"workspace.reload.disabled.state": "devre dışı",
|
||||||
|
|||||||
@@ -542,6 +542,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "当前",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "当前",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量为 $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量为 $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "兑换优惠券",
|
||||||
|
"workspace.redeem.subtitle": "兑换优惠码以领取充值额度或权益。",
|
||||||
|
"workspace.redeem.placeholder": "输入优惠码",
|
||||||
|
"workspace.redeem.redeem": "兑换",
|
||||||
|
"workspace.redeem.redeeming": "兑换中...",
|
||||||
|
"workspace.redeem.success": "优惠券兑换成功。",
|
||||||
|
|
||||||
"workspace.reload.title": "自动充值",
|
"workspace.reload.title": "自动充值",
|
||||||
"workspace.reload.disabled.before": "自动充值已",
|
"workspace.reload.disabled.before": "自动充值已",
|
||||||
"workspace.reload.disabled.state": "禁用",
|
"workspace.reload.disabled.state": "禁用",
|
||||||
|
|||||||
@@ -542,6 +542,13 @@ export const dict = {
|
|||||||
"workspace.monthlyLimit.currentUsage.beforeMonth": "目前",
|
"workspace.monthlyLimit.currentUsage.beforeMonth": "目前",
|
||||||
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量為 $",
|
"workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量為 $",
|
||||||
|
|
||||||
|
"workspace.redeem.title": "兌換優惠券",
|
||||||
|
"workspace.redeem.subtitle": "兌換優惠碼以領取儲值額度或權益。",
|
||||||
|
"workspace.redeem.placeholder": "輸入優惠碼",
|
||||||
|
"workspace.redeem.redeem": "兌換",
|
||||||
|
"workspace.redeem.redeeming": "兌換中...",
|
||||||
|
"workspace.redeem.success": "優惠券兌換成功。",
|
||||||
|
|
||||||
"workspace.reload.title": "自動儲值",
|
"workspace.reload.title": "自動儲值",
|
||||||
"workspace.reload.disabled.before": "自動儲值已",
|
"workspace.reload.disabled.before": "自動儲值已",
|
||||||
"workspace.reload.disabled.state": "停用",
|
"workspace.reload.disabled.state": "停用",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Actor } from "@opencode-ai/console-core/actor.js"
|
|||||||
import { Resource } from "@opencode-ai/console-resource"
|
import { Resource } from "@opencode-ai/console-resource"
|
||||||
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
import { LiteData } from "@opencode-ai/console-core/lite.js"
|
||||||
import { BlackData } from "@opencode-ai/console-core/black.js"
|
import { BlackData } from "@opencode-ai/console-core/black.js"
|
||||||
|
import { User } from "@opencode-ai/console-core/user.js"
|
||||||
|
|
||||||
export async function POST(input: APIEvent) {
|
export async function POST(input: APIEvent) {
|
||||||
const body = await Billing.stripe().webhooks.constructEventAsync(
|
const body = await Billing.stripe().webhooks.constructEventAsync(
|
||||||
@@ -109,6 +110,8 @@ export async function POST(input: APIEvent) {
|
|||||||
if (type === "lite") {
|
if (type === "lite") {
|
||||||
const workspaceID = body.data.object.metadata?.workspaceID
|
const workspaceID = body.data.object.metadata?.workspaceID
|
||||||
const userID = body.data.object.metadata?.userID
|
const userID = body.data.object.metadata?.userID
|
||||||
|
const userEmail = body.data.object.metadata?.userEmail
|
||||||
|
const coupon = body.data.object.metadata?.coupon
|
||||||
const customerID = body.data.object.customer as string
|
const customerID = body.data.object.customer as string
|
||||||
const invoiceID = body.data.object.latest_invoice as string
|
const invoiceID = body.data.object.latest_invoice as string
|
||||||
const subscriptionID = body.data.object.id as string
|
const subscriptionID = body.data.object.id as string
|
||||||
@@ -156,6 +159,10 @@ export async function POST(input: APIEvent) {
|
|||||||
id: Identifier.create("lite"),
|
id: Identifier.create("lite"),
|
||||||
userID: userID,
|
userID: userID,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (userEmail && coupon === LiteData.firstMonth100Coupon) {
|
||||||
|
await Billing.redeemCoupon(userEmail, "GOFREEMONTH")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { BillingSection } from "./billing-section"
|
|||||||
import { ReloadSection } from "./reload-section"
|
import { ReloadSection } from "./reload-section"
|
||||||
import { PaymentSection } from "./payment-section"
|
import { PaymentSection } from "./payment-section"
|
||||||
import { BlackSection } from "./black-section"
|
import { BlackSection } from "./black-section"
|
||||||
|
import { RedeemSection } from "./redeem-section"
|
||||||
import { createMemo, Show } from "solid-js"
|
import { createMemo, Show } from "solid-js"
|
||||||
import { createAsync, useParams } from "@solidjs/router"
|
import { createAsync, useParams } from "@solidjs/router"
|
||||||
import { queryBillingInfo, querySessionInfo } from "../../common"
|
import { queryBillingInfo, querySessionInfo } from "../../common"
|
||||||
@@ -24,6 +25,7 @@ export default function () {
|
|||||||
<Show when={billingInfo()?.customerID}>
|
<Show when={billingInfo()?.customerID}>
|
||||||
<ReloadSection />
|
<ReloadSection />
|
||||||
<MonthlyLimitSection />
|
<MonthlyLimitSection />
|
||||||
|
<RedeemSection />
|
||||||
<PaymentSection />
|
<PaymentSection />
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
.root {
|
||||||
|
[data-slot="redeem-container"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
min-width: 20rem;
|
||||||
|
width: fit-content;
|
||||||
|
|
||||||
|
@media (max-width: 30rem) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="redeem-form"] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
|
||||||
|
[data-slot="input-row"] {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: stretch;
|
||||||
|
|
||||||
|
@media (max-width: 30rem) {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--color-text-disabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="form-error"] {
|
||||||
|
color: var(--color-danger);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-slot="form-success"] {
|
||||||
|
color: var(--color-success, var(--color-accent));
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { json, action, useParams, useSubmission } from "@solidjs/router"
|
||||||
|
import { Show } from "solid-js"
|
||||||
|
import { withActor } from "~/context/auth.withActor"
|
||||||
|
import { Billing } from "@opencode-ai/console-core/billing.js"
|
||||||
|
import { User } from "@opencode-ai/console-core/user.js"
|
||||||
|
import { Actor } from "@opencode-ai/console-core/actor.js"
|
||||||
|
import { CouponType } from "@opencode-ai/console-core/schema/billing.sql.js"
|
||||||
|
import styles from "./redeem-section.module.css"
|
||||||
|
import { queryBillingInfo } from "../../common"
|
||||||
|
import { useI18n } from "~/context/i18n"
|
||||||
|
import { formError, localizeError } from "~/lib/form-error"
|
||||||
|
|
||||||
|
const redeem = action(async (form: FormData) => {
|
||||||
|
"use server"
|
||||||
|
const workspaceID = form.get("workspaceID") as string | null
|
||||||
|
if (!workspaceID) return { error: formError.workspaceRequired }
|
||||||
|
const code = (form.get("code") as string | null)?.trim().toUpperCase()
|
||||||
|
if (!code) return { error: "Coupon code is required." }
|
||||||
|
if (!(CouponType as readonly string[]).includes(code)) return { error: "Invalid coupon code." }
|
||||||
|
|
||||||
|
return json(
|
||||||
|
await withActor(async () => {
|
||||||
|
const actor = Actor.assert("user")
|
||||||
|
const email = await User.getAuthEmail(actor.properties.userID)
|
||||||
|
if (!email) return { error: "No email on account." }
|
||||||
|
return Billing.redeemCoupon(email, code as (typeof CouponType)[number])
|
||||||
|
.then(() => ({ error: undefined, data: true }))
|
||||||
|
.catch((e) => ({ error: e.message as string }))
|
||||||
|
}, workspaceID),
|
||||||
|
{ revalidate: queryBillingInfo.key },
|
||||||
|
)
|
||||||
|
}, "billing.redeemCoupon")
|
||||||
|
|
||||||
|
export function RedeemSection() {
|
||||||
|
const params = useParams()
|
||||||
|
const i18n = useI18n()
|
||||||
|
const submission = useSubmission(redeem)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class={styles.root}>
|
||||||
|
<div data-slot="section-title">
|
||||||
|
<h2>{i18n.t("workspace.redeem.title")}</h2>
|
||||||
|
<p>{i18n.t("workspace.redeem.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<div data-slot="redeem-container">
|
||||||
|
<form action={redeem} method="post" data-slot="redeem-form">
|
||||||
|
<div data-slot="input-row">
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
data-component="input"
|
||||||
|
name="code"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder={i18n.t("workspace.redeem.placeholder")}
|
||||||
|
/>
|
||||||
|
<button type="submit" data-color="primary" disabled={submission.pending}>
|
||||||
|
{submission.pending ? i18n.t("workspace.redeem.redeeming") : i18n.t("workspace.redeem.redeem")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<Show when={submission.result && (submission.result as any).error}>
|
||||||
|
{(err: any) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
|
||||||
|
</Show>
|
||||||
|
<Show when={submission.result && !(submission.result as any).error && (submission.result as any).data}>
|
||||||
|
<div data-slot="form-success">{i18n.t("workspace.redeem.success")}</div>
|
||||||
|
</Show>
|
||||||
|
<input type="hidden" name="workspaceID" value={params.id} />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE TABLE `coupon` (
|
||||||
|
`email` varchar(255),
|
||||||
|
`type` enum('BUILDATHON','GOFREEMONTH') NOT NULL,
|
||||||
|
`time_redeemed` timestamp(3),
|
||||||
|
CONSTRAINT PRIMARY KEY(`email`,`type`)
|
||||||
|
);
|
||||||
+2619
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Database } from "../src/drizzle/index.js"
|
||||||
|
import { CouponTable, CouponType } from "../src/schema/billing.sql.js"
|
||||||
|
|
||||||
|
const email = process.argv[2]
|
||||||
|
const type = process.argv[3] as (typeof CouponType)[number]
|
||||||
|
|
||||||
|
if (!email || !type) {
|
||||||
|
console.error(`Usage: bun create-coupon.ts <email> <${CouponType.join("|")}>`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(CouponType as readonly string[]).includes(type)) {
|
||||||
|
console.error(`Error: type must be one of ${CouponType.join(", ")}`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
await Database.use((tx) =>
|
||||||
|
tx.insert(CouponTable).values({
|
||||||
|
email,
|
||||||
|
type,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(`Created ${type} coupon for ${email}`)
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { Stripe } from "stripe"
|
import { Stripe } from "stripe"
|
||||||
import { Database, eq, sql } from "./drizzle"
|
import { and, Database, eq, isNull, sql } from "./drizzle"
|
||||||
import { BillingTable, LiteTable, PaymentTable, SubscriptionTable, UsageTable } from "./schema/billing.sql"
|
import {
|
||||||
|
BillingTable,
|
||||||
|
CouponTable,
|
||||||
|
CouponType,
|
||||||
|
LiteTable,
|
||||||
|
PaymentTable,
|
||||||
|
SubscriptionTable,
|
||||||
|
UsageTable,
|
||||||
|
} from "./schema/billing.sql"
|
||||||
import { Actor } from "./actor"
|
import { Actor } from "./actor"
|
||||||
import { fn } from "./util/fn"
|
import { fn } from "./util/fn"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
@@ -147,6 +155,37 @@ export namespace Billing {
|
|||||||
return amountInMicroCents
|
return amountInMicroCents
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const redeemCoupon = async (email: string, type: (typeof CouponType)[number]) => {
|
||||||
|
const coupon = await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select()
|
||||||
|
.from(CouponTable)
|
||||||
|
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type)))
|
||||||
|
.then((rows) => rows[0]),
|
||||||
|
)
|
||||||
|
if (!coupon) throw new Error("Invalid coupon code")
|
||||||
|
if (coupon.timeRedeemed) throw new Error("Coupon already redeemed")
|
||||||
|
|
||||||
|
if (type === "BUILDATHON") await grantCredit(Actor.workspace(), 500)
|
||||||
|
|
||||||
|
await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.update(CouponTable)
|
||||||
|
.set({ timeRedeemed: sql`now()` })
|
||||||
|
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasCoupon = async (email: string, type: (typeof CouponType)[number]) => {
|
||||||
|
return await Database.use((tx) =>
|
||||||
|
tx
|
||||||
|
.select()
|
||||||
|
.from(CouponTable)
|
||||||
|
.where(and(eq(CouponTable.email, email), eq(CouponTable.type, type), isNull(CouponTable.timeRedeemed)))
|
||||||
|
.then((rows) => rows.length > 0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const setMonthlyLimit = fn(z.number(), async (input) => {
|
export const setMonthlyLimit = fn(z.number(), async (input) => {
|
||||||
return await Database.use((tx) =>
|
return await Database.use((tx) =>
|
||||||
tx
|
tx
|
||||||
@@ -245,16 +284,19 @@ export namespace Billing {
|
|||||||
const user = Actor.assert("user")
|
const user = Actor.assert("user")
|
||||||
const { successUrl, cancelUrl, method } = input
|
const { successUrl, cancelUrl, method } = input
|
||||||
|
|
||||||
const email = await User.getAuthEmail(user.properties.userID)
|
const email = (await User.getAuthEmail(user.properties.userID))!
|
||||||
const billing = await Billing.get()
|
const billing = await Billing.get()
|
||||||
|
|
||||||
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
|
if (billing.subscriptionID) throw new Error("Already subscribed to Black")
|
||||||
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
|
if (billing.liteSubscriptionID) throw new Error("Already subscribed to Lite")
|
||||||
|
|
||||||
|
const coupon = (await Billing.hasCoupon(email, "GOFREEMONTH"))
|
||||||
|
? LiteData.firstMonth100Coupon
|
||||||
|
: LiteData.firstMonth50Coupon
|
||||||
const createSession = () =>
|
const createSession = () =>
|
||||||
Billing.stripe().checkout.sessions.create({
|
Billing.stripe().checkout.sessions.create({
|
||||||
mode: "subscription",
|
mode: "subscription",
|
||||||
discounts: [{ coupon: LiteData.firstMonthCoupon(email!) }],
|
discounts: [{ coupon }],
|
||||||
...(billing.customerID
|
...(billing.customerID
|
||||||
? {
|
? {
|
||||||
customer: billing.customerID,
|
customer: billing.customerID,
|
||||||
@@ -264,7 +306,7 @@ export namespace Billing {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
customer_email: email!,
|
customer_email: email,
|
||||||
}),
|
}),
|
||||||
...(() => {
|
...(() => {
|
||||||
if (method === "alipay") {
|
if (method === "alipay") {
|
||||||
@@ -312,6 +354,8 @@ export namespace Billing {
|
|||||||
metadata: {
|
metadata: {
|
||||||
workspaceID: Actor.workspace(),
|
workspaceID: Actor.workspace(),
|
||||||
userID: user.properties.userID,
|
userID: user.properties.userID,
|
||||||
|
userEmail: email,
|
||||||
|
coupon,
|
||||||
type: "lite",
|
type: "lite",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,11 +11,7 @@ export namespace LiteData {
|
|||||||
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
|
export const productID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.product)
|
||||||
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
|
export const priceID = fn(z.void(), () => Resource.ZEN_LITE_PRICE.price)
|
||||||
export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
|
export const priceInr = fn(z.void(), () => Resource.ZEN_LITE_PRICE.priceInr)
|
||||||
export const firstMonthCoupon = fn(z.string(), (email) => {
|
export const firstMonth100Coupon = Resource.ZEN_LITE_PRICE.firstMonth100Coupon
|
||||||
const invitees = Resource.ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES.value.split(",")
|
export const firstMonth50Coupon = Resource.ZEN_LITE_PRICE.firstMonth50Coupon
|
||||||
return invitees.includes(email)
|
|
||||||
? Resource.ZEN_LITE_PRICE.firstMonth100Coupon
|
|
||||||
: Resource.ZEN_LITE_PRICE.firstMonth50Coupon
|
|
||||||
})
|
|
||||||
export const planName = fn(z.void(), () => "lite")
|
export const planName = fn(z.void(), () => "lite")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { bigint, boolean, index, int, json, mysqlEnum, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
|
import {
|
||||||
|
bigint,
|
||||||
|
boolean,
|
||||||
|
index,
|
||||||
|
int,
|
||||||
|
json,
|
||||||
|
mysqlEnum,
|
||||||
|
mysqlTable,
|
||||||
|
primaryKey,
|
||||||
|
uniqueIndex,
|
||||||
|
varchar,
|
||||||
|
} from "drizzle-orm/mysql-core"
|
||||||
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
|
||||||
import { workspaceIndexes } from "./workspace.sql"
|
import { workspaceIndexes } from "./workspace.sql"
|
||||||
|
|
||||||
@@ -121,3 +132,14 @@ export const UsageTable = mysqlTable(
|
|||||||
},
|
},
|
||||||
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
|
(table) => [...workspaceIndexes(table), index("usage_time_created").on(table.workspaceID, table.timeCreated)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const CouponType = ["BUILDATHON", "GOFREEMONTH"] as const
|
||||||
|
export const CouponTable = mysqlTable(
|
||||||
|
"coupon",
|
||||||
|
{
|
||||||
|
email: varchar("email", { length: 255 }),
|
||||||
|
type: mysqlEnum("type", CouponType).notNull(),
|
||||||
|
timeRedeemed: utc("time_redeemed"),
|
||||||
|
},
|
||||||
|
(table) => [primaryKey({ columns: [table.email, table.type] })],
|
||||||
|
)
|
||||||
|
|||||||
Vendored
-4
@@ -142,10 +142,6 @@ declare module "sst" {
|
|||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
|
|
||||||
"type": "sst.sst.Secret"
|
|
||||||
"value": string
|
|
||||||
}
|
|
||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth100Coupon": string
|
"firstMonth100Coupon": string
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
-4
@@ -142,10 +142,6 @@ declare module "sst" {
|
|||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
|
|
||||||
"type": "sst.sst.Secret"
|
|
||||||
"value": string
|
|
||||||
}
|
|
||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth100Coupon": string
|
"firstMonth100Coupon": string
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
-4
@@ -142,10 +142,6 @@ declare module "sst" {
|
|||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
|
|
||||||
"type": "sst.sst.Secret"
|
|
||||||
"value": string
|
|
||||||
}
|
|
||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth100Coupon": string
|
"firstMonth100Coupon": string
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
Vendored
-4
@@ -142,10 +142,6 @@ declare module "sst" {
|
|||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
|
|
||||||
"type": "sst.sst.Secret"
|
|
||||||
"value": string
|
|
||||||
}
|
|
||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth100Coupon": string
|
"firstMonth100Coupon": string
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.4.11"
|
version = "1.4.14"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.14/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.14/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.14/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.14/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.11/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.14/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
Vendored
-4
@@ -142,10 +142,6 @@ declare module "sst" {
|
|||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
|
|
||||||
"type": "sst.sst.Secret"
|
|
||||||
"value": string
|
|
||||||
}
|
|
||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth100Coupon": string
|
"firstMonth100Coupon": string
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@agentclientprotocol/sdk": "0.16.1",
|
"@agentclientprotocol/sdk": "0.16.1",
|
||||||
"@ai-sdk/alibaba": "1.0.17",
|
"@ai-sdk/alibaba": "1.0.17",
|
||||||
"@ai-sdk/amazon-bedrock": "4.0.95",
|
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||||
"@ai-sdk/anthropic": "3.0.71",
|
"@ai-sdk/anthropic": "3.0.71",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.49",
|
||||||
"@ai-sdk/cerebras": "2.0.41",
|
"@ai-sdk/cerebras": "2.0.41",
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { Effect } from "effect"
|
|
||||||
import pkg from "../package.json"
|
import pkg from "../package.json"
|
||||||
import { Script } from "@opencode-ai/script"
|
import { Script } from "@opencode-ai/script"
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
@@ -9,107 +7,82 @@ import { fileURLToPath } from "url"
|
|||||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||||
process.chdir(dir)
|
process.chdir(dir)
|
||||||
|
|
||||||
const published = (name: string, version: string) =>
|
async function published(name: string, version: string) {
|
||||||
Effect.promise(() => $`npm view ${name}@${version} version`.nothrow()).pipe(
|
return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
|
||||||
Effect.map((result) => result.exitCode === 0),
|
}
|
||||||
)
|
|
||||||
|
|
||||||
const sha256 = (path: string) =>
|
async function publish(dir: string, name: string, version: string) {
|
||||||
Effect.promise(() => $`sha256sum ${path} | cut -d' ' -f1`.text()).pipe(Effect.map((text) => text.trim()))
|
if (await published(name, version)) {
|
||||||
|
console.log(`already published ${name}@${version}`)
|
||||||
const publishPackage = (dir: string, name: string, version: string) =>
|
return
|
||||||
Effect.gen(function* () {
|
|
||||||
if (yield* published(name, version)) {
|
|
||||||
console.log(`already published ${name}@${version}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (process.platform !== "win32") yield* Effect.promise(() => $`chmod -R 755 .`.cwd(dir))
|
|
||||||
yield* Effect.promise(() => $`bun pm pack`.cwd(dir))
|
|
||||||
yield* Effect.promise(() => $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir))
|
|
||||||
})
|
|
||||||
|
|
||||||
const binaryVersion = (value: unknown) => {
|
|
||||||
if (
|
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
"name" in value &&
|
|
||||||
typeof value.name === "string" &&
|
|
||||||
"version" in value &&
|
|
||||||
typeof value.version === "string"
|
|
||||||
) {
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
throw new Error("invalid dist package manifest")
|
if (process.platform !== "win32") await $`chmod -R 755 .`.cwd(dir)
|
||||||
|
await $`bun pm pack`.cwd(dir)
|
||||||
|
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureVersion = (value: unknown) => {
|
const binaries: Record<string, string> = {}
|
||||||
if (typeof value === "string") return value
|
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: "./dist" })) {
|
||||||
throw new Error("missing dist package version")
|
const pkg = await Bun.file(`./dist/${filepath}`).json()
|
||||||
|
binaries[pkg.name] = pkg.version
|
||||||
}
|
}
|
||||||
|
console.log("binaries", binaries)
|
||||||
|
const version = Object.values(binaries)[0]
|
||||||
|
|
||||||
const program = Effect.gen(function* () {
|
await $`mkdir -p ./dist/${pkg.name}`
|
||||||
const binaries: Record<string, string> = Object.fromEntries(
|
await $`cp -r ./bin ./dist/${pkg.name}/bin`
|
||||||
yield* Effect.promise(async () =>
|
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
|
||||||
Array.fromAsync(new Bun.Glob("*/package.json").scan({ cwd: "./dist" }), async (filepath) => {
|
await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text())
|
||||||
const current = binaryVersion(await Bun.file(`./dist/${filepath}`).json())
|
|
||||||
return [current.name, current.version] as const
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
console.log("binaries", binaries)
|
|
||||||
|
|
||||||
const version = ensureVersion(Object.values(binaries)[0])
|
await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: pkg.name + "-ai",
|
||||||
|
bin: {
|
||||||
|
[pkg.name]: `./bin/${pkg.name}`,
|
||||||
|
},
|
||||||
|
scripts: {
|
||||||
|
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
|
||||||
|
},
|
||||||
|
version: version,
|
||||||
|
license: pkg.license,
|
||||||
|
optionalDependencies: binaries,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
yield* Effect.promise(() => $`mkdir -p ./dist/${pkg.name}`)
|
const tasks = Object.entries(binaries).map(async ([name]) => {
|
||||||
yield* Effect.promise(() => $`cp -r ./bin ./dist/${pkg.name}/bin`)
|
await publish(`./dist/${name}`, name, binaries[name])
|
||||||
yield* Effect.promise(() => $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`)
|
})
|
||||||
yield* Effect.promise(async () =>
|
await Promise.all(tasks)
|
||||||
Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text()),
|
await publish(`./dist/${pkg.name}`, `${pkg.name}-ai`, version)
|
||||||
)
|
|
||||||
yield* Effect.promise(() =>
|
|
||||||
Bun.write(
|
|
||||||
`./dist/${pkg.name}/package.json`,
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
name: pkg.name + "-ai",
|
|
||||||
bin: { [pkg.name]: `./bin/${pkg.name}` },
|
|
||||||
scripts: { postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs" },
|
|
||||||
version,
|
|
||||||
license: pkg.license,
|
|
||||||
optionalDependencies: binaries,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
yield* Effect.all(Object.entries(binaries).map(([name, version]) => publishPackage(`./dist/${name}`, name, version)))
|
const image = "ghcr.io/anomalyco/opencode"
|
||||||
yield* publishPackage(`./dist/${pkg.name}`, `${pkg.name}-ai`, version)
|
const platforms = "linux/amd64,linux/arm64"
|
||||||
|
const tags = [`${image}:${version}`, `${image}:${Script.channel}`]
|
||||||
|
const tagFlags = tags.flatMap((t) => ["-t", t])
|
||||||
|
await $`docker buildx build --platform ${platforms} ${tagFlags} --push .`
|
||||||
|
|
||||||
const image = "ghcr.io/anomalyco/opencode"
|
// registries
|
||||||
const tags = [`${image}:${version}`, `${image}:${Script.channel}`]
|
if (!Script.preview) {
|
||||||
yield* Effect.promise(
|
// Calculate SHA values
|
||||||
() => $`docker buildx build --platform linux/amd64,linux/arm64 ${tags.flatMap((t) => ["-t", t])} --push .`,
|
const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||||
)
|
const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||||
|
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||||
|
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
|
||||||
|
|
||||||
if (Script.preview) return
|
const [pkgver, _subver = ""] = Script.version.split(/(-.*)/, 2)
|
||||||
|
|
||||||
const [arm64Sha, x64Sha, macX64Sha, macArm64Sha] = yield* Effect.all([
|
|
||||||
sha256("./dist/opencode-linux-arm64.tar.gz"),
|
|
||||||
sha256("./dist/opencode-linux-x64.tar.gz"),
|
|
||||||
sha256("./dist/opencode-darwin-x64.zip"),
|
|
||||||
sha256("./dist/opencode-darwin-arm64.zip"),
|
|
||||||
])
|
|
||||||
const [pkgver, subver = ""] = Script.version.split(/(-.*)/, 2)
|
|
||||||
|
|
||||||
|
// arch
|
||||||
const binaryPkgbuild = [
|
const binaryPkgbuild = [
|
||||||
"# Maintainer: dax",
|
"# Maintainer: dax",
|
||||||
"# Maintainer: adam",
|
"# Maintainer: adam",
|
||||||
"",
|
"",
|
||||||
"pkgname='opencode-bin'",
|
"pkgname='opencode-bin'",
|
||||||
`pkgver=${pkgver}`,
|
`pkgver=${pkgver}`,
|
||||||
`_subver=${subver}`,
|
`_subver=${_subver}`,
|
||||||
"options=('!debug' '!strip')",
|
"options=('!debug' '!strip')",
|
||||||
"pkgrel=1",
|
"pkgrel=1",
|
||||||
"pkgdesc='The AI coding agent built for the terminal.'",
|
"pkgdesc='The AI coding agent built for the terminal.'",
|
||||||
@@ -122,6 +95,7 @@ const program = Effect.gen(function* () {
|
|||||||
"",
|
"",
|
||||||
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.tar.gz")`,
|
`source_aarch64=("\${pkgname}_\${pkgver}_aarch64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.tar.gz")`,
|
||||||
`sha256sums_aarch64=('${arm64Sha}')`,
|
`sha256sums_aarch64=('${arm64Sha}')`,
|
||||||
|
|
||||||
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.tar.gz")`,
|
`source_x86_64=("\${pkgname}_\${pkgver}_x86_64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.tar.gz")`,
|
||||||
`sha256sums_x86_64=('${x64Sha}')`,
|
`sha256sums_x86_64=('${x64Sha}')`,
|
||||||
"",
|
"",
|
||||||
@@ -131,40 +105,37 @@ const program = Effect.gen(function* () {
|
|||||||
"",
|
"",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
|
|
||||||
yield* Effect.promise(async () => {
|
for (const [pkg, pkgbuild] of [["opencode-bin", binaryPkgbuild]]) {
|
||||||
for (let i = 0; i < 30; i++) {
|
for (let i = 0; i < 30; i++) {
|
||||||
try {
|
try {
|
||||||
await $`rm -rf ./dist/aur-opencode-bin`
|
await $`rm -rf ./dist/aur-${pkg}`
|
||||||
await $`git clone ssh://aur@aur.archlinux.org/opencode-bin.git ./dist/aur-opencode-bin`
|
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
|
||||||
await $`cd ./dist/aur-opencode-bin && git checkout master`
|
await $`cd ./dist/aur-${pkg} && git checkout master`
|
||||||
await Bun.write(`./dist/aur-opencode-bin/PKGBUILD`, binaryPkgbuild)
|
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild)
|
||||||
await $`cd ./dist/aur-opencode-bin && makepkg --printsrcinfo > .SRCINFO`
|
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
|
||||||
await $`cd ./dist/aur-opencode-bin && git add PKGBUILD .SRCINFO`
|
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
|
||||||
if ((await $`cd ./dist/aur-opencode-bin && git diff --cached --quiet`.nothrow()).exitCode === 0) return
|
if ((await $`cd ./dist/aur-${pkg} && git diff --cached --quiet`.nothrow()).exitCode === 0) break
|
||||||
await $`cd ./dist/aur-opencode-bin && git commit -m "Update to v${Script.version}"`
|
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${Script.version}"`
|
||||||
await $`cd ./dist/aur-opencode-bin && git push`
|
await $`cd ./dist/aur-${pkg} && git push`
|
||||||
return
|
break
|
||||||
} catch {}
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const token = process.env.GITHUB_TOKEN
|
|
||||||
if (!token) {
|
|
||||||
console.error("GITHUB_TOKEN is required to update homebrew tap")
|
|
||||||
process.exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Homebrew formula
|
||||||
const homebrewFormula = [
|
const homebrewFormula = [
|
||||||
"# typed: false",
|
"# typed: false",
|
||||||
"# frozen_string_literal: true",
|
"# frozen_string_literal: true",
|
||||||
"",
|
"",
|
||||||
"# This file was generated by GoReleaser. DO NOT EDIT.",
|
"# This file was generated by GoReleaser. DO NOT EDIT.",
|
||||||
"class Opencode < Formula",
|
"class Opencode < Formula",
|
||||||
' desc "The AI coding agent built for the terminal."',
|
` desc "The AI coding agent built for the terminal."`,
|
||||||
' homepage "https://github.com/anomalyco/opencode"',
|
` homepage "https://github.com/anomalyco/opencode"`,
|
||||||
` version "${Script.version.split("-")[0]}"`,
|
` version "${Script.version.split("-")[0]}"`,
|
||||||
"",
|
"",
|
||||||
' depends_on "ripgrep"',
|
` depends_on "ripgrep"`,
|
||||||
"",
|
"",
|
||||||
" on_macos do",
|
" on_macos do",
|
||||||
" if Hardware::CPU.intel?",
|
" if Hardware::CPU.intel?",
|
||||||
@@ -206,15 +177,18 @@ const program = Effect.gen(function* () {
|
|||||||
"",
|
"",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
|
|
||||||
yield* Effect.promise(async () => {
|
const token = process.env.GITHUB_TOKEN
|
||||||
await $`rm -rf ./dist/homebrew-tap`
|
if (!token) {
|
||||||
await $`git clone https://x-access-token:${token}@github.com/anomalyco/homebrew-tap.git ./dist/homebrew-tap`
|
console.error("GITHUB_TOKEN is required to update homebrew tap")
|
||||||
await Bun.write("./dist/homebrew-tap/opencode.rb", homebrewFormula)
|
process.exit(1)
|
||||||
await $`cd ./dist/homebrew-tap && git add opencode.rb`
|
}
|
||||||
if ((await $`cd ./dist/homebrew-tap && git diff --cached --quiet`.nothrow()).exitCode === 0) return
|
const tap = `https://x-access-token:${token}@github.com/anomalyco/homebrew-tap.git`
|
||||||
|
await $`rm -rf ./dist/homebrew-tap`
|
||||||
|
await $`git clone ${tap} ./dist/homebrew-tap`
|
||||||
|
await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula)
|
||||||
|
await $`cd ./dist/homebrew-tap && git add opencode.rb`
|
||||||
|
if ((await $`cd ./dist/homebrew-tap && git diff --cached --quiet`.nothrow()).exitCode !== 0) {
|
||||||
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
|
await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"`
|
||||||
await $`cd ./dist/homebrew-tap && git push`
|
await $`cd ./dist/homebrew-tap && git push`
|
||||||
})
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
await Effect.runPromise(program)
|
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
# Schema migration
|
# Schema migration
|
||||||
|
|
||||||
Practical reference for migrating data types in `packages/opencode` from Zod-first definitions to Effect Schema with Zod compatibility shims.
|
Practical reference for migrating data types in `packages/opencode` from
|
||||||
|
Zod-first definitions to Effect Schema with Zod compatibility shims.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
Use Effect Schema as the source of truth for domain models, IDs, inputs, outputs, and typed errors.
|
Use Effect Schema as the source of truth for domain models, IDs, inputs,
|
||||||
|
outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
|
||||||
|
compatibility boundaries by exposing a `.zod` static derived from the Effect
|
||||||
|
schema via `@/util/effect-zod`.
|
||||||
|
|
||||||
Keep Zod available at existing HTTP, tool, and compatibility boundaries by exposing a `.zod` field derived from the Effect schema.
|
The long-term driver is `specs/effect/http-api.md` — once the HTTP server
|
||||||
|
moves to `@effect/platform`, every Schema-first DTO can flow through
|
||||||
|
`HttpApi` / `HttpRouter` without a zod translation layer, and the entire
|
||||||
|
`effect-zod` walker plus every `.zod` static can be deleted.
|
||||||
|
|
||||||
## Preferred shapes
|
## Preferred shapes
|
||||||
|
|
||||||
@@ -24,17 +31,14 @@ export class Info extends Schema.Class<Info>("Foo.Info")({
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
If the class cannot reference itself cleanly during initialization, use the existing two-step pattern:
|
If the class cannot reference itself cleanly during initialization, use the
|
||||||
|
two-step `withStatics` pattern:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const _Info = Schema.Struct({
|
export const Info = Schema.Struct({
|
||||||
id: FooID,
|
id: FooID,
|
||||||
name: Schema.String,
|
name: Schema.String,
|
||||||
})
|
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
|
|
||||||
export const Info = Object.assign(_Info, {
|
|
||||||
zod: zod(_Info),
|
|
||||||
})
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Errors
|
### Errors
|
||||||
@@ -49,27 +53,89 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Foo
|
|||||||
|
|
||||||
### IDs and branded leaf types
|
### IDs and branded leaf types
|
||||||
|
|
||||||
Keep branded/schema-backed IDs as Effect schemas and expose `static readonly zod` for compatibility when callers still expect Zod.
|
Keep branded/schema-backed IDs as Effect schemas and expose
|
||||||
|
`static readonly zod` for compatibility when callers still expect Zod.
|
||||||
|
|
||||||
|
### Refinements
|
||||||
|
|
||||||
|
Reuse named refinements instead of re-spelling `z.number().int().positive()`
|
||||||
|
in every schema. The `effect-zod` walker translates the Effect versions into
|
||||||
|
the corresponding zod methods, so JSON Schema output (`type: integer`,
|
||||||
|
`exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
|
||||||
|
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
|
||||||
|
const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
|
||||||
|
```
|
||||||
|
|
||||||
|
See `test/util/effect-zod.test.ts` for the full set of translated checks.
|
||||||
|
|
||||||
## Compatibility rule
|
## Compatibility rule
|
||||||
|
|
||||||
During migration, route validators, tool parameters, and any existing Zod-based boundary should consume the derived `.zod` schema instead of maintaining a second hand-written Zod schema.
|
During migration, route validators, tool parameters, and any existing
|
||||||
|
Zod-based boundary should consume the derived `.zod` schema instead of
|
||||||
|
maintaining a second hand-written Zod schema.
|
||||||
|
|
||||||
The default should be:
|
The default should be:
|
||||||
|
|
||||||
- Effect Schema owns the type
|
- Effect Schema owns the type
|
||||||
- `.zod` exists only as a compatibility surface
|
- `.zod` exists only as a compatibility surface
|
||||||
- new domain models should not start Zod-first unless there is a concrete boundary-specific need
|
- new domain models should not start Zod-first unless there is a concrete
|
||||||
|
boundary-specific need
|
||||||
|
|
||||||
## When Zod can stay
|
## When Zod can stay
|
||||||
|
|
||||||
It is fine to keep a Zod-native schema temporarily when:
|
It is fine to keep a Zod-native schema temporarily when:
|
||||||
|
|
||||||
- the type is only used at an HTTP or tool boundary
|
- the type is only used at an HTTP or tool boundary and is not reused elsewhere
|
||||||
- the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
|
- the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
|
||||||
- the migration would force unrelated churn across a large call graph
|
- the migration would force unrelated churn across a large call graph
|
||||||
|
|
||||||
When this happens, prefer leaving a short note or TODO rather than silently creating a parallel schema source of truth.
|
When this happens, prefer leaving a short note or TODO rather than silently
|
||||||
|
creating a parallel schema source of truth.
|
||||||
|
|
||||||
|
## Escape hatches
|
||||||
|
|
||||||
|
The walker in `@/util/effect-zod` exposes three explicit escape hatches for
|
||||||
|
cases the pure-Schema path cannot express. Each one stays in the codebase
|
||||||
|
only as long as its upstream or local dependency requires it — inline
|
||||||
|
comments document when each can be deleted.
|
||||||
|
|
||||||
|
### `ZodOverride` annotation
|
||||||
|
|
||||||
|
Replaces the entire derivation with a hand-crafted zod schema. Used when:
|
||||||
|
|
||||||
|
- the target carries external `$ref` metadata (e.g.
|
||||||
|
`config/model-id.ts` points at `https://models.dev/...`)
|
||||||
|
- the target is a zod-only schema that cannot yet be expressed as Schema
|
||||||
|
(e.g. `ConfigAgent.Info`, `ConfigPermission.Info`, `Log.Level`)
|
||||||
|
|
||||||
|
### `ZodPreprocess` annotation
|
||||||
|
|
||||||
|
Wraps the derived zod schema with `z.preprocess(fn, inner)`. Used by
|
||||||
|
`config/permission.ts` to inject `__originalKeys` before parsing, because
|
||||||
|
`Schema.StructWithRest` canonicalises output (known fields first, catchall
|
||||||
|
after) and destroys the user's original property order — which permission
|
||||||
|
rule precedence depends on.
|
||||||
|
|
||||||
|
Tracked upstream as `effect:core/wlh553`: "Schema: add preserveInputOrder
|
||||||
|
(or pre-parse hook) for open structs." Once that lands, `ZodPreprocess` and
|
||||||
|
the `__originalKeys` hack can both be deleted.
|
||||||
|
|
||||||
|
### Local `DeepMutable<T>` in `config/config.ts`
|
||||||
|
|
||||||
|
`Schema.Struct` produces `readonly` types. Some consumer code (notably the
|
||||||
|
`Config` service) mutates `Info` objects directly, so a readonly-stripping
|
||||||
|
utility is needed when casting the derived zod schema's output type.
|
||||||
|
|
||||||
|
`Types.DeepMutable` from effect-smol would be a drop-in, but it widens
|
||||||
|
`unknown` to `{}` in the fallback branch — a bug that affects any schema
|
||||||
|
using `Schema.Record(String, Schema.Unknown)`.
|
||||||
|
|
||||||
|
Tracked upstream as `effect:core/x228my`: "Types.DeepMutable widens unknown
|
||||||
|
to `{}`." Once that lands, the local `DeepMutable` copy can be deleted and
|
||||||
|
`Types.DeepMutable` used directly.
|
||||||
|
|
||||||
## Ordering
|
## Ordering
|
||||||
|
|
||||||
@@ -81,19 +147,179 @@ Migrate in this order:
|
|||||||
4. Service-local internal models
|
4. Service-local internal models
|
||||||
5. Route and tool boundary validators that can switch to `.zod`
|
5. Route and tool boundary validators that can switch to `.zod`
|
||||||
|
|
||||||
This keeps shared types canonical first and makes boundary updates mostly mechanical.
|
This keeps shared types canonical first and makes boundary updates mostly
|
||||||
|
mechanical.
|
||||||
|
|
||||||
## Checklist
|
## Progress tracker
|
||||||
|
|
||||||
- [ ] Shared `schema.ts` leaf models are Effect Schema-first
|
### `src/config/` ✅ complete
|
||||||
- [ ] Exported `Info` / `Input` / `Output` types use `Schema.Class` where appropriate
|
|
||||||
- [ ] Domain errors use `Schema.TaggedErrorClass`
|
All of `packages/opencode/src/config/` has been migrated. Files that still
|
||||||
- [ ] Migrated types expose `.zod` for back compatibility
|
import `z` do so only for local `ZodOverride` bridges or for `z.ZodType`
|
||||||
- [ ] Route and tool validators consume derived `.zod` instead of duplicate Zod definitions
|
type annotations — the `export const <Info|Spec>` values are all Effect
|
||||||
- [ ] New domain models default to Effect Schema first
|
Schema at source.
|
||||||
|
|
||||||
|
- [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider
|
||||||
|
- [x] server, layout
|
||||||
|
- [x] keybinds
|
||||||
|
- [x] permission#Info
|
||||||
|
- [x] agent
|
||||||
|
- [x] config.ts root
|
||||||
|
|
||||||
|
### `src/*/schema.ts` leaf modules
|
||||||
|
|
||||||
|
These are the highest-priority next targets. Each is a small, self-contained
|
||||||
|
schema module with a clear domain.
|
||||||
|
|
||||||
|
- [ ] `src/control-plane/schema.ts`
|
||||||
|
- [ ] `src/permission/schema.ts`
|
||||||
|
- [ ] `src/project/schema.ts`
|
||||||
|
- [ ] `src/provider/schema.ts`
|
||||||
|
- [ ] `src/pty/schema.ts`
|
||||||
|
- [ ] `src/question/schema.ts`
|
||||||
|
- [ ] `src/session/schema.ts`
|
||||||
|
- [ ] `src/sync/schema.ts`
|
||||||
|
- [ ] `src/tool/schema.ts`
|
||||||
|
|
||||||
|
### Session domain
|
||||||
|
|
||||||
|
Major cluster. Message + event types flow through the SSE API and every SDK
|
||||||
|
output, so byte-identical SDK surface is critical.
|
||||||
|
|
||||||
|
- [ ] `src/session/compaction.ts`
|
||||||
|
- [ ] `src/session/message-v2.ts`
|
||||||
|
- [ ] `src/session/message.ts`
|
||||||
|
- [ ] `src/session/prompt.ts`
|
||||||
|
- [ ] `src/session/revert.ts`
|
||||||
|
- [ ] `src/session/session.ts`
|
||||||
|
- [ ] `src/session/status.ts`
|
||||||
|
- [ ] `src/session/summary.ts`
|
||||||
|
- [ ] `src/session/todo.ts`
|
||||||
|
|
||||||
|
### Provider domain
|
||||||
|
|
||||||
|
- [ ] `src/provider/auth.ts`
|
||||||
|
- [ ] `src/provider/models.ts`
|
||||||
|
- [ ] `src/provider/provider.ts`
|
||||||
|
|
||||||
|
### Tool schemas
|
||||||
|
|
||||||
|
Each tool declares its parameters via a zod schema. Tools are consumed by
|
||||||
|
both the in-process runtime and the AI SDK's tool-calling layer, so the
|
||||||
|
emitted JSON Schema must stay byte-identical.
|
||||||
|
|
||||||
|
- [ ] `src/tool/apply_patch.ts`
|
||||||
|
- [ ] `src/tool/bash.ts`
|
||||||
|
- [ ] `src/tool/codesearch.ts`
|
||||||
|
- [ ] `src/tool/edit.ts`
|
||||||
|
- [ ] `src/tool/glob.ts`
|
||||||
|
- [ ] `src/tool/grep.ts`
|
||||||
|
- [ ] `src/tool/invalid.ts`
|
||||||
|
- [ ] `src/tool/lsp.ts`
|
||||||
|
- [ ] `src/tool/multiedit.ts`
|
||||||
|
- [ ] `src/tool/plan.ts`
|
||||||
|
- [ ] `src/tool/question.ts`
|
||||||
|
- [ ] `src/tool/read.ts`
|
||||||
|
- [ ] `src/tool/registry.ts`
|
||||||
|
- [ ] `src/tool/skill.ts`
|
||||||
|
- [ ] `src/tool/task.ts`
|
||||||
|
- [ ] `src/tool/todo.ts`
|
||||||
|
- [ ] `src/tool/tool.ts`
|
||||||
|
- [ ] `src/tool/webfetch.ts`
|
||||||
|
- [ ] `src/tool/websearch.ts`
|
||||||
|
- [ ] `src/tool/write.ts`
|
||||||
|
|
||||||
|
### HTTP route boundaries
|
||||||
|
|
||||||
|
Every file in `src/server/routes/` uses hono-openapi with zod validators for
|
||||||
|
route inputs/outputs. Migrating these individually is the last step; most
|
||||||
|
will switch to `.zod` derived from the Schema-migrated domain types above,
|
||||||
|
which means touching them is largely mechanical once the domain side is
|
||||||
|
done.
|
||||||
|
|
||||||
|
- [ ] `src/server/error.ts`
|
||||||
|
- [ ] `src/server/event.ts`
|
||||||
|
- [ ] `src/server/projectors.ts`
|
||||||
|
- [ ] `src/server/routes/control/index.ts`
|
||||||
|
- [ ] `src/server/routes/control/workspace.ts`
|
||||||
|
- [ ] `src/server/routes/global.ts`
|
||||||
|
- [ ] `src/server/routes/instance/index.ts`
|
||||||
|
- [ ] `src/server/routes/instance/config.ts`
|
||||||
|
- [ ] `src/server/routes/instance/event.ts`
|
||||||
|
- [ ] `src/server/routes/instance/experimental.ts`
|
||||||
|
- [ ] `src/server/routes/instance/file.ts`
|
||||||
|
- [ ] `src/server/routes/instance/mcp.ts`
|
||||||
|
- [ ] `src/server/routes/instance/permission.ts`
|
||||||
|
- [ ] `src/server/routes/instance/project.ts`
|
||||||
|
- [ ] `src/server/routes/instance/provider.ts`
|
||||||
|
- [ ] `src/server/routes/instance/pty.ts`
|
||||||
|
- [ ] `src/server/routes/instance/question.ts`
|
||||||
|
- [ ] `src/server/routes/instance/session.ts`
|
||||||
|
- [ ] `src/server/routes/instance/sync.ts`
|
||||||
|
- [ ] `src/server/routes/instance/tui.ts`
|
||||||
|
|
||||||
|
The bigger prize for this group is the `@effect/platform` HTTP migration
|
||||||
|
described in `specs/effect/http-api.md`. Once that lands, every one of
|
||||||
|
these files changes shape entirely (`HttpApi.endpoint(...)` and friends),
|
||||||
|
so the Schema-first domain types become a prerequisite rather than a
|
||||||
|
sibling task.
|
||||||
|
|
||||||
|
### Everything else
|
||||||
|
|
||||||
|
Small / shared / control-plane / CLI. Mostly independent; can be done
|
||||||
|
piecewise.
|
||||||
|
|
||||||
|
- [ ] `src/acp/agent.ts`
|
||||||
|
- [ ] `src/agent/agent.ts`
|
||||||
|
- [ ] `src/bus/bus-event.ts`
|
||||||
|
- [ ] `src/bus/index.ts`
|
||||||
|
- [ ] `src/cli/cmd/tui/config/tui-migrate.ts`
|
||||||
|
- [ ] `src/cli/cmd/tui/config/tui-schema.ts`
|
||||||
|
- [ ] `src/cli/cmd/tui/config/tui.ts`
|
||||||
|
- [ ] `src/cli/cmd/tui/event.ts`
|
||||||
|
- [ ] `src/cli/ui.ts`
|
||||||
|
- [ ] `src/command/index.ts`
|
||||||
|
- [ ] `src/control-plane/adaptors/worktree.ts`
|
||||||
|
- [ ] `src/control-plane/types.ts`
|
||||||
|
- [ ] `src/control-plane/workspace.ts`
|
||||||
|
- [ ] `src/file/index.ts`
|
||||||
|
- [ ] `src/file/ripgrep.ts`
|
||||||
|
- [ ] `src/file/watcher.ts`
|
||||||
|
- [ ] `src/format/index.ts`
|
||||||
|
- [ ] `src/id/id.ts`
|
||||||
|
- [ ] `src/ide/index.ts`
|
||||||
|
- [ ] `src/installation/index.ts`
|
||||||
|
- [ ] `src/lsp/client.ts`
|
||||||
|
- [ ] `src/lsp/lsp.ts`
|
||||||
|
- [ ] `src/mcp/auth.ts`
|
||||||
|
- [ ] `src/patch/index.ts`
|
||||||
|
- [ ] `src/plugin/github-copilot/models.ts`
|
||||||
|
- [ ] `src/project/project.ts`
|
||||||
|
- [ ] `src/project/vcs.ts`
|
||||||
|
- [ ] `src/pty/index.ts`
|
||||||
|
- [ ] `src/skill/index.ts`
|
||||||
|
- [ ] `src/snapshot/index.ts`
|
||||||
|
- [ ] `src/storage/db.ts`
|
||||||
|
- [ ] `src/storage/storage.ts`
|
||||||
|
- [ ] `src/sync/index.ts`
|
||||||
|
- [ ] `src/util/fn.ts`
|
||||||
|
- [ ] `src/util/log.ts`
|
||||||
|
- [ ] `src/util/update-schema.ts`
|
||||||
|
- [ ] `src/worktree/index.ts`
|
||||||
|
|
||||||
|
### Do-not-migrate
|
||||||
|
|
||||||
|
- `src/util/effect-zod.ts` — the walker itself. Stays zod-importing forever
|
||||||
|
(it's what emits zod from Schema). Goes away only when the `.zod`
|
||||||
|
compatibility layer is no longer needed anywhere.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- Use `@/util/effect-zod` for all Schema -> Zod conversion.
|
- Use `@/util/effect-zod` for all Schema → Zod conversion.
|
||||||
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and Effect definitions for the same domain type.
|
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and
|
||||||
- Keep the migration incremental. Converting the domain model first is more valuable than converting every boundary in the same change.
|
Effect definitions for the same domain type.
|
||||||
|
- Keep the migration incremental. Converting the domain model first is more
|
||||||
|
valuable than converting every boundary in the same change.
|
||||||
|
- Every migrated file should leave the generated SDK output (`packages/sdk/
|
||||||
|
openapi.json` and `packages/sdk/js/src/v2/gen/types.gen.ts`) byte-identical
|
||||||
|
unless the change is deliberately user-visible.
|
||||||
|
|||||||
@@ -25,7 +25,19 @@ export const GenerateCommand = {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const json = JSON.stringify(specs, null, 2)
|
const raw = JSON.stringify(specs, null, 2)
|
||||||
|
|
||||||
|
// Format through prettier so output is byte-identical to committed file
|
||||||
|
// regardless of whether ./script/format.ts runs afterward.
|
||||||
|
const prettier = await import("prettier")
|
||||||
|
const babel = await import("prettier/plugins/babel")
|
||||||
|
const estree = await import("prettier/plugins/estree")
|
||||||
|
const format = prettier.format ?? prettier.default?.format
|
||||||
|
const json = await format(raw, {
|
||||||
|
parser: "json",
|
||||||
|
plugins: [babel.default ?? babel, estree.default ?? estree],
|
||||||
|
printWidth: 120,
|
||||||
|
})
|
||||||
|
|
||||||
// Wait for stdout to finish writing before process.exit() is called
|
// Wait for stdout to finish writing before process.exit() is called
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
|||||||
@@ -467,7 +467,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
|
|||||||
return store.status
|
return store.status
|
||||||
},
|
},
|
||||||
get ready() {
|
get ready() {
|
||||||
return true
|
|
||||||
if (process.env.OPENCODE_FAST_BOOT) return true
|
if (process.env.OPENCODE_FAST_BOOT) return true
|
||||||
return store.status !== "loading"
|
return store.status !== "loading"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import type { TextPart } from "@opencode-ai/sdk/v2"
|
|||||||
import { Locale } from "@/util"
|
import { Locale } from "@/util"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { useRoute } from "@tui/context/route"
|
import { useRoute } from "@tui/context/route"
|
||||||
import { useDialog } from "../../ui/dialog"
|
import { useDialog, type DialogContext } from "../../ui/dialog"
|
||||||
import type { PromptInfo } from "@tui/component/prompt/history"
|
import type { PromptInfo } from "@tui/component/prompt/history"
|
||||||
import { strip } from "@tui/component/prompt/part"
|
import { strip } from "@tui/component/prompt/part"
|
||||||
|
|
||||||
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
|
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID?: string) => void }) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
@@ -19,9 +19,21 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
|||||||
dialog.setSize("large")
|
dialog.setSize("large")
|
||||||
})
|
})
|
||||||
|
|
||||||
const options = createMemo((): DialogSelectOption<string>[] => {
|
const options = createMemo((): DialogSelectOption<string | undefined>[] => {
|
||||||
const messages = sync.data.message[props.sessionID] ?? []
|
const messages = sync.data.message[props.sessionID] ?? []
|
||||||
const result = [] as DialogSelectOption<string>[]
|
const fullSession = {
|
||||||
|
title: "Full session",
|
||||||
|
value: undefined,
|
||||||
|
onSelect: async (dialog: DialogContext) => {
|
||||||
|
const forked = await sdk.client.session.fork({ sessionID: props.sessionID })
|
||||||
|
route.navigate({
|
||||||
|
sessionID: forked.data!.id,
|
||||||
|
type: "session",
|
||||||
|
})
|
||||||
|
dialog.clear()
|
||||||
|
},
|
||||||
|
} satisfies DialogSelectOption<string | undefined>
|
||||||
|
const result = [] as DialogSelectOption<string | undefined>[]
|
||||||
for (const message of messages) {
|
for (const message of messages) {
|
||||||
if (message.role !== "user") continue
|
if (message.role !== "user") continue
|
||||||
const part = (sync.data.part[message.id] ?? []).find(
|
const part = (sync.data.part[message.id] ?? []).find(
|
||||||
@@ -57,9 +69,8 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
result.reverse()
|
return [fullSession, ...result.reverse()]
|
||||||
return result
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork from message" options={options()} />
|
return <DialogSelect onMove={(option) => props.onMove(option.value)} title="Fork session" options={options()} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -451,7 +451,7 @@ export function Session() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Fork from message",
|
title: "Fork session",
|
||||||
value: "session.fork",
|
value: "session.fork",
|
||||||
keybind: "session_fork",
|
keybind: "session_fork",
|
||||||
category: "Session",
|
category: "Session",
|
||||||
@@ -462,6 +462,7 @@ export function Session() {
|
|||||||
dialog.replace(() => (
|
dialog.replace(() => (
|
||||||
<DialogForkFromTimeline
|
<DialogForkFromTimeline
|
||||||
onMove={(messageID) => {
|
onMove={(messageID) => {
|
||||||
|
if (!messageID) return
|
||||||
const child = scroll.getChildren().find((child) => {
|
const child = scroll.getChildren().find((child) => {
|
||||||
return child.id === messageID
|
return child.id === messageID
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
export * as ConfigAgent from "./agent"
|
export * as ConfigAgent from "./agent"
|
||||||
|
|
||||||
import { Log } from "../util"
|
import { Schema } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||||
|
import { Log } from "../util"
|
||||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { configEntryNameFromPath } from "./entry-name"
|
import { configEntryNameFromPath } from "./entry-name"
|
||||||
import { InvalidError } from "./error"
|
import { InvalidError } from "./error"
|
||||||
import * as ConfigMarkdown from "./markdown"
|
import * as ConfigMarkdown from "./markdown"
|
||||||
@@ -13,89 +15,102 @@ import { ConfigPermission } from "./permission"
|
|||||||
|
|
||||||
const log = Log.create({ service: "config" })
|
const log = Log.create({ service: "config" })
|
||||||
|
|
||||||
export const Info = z
|
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
|
||||||
.object({
|
|
||||||
model: ConfigModelID.zod.optional(),
|
|
||||||
variant: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Default model variant for this agent (applies only when using the agent's configured model)."),
|
|
||||||
temperature: z.number().optional(),
|
|
||||||
top_p: z.number().optional(),
|
|
||||||
prompt: z.string().optional(),
|
|
||||||
tools: z.record(z.string(), z.boolean()).optional().describe("@deprecated Use 'permission' field instead"),
|
|
||||||
disable: z.boolean().optional(),
|
|
||||||
description: z.string().optional().describe("Description of when to use the agent"),
|
|
||||||
mode: z.enum(["subagent", "primary", "all"]).optional(),
|
|
||||||
hidden: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"),
|
|
||||||
options: z.record(z.string(), z.any()).optional(),
|
|
||||||
color: z
|
|
||||||
.union([
|
|
||||||
z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"),
|
|
||||||
z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
|
||||||
])
|
|
||||||
.optional()
|
|
||||||
.describe("Hex color code (e.g., #FF5733) or theme color (e.g., primary)"),
|
|
||||||
steps: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.positive()
|
|
||||||
.optional()
|
|
||||||
.describe("Maximum number of agentic iterations before forcing text-only response"),
|
|
||||||
maxSteps: z.number().int().positive().optional().describe("@deprecated Use 'steps' field instead."),
|
|
||||||
permission: ConfigPermission.Info.optional(),
|
|
||||||
})
|
|
||||||
.catchall(z.any())
|
|
||||||
.transform((agent, _ctx) => {
|
|
||||||
const knownKeys = new Set([
|
|
||||||
"name",
|
|
||||||
"model",
|
|
||||||
"variant",
|
|
||||||
"prompt",
|
|
||||||
"description",
|
|
||||||
"temperature",
|
|
||||||
"top_p",
|
|
||||||
"mode",
|
|
||||||
"hidden",
|
|
||||||
"color",
|
|
||||||
"steps",
|
|
||||||
"maxSteps",
|
|
||||||
"options",
|
|
||||||
"permission",
|
|
||||||
"disable",
|
|
||||||
"tools",
|
|
||||||
])
|
|
||||||
|
|
||||||
const options: Record<string, unknown> = { ...agent.options }
|
const Color = Schema.Union([
|
||||||
for (const [key, value] of Object.entries(agent)) {
|
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
|
||||||
if (!knownKeys.has(key)) options[key] = value
|
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
||||||
|
])
|
||||||
|
|
||||||
|
// ConfigPermission.Info is a zod schema (its `.preprocess(...).transform(...)`
|
||||||
|
// shape lives outside the Effect Schema type system), so the walker reaches it
|
||||||
|
// via ZodOverride rather than a pure Schema reference. This preserves the
|
||||||
|
// `$ref: PermissionConfig` emitted in openapi.json.
|
||||||
|
const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
|
||||||
|
|
||||||
|
const AgentSchema = Schema.StructWithRest(
|
||||||
|
Schema.Struct({
|
||||||
|
model: Schema.optional(ConfigModelID),
|
||||||
|
variant: Schema.optional(Schema.String).annotate({
|
||||||
|
description: "Default model variant for this agent (applies only when using the agent's configured model).",
|
||||||
|
}),
|
||||||
|
temperature: Schema.optional(Schema.Number),
|
||||||
|
top_p: Schema.optional(Schema.Number),
|
||||||
|
prompt: Schema.optional(Schema.String),
|
||||||
|
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
|
||||||
|
description: "@deprecated Use 'permission' field instead",
|
||||||
|
}),
|
||||||
|
disable: Schema.optional(Schema.Boolean),
|
||||||
|
description: Schema.optional(Schema.String).annotate({ description: "Description of when to use the agent" }),
|
||||||
|
mode: Schema.optional(Schema.Literals(["subagent", "primary", "all"])),
|
||||||
|
hidden: Schema.optional(Schema.Boolean).annotate({
|
||||||
|
description: "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)",
|
||||||
|
}),
|
||||||
|
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||||
|
color: Schema.optional(Color).annotate({
|
||||||
|
description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",
|
||||||
|
}),
|
||||||
|
steps: Schema.optional(PositiveInt).annotate({
|
||||||
|
description: "Maximum number of agentic iterations before forcing text-only response",
|
||||||
|
}),
|
||||||
|
maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }),
|
||||||
|
permission: Schema.optional(PermissionRef),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Schema.Any)],
|
||||||
|
)
|
||||||
|
|
||||||
|
const KNOWN_KEYS = new Set([
|
||||||
|
"name",
|
||||||
|
"model",
|
||||||
|
"variant",
|
||||||
|
"prompt",
|
||||||
|
"description",
|
||||||
|
"temperature",
|
||||||
|
"top_p",
|
||||||
|
"mode",
|
||||||
|
"hidden",
|
||||||
|
"color",
|
||||||
|
"steps",
|
||||||
|
"maxSteps",
|
||||||
|
"options",
|
||||||
|
"permission",
|
||||||
|
"disable",
|
||||||
|
"tools",
|
||||||
|
])
|
||||||
|
|
||||||
|
// Post-parse normalisation:
|
||||||
|
// - Promote any unknown-but-present keys into `options` so they survive the
|
||||||
|
// round-trip in a well-known field.
|
||||||
|
// - Translate the deprecated `tools: { name: boolean }` map into the new
|
||||||
|
// `permission` shape (write-adjacent tools collapse into `permission.edit`).
|
||||||
|
// - Coalesce `steps ?? maxSteps` so downstream can ignore the deprecated alias.
|
||||||
|
const normalize = (agent: z.infer<typeof Info>) => {
|
||||||
|
const options: Record<string, unknown> = { ...agent.options }
|
||||||
|
for (const [key, value] of Object.entries(agent)) {
|
||||||
|
if (!KNOWN_KEYS.has(key)) options[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission: ConfigPermission.Info = {}
|
||||||
|
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
|
||||||
|
const action = enabled ? "allow" : "deny"
|
||||||
|
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
||||||
|
permission.edit = action
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
permission[tool] = action
|
||||||
|
}
|
||||||
|
globalThis.Object.assign(permission, agent.permission)
|
||||||
|
|
||||||
const permission: ConfigPermission.Info = {}
|
return { ...agent, options, permission, steps: agent.steps ?? agent.maxSteps }
|
||||||
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
|
}
|
||||||
const action = enabled ? "allow" : "deny"
|
|
||||||
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
|
||||||
permission.edit = action
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
permission[tool] = action
|
|
||||||
}
|
|
||||||
Object.assign(permission, agent.permission)
|
|
||||||
|
|
||||||
const steps = agent.steps ?? agent.maxSteps
|
export const Info = zod(AgentSchema).transform(normalize).meta({ ref: "AgentConfig" }) as unknown as z.ZodType<
|
||||||
|
Omit<z.infer<ReturnType<typeof zod<typeof AgentSchema>>>, "options" | "permission" | "steps"> & {
|
||||||
return { ...agent, options, permission, steps } as typeof agent & {
|
options?: Record<string, unknown>
|
||||||
options?: Record<string, unknown>
|
permission?: ConfigPermission.Info
|
||||||
permission?: ConfigPermission.Info
|
steps?: number
|
||||||
steps?: number
|
}
|
||||||
}
|
>
|
||||||
})
|
|
||||||
.meta({
|
|
||||||
ref: "AgentConfig",
|
|
||||||
})
|
|
||||||
export type Info = z.infer<typeof Info>
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
export async function load(dir: string) {
|
export async function load(dir: string) {
|
||||||
|
|||||||
@@ -21,9 +21,10 @@ import { isRecord } from "@/util/record"
|
|||||||
import type { ConsoleState } from "./console-state"
|
import type { ConsoleState } from "./console-state"
|
||||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||||
import { InstanceState } from "@/effect"
|
import { InstanceState } from "@/effect"
|
||||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect"
|
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
||||||
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
||||||
import { InstanceRef } from "@/effect/instance-ref"
|
import { InstanceRef } from "@/effect/instance-ref"
|
||||||
|
import { zod, ZodOverride } from "@/util/effect-zod"
|
||||||
import { ConfigAgent } from "./agent"
|
import { ConfigAgent } from "./agent"
|
||||||
import { ConfigCommand } from "./command"
|
import { ConfigCommand } from "./command"
|
||||||
import { ConfigFormatter } from "./formatter"
|
import { ConfigFormatter } from "./formatter"
|
||||||
@@ -79,152 +80,182 @@ export const Server = ConfigServer.Server.zod
|
|||||||
export const Layout = ConfigLayout.Layout.zod
|
export const Layout = ConfigLayout.Layout.zod
|
||||||
export type Layout = ConfigLayout.Layout
|
export type Layout = ConfigLayout.Layout
|
||||||
|
|
||||||
export const Info = z
|
// Schemas that still live at the zod layer (have .transform / .preprocess /
|
||||||
.object({
|
// .meta not expressible in current Effect Schema) get referenced via a
|
||||||
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
|
// ZodOverride-annotated Schema.Any. Walker sees the annotation and emits the
|
||||||
logLevel: Log.Level.optional().describe("Log level"),
|
// exact zod directly, preserving component $refs.
|
||||||
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
|
const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info })
|
||||||
command: z
|
const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
|
||||||
.record(z.string(), ConfigCommand.Info.zod)
|
const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
|
||||||
.optional()
|
|
||||||
.describe("Command configuration, see https://opencode.ai/docs/commands"),
|
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
|
||||||
skills: ConfigSkills.Info.zod.optional().describe("Additional skill folder paths"),
|
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))
|
||||||
watcher: z
|
|
||||||
.object({
|
const InfoSchema = Schema.Struct({
|
||||||
ignore: z.array(z.string()).optional(),
|
$schema: Schema.optional(Schema.String).annotate({
|
||||||
})
|
description: "JSON schema reference for configuration validation",
|
||||||
.optional(),
|
}),
|
||||||
snapshot: z
|
logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }),
|
||||||
.boolean()
|
server: Schema.optional(ConfigServer.Server).annotate({
|
||||||
.optional()
|
description: "Server configuration for opencode serve and web commands",
|
||||||
.describe(
|
}),
|
||||||
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
command: Schema.optional(Schema.Record(Schema.String, ConfigCommand.Info)).annotate({
|
||||||
),
|
description: "Command configuration, see https://opencode.ai/docs/commands",
|
||||||
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
|
}),
|
||||||
plugin: ConfigPlugin.Spec.zod.array().optional(),
|
skills: Schema.optional(ConfigSkills.Info).annotate({ description: "Additional skill folder paths" }),
|
||||||
share: z
|
watcher: Schema.optional(
|
||||||
.enum(["manual", "auto", "disabled"])
|
Schema.Struct({
|
||||||
.optional()
|
ignore: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||||
.describe(
|
}),
|
||||||
"Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing",
|
),
|
||||||
),
|
snapshot: Schema.optional(Schema.Boolean).annotate({
|
||||||
autoshare: z
|
description:
|
||||||
.boolean()
|
"Enable or disable snapshot tracking. When false, filesystem snapshots are not recorded and undoing or reverting will not undo/redo file changes. Defaults to true.",
|
||||||
.optional()
|
}),
|
||||||
.describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"),
|
// User-facing plugin config is stored as Specs; provenance gets attached later while configs are merged.
|
||||||
autoupdate: z
|
plugin: Schema.optional(Schema.mutable(Schema.Array(ConfigPlugin.Spec))),
|
||||||
.union([z.boolean(), z.literal("notify")])
|
share: Schema.optional(Schema.Literals(["manual", "auto", "disabled"])).annotate({
|
||||||
.optional()
|
description:
|
||||||
.describe(
|
"Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing",
|
||||||
"Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications",
|
}),
|
||||||
),
|
autoshare: Schema.optional(Schema.Boolean).annotate({
|
||||||
disabled_providers: z.array(z.string()).optional().describe("Disable providers that are loaded automatically"),
|
description: "@deprecated Use 'share' field instead. Share newly created sessions automatically",
|
||||||
enabled_providers: z
|
}),
|
||||||
.array(z.string())
|
autoupdate: Schema.optional(Schema.Union([Schema.Boolean, Schema.Literal("notify")])).annotate({
|
||||||
.optional()
|
description:
|
||||||
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
|
"Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications",
|
||||||
model: ConfigModelID.zod.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
|
}),
|
||||||
small_model: ConfigModelID.zod
|
disabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
|
||||||
.describe("Small model to use for tasks like title generation in the format of provider/model")
|
description: "Disable providers that are loaded automatically",
|
||||||
.optional(),
|
}),
|
||||||
default_agent: z
|
enabled_providers: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
|
||||||
.string()
|
description: "When set, ONLY these providers will be enabled. All other providers will be ignored",
|
||||||
.optional()
|
}),
|
||||||
.describe(
|
model: Schema.optional(ConfigModelID).annotate({
|
||||||
"Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",
|
description: "Model to use in the format of provider/model, eg anthropic/claude-2",
|
||||||
),
|
}),
|
||||||
username: z.string().optional().describe("Custom username to display in conversations instead of system username"),
|
small_model: Schema.optional(ConfigModelID).annotate({
|
||||||
mode: z
|
description: "Small model to use for tasks like title generation in the format of provider/model",
|
||||||
.object({
|
}),
|
||||||
build: ConfigAgent.Info.optional(),
|
default_agent: Schema.optional(Schema.String).annotate({
|
||||||
plan: ConfigAgent.Info.optional(),
|
description:
|
||||||
})
|
"Default agent to use when none is specified. Must be a primary agent. Falls back to 'build' if not set or if the specified agent is invalid.",
|
||||||
.catchall(ConfigAgent.Info)
|
}),
|
||||||
.optional()
|
username: Schema.optional(Schema.String).annotate({
|
||||||
.describe("@deprecated Use `agent` field instead."),
|
description: "Custom username to display in conversations instead of system username",
|
||||||
agent: z
|
}),
|
||||||
.object({
|
mode: Schema.optional(
|
||||||
|
Schema.StructWithRest(
|
||||||
|
Schema.Struct({
|
||||||
|
build: Schema.optional(AgentRef),
|
||||||
|
plan: Schema.optional(AgentRef),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, AgentRef)],
|
||||||
|
),
|
||||||
|
).annotate({ description: "@deprecated Use `agent` field instead." }),
|
||||||
|
agent: Schema.optional(
|
||||||
|
Schema.StructWithRest(
|
||||||
|
Schema.Struct({
|
||||||
// primary
|
// primary
|
||||||
plan: ConfigAgent.Info.optional(),
|
plan: Schema.optional(AgentRef),
|
||||||
build: ConfigAgent.Info.optional(),
|
build: Schema.optional(AgentRef),
|
||||||
// subagent
|
// subagent
|
||||||
general: ConfigAgent.Info.optional(),
|
general: Schema.optional(AgentRef),
|
||||||
explore: ConfigAgent.Info.optional(),
|
explore: Schema.optional(AgentRef),
|
||||||
// specialized
|
// specialized
|
||||||
title: ConfigAgent.Info.optional(),
|
title: Schema.optional(AgentRef),
|
||||||
summary: ConfigAgent.Info.optional(),
|
summary: Schema.optional(AgentRef),
|
||||||
compaction: ConfigAgent.Info.optional(),
|
compaction: Schema.optional(AgentRef),
|
||||||
})
|
}),
|
||||||
.catchall(ConfigAgent.Info)
|
[Schema.Record(Schema.String, AgentRef)],
|
||||||
.optional()
|
),
|
||||||
.describe("Agent configuration, see https://opencode.ai/docs/agents"),
|
).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }),
|
||||||
provider: z
|
provider: Schema.optional(Schema.Record(Schema.String, ConfigProvider.Info)).annotate({
|
||||||
.record(z.string(), ConfigProvider.Info.zod)
|
description: "Custom provider configurations and model overrides",
|
||||||
.optional()
|
}),
|
||||||
.describe("Custom provider configurations and model overrides"),
|
mcp: Schema.optional(
|
||||||
mcp: z
|
Schema.Record(
|
||||||
.record(
|
Schema.String,
|
||||||
z.string(),
|
Schema.Union([
|
||||||
z.union([
|
ConfigMCP.Info,
|
||||||
ConfigMCP.Info.zod,
|
// Matches the legacy `{ enabled: false }` form used to disable a server.
|
||||||
z
|
Schema.Any.annotate({ [ZodOverride]: z.object({ enabled: z.boolean() }).strict() }),
|
||||||
.object({
|
]),
|
||||||
enabled: z.boolean(),
|
),
|
||||||
})
|
).annotate({ description: "MCP (Model Context Protocol) server configurations" }),
|
||||||
.strict(),
|
formatter: Schema.optional(ConfigFormatter.Info),
|
||||||
]),
|
lsp: Schema.optional(ConfigLSP.Info),
|
||||||
)
|
instructions: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
|
||||||
.optional()
|
description: "Additional instruction files or patterns to include",
|
||||||
.describe("MCP (Model Context Protocol) server configurations"),
|
}),
|
||||||
formatter: ConfigFormatter.Info.zod.optional(),
|
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
|
||||||
lsp: ConfigLSP.Info.zod.optional(),
|
permission: Schema.optional(PermissionRef),
|
||||||
instructions: z.array(z.string()).optional().describe("Additional instruction files or patterns to include"),
|
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||||
layout: Layout.optional().describe("@deprecated Always uses stretch layout."),
|
enterprise: Schema.optional(
|
||||||
permission: ConfigPermission.Info.optional(),
|
Schema.Struct({
|
||||||
tools: z.record(z.string(), z.boolean()).optional(),
|
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
|
||||||
enterprise: z
|
}),
|
||||||
.object({
|
),
|
||||||
url: z.string().optional().describe("Enterprise URL"),
|
compaction: Schema.optional(
|
||||||
})
|
Schema.Struct({
|
||||||
.optional(),
|
auto: Schema.optional(Schema.Boolean).annotate({
|
||||||
compaction: z
|
description: "Enable automatic compaction when context is full (default: true)",
|
||||||
.object({
|
}),
|
||||||
auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"),
|
prune: Schema.optional(Schema.Boolean).annotate({
|
||||||
prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
|
description: "Enable pruning of old tool outputs (default: true)",
|
||||||
reserved: z
|
}),
|
||||||
.number()
|
reserved: Schema.optional(NonNegativeInt).annotate({
|
||||||
.int()
|
description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
|
||||||
.min(0)
|
}),
|
||||||
.optional()
|
}),
|
||||||
.describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."),
|
),
|
||||||
})
|
experimental: Schema.optional(
|
||||||
.optional(),
|
Schema.Struct({
|
||||||
experimental: z
|
disable_paste_summary: Schema.optional(Schema.Boolean),
|
||||||
.object({
|
batch_tool: Schema.optional(Schema.Boolean).annotate({ description: "Enable the batch tool" }),
|
||||||
disable_paste_summary: z.boolean().optional(),
|
openTelemetry: Schema.optional(Schema.Boolean).annotate({
|
||||||
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
|
description: "Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)",
|
||||||
openTelemetry: z
|
}),
|
||||||
.boolean()
|
primary_tools: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({
|
||||||
.optional()
|
description: "Tools that should only be available to primary agents.",
|
||||||
.describe("Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag)"),
|
}),
|
||||||
primary_tools: z
|
continue_loop_on_deny: Schema.optional(Schema.Boolean).annotate({
|
||||||
.array(z.string())
|
description: "Continue the agent loop when a tool call is denied",
|
||||||
.optional()
|
}),
|
||||||
.describe("Tools that should only be available to primary agents."),
|
mcp_timeout: Schema.optional(PositiveInt).annotate({
|
||||||
continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"),
|
description: "Timeout in milliseconds for model context protocol (MCP) requests",
|
||||||
mcp_timeout: z
|
}),
|
||||||
.number()
|
}),
|
||||||
.int()
|
),
|
||||||
.positive()
|
})
|
||||||
.optional()
|
|
||||||
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
|
// Schema.Struct produces readonly types by default, but the service code
|
||||||
})
|
// below mutates Info objects directly (e.g. `config.mode = ...`). Strip the
|
||||||
.optional(),
|
// readonly recursively so callers get the same mutable shape zod inferred.
|
||||||
})
|
//
|
||||||
|
// `Types.DeepMutable` from effect-smol would be a drop-in, but its fallback
|
||||||
|
// branch `{ -readonly [K in keyof T]: ... }` collapses `unknown` to `{}`
|
||||||
|
// (since `keyof unknown = never`), which widens `Record<string, unknown>`
|
||||||
|
// fields like `ConfigPlugin.Options`. The local version gates on
|
||||||
|
// `extends object` so `unknown` passes through.
|
||||||
|
//
|
||||||
|
// Tuple branch preserves `ConfigPlugin.Spec`'s `readonly [string, Options]`
|
||||||
|
// shape (otherwise the general array branch widens it to an array).
|
||||||
|
type DeepMutable<T> = T extends readonly [unknown, ...unknown[]]
|
||||||
|
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
|
||||||
|
: T extends readonly (infer U)[]
|
||||||
|
? DeepMutable<U>[]
|
||||||
|
: T extends object
|
||||||
|
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
|
||||||
|
: T
|
||||||
|
|
||||||
|
// The walker emits `z.object({...})` which is non-strict by default. Config
|
||||||
|
// historically uses `.strict()` (additionalProperties: false in openapi.json),
|
||||||
|
// so layer that on after derivation. Re-apply the Config ref afterward
|
||||||
|
// since `.strict()` strips the walker's meta annotation.
|
||||||
|
export const Info = (zod(InfoSchema) as unknown as z.ZodObject<any>)
|
||||||
.strict()
|
.strict()
|
||||||
.meta({
|
.meta({ ref: "Config" }) as unknown as z.ZodType<DeepMutable<Schema.Schema.Type<typeof InfoSchema>>>
|
||||||
ref: "Config",
|
|
||||||
})
|
|
||||||
|
|
||||||
export type Info = z.output<typeof Info> & {
|
export type Info = z.output<typeof Info> & {
|
||||||
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
|
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
|
||||||
|
|||||||
@@ -1,166 +1,127 @@
|
|||||||
export * as ConfigKeybinds from "./keybinds"
|
export * as ConfigKeybinds from "./keybinds"
|
||||||
|
|
||||||
import z from "zod"
|
import { Effect, Schema } from "effect"
|
||||||
|
import type z from "zod"
|
||||||
|
import { zod } from "@/util/effect-zod"
|
||||||
|
|
||||||
export const Keybinds = z
|
// Every keybind field has the same shape: an optional string with a default
|
||||||
.object({
|
// binding and a human description. `keybind()` keeps the declaration list
|
||||||
leader: z.string().optional().default("ctrl+x").describe("Leader key for keybind combinations"),
|
// below dense and readable.
|
||||||
app_exit: z.string().optional().default("ctrl+c,ctrl+d,<leader>q").describe("Exit the application"),
|
const keybind = (value: string, description: string) =>
|
||||||
editor_open: z.string().optional().default("<leader>e").describe("Open external editor"),
|
Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(value))).annotate({ description })
|
||||||
theme_list: z.string().optional().default("<leader>t").describe("List available themes"),
|
|
||||||
sidebar_toggle: z.string().optional().default("<leader>b").describe("Toggle sidebar"),
|
// Windows prepends ctrl+z to the undo binding because `terminal_suspend`
|
||||||
scrollbar_toggle: z.string().optional().default("none").describe("Toggle session scrollbar"),
|
// cannot consume ctrl+z on native Windows terminals (no POSIX suspend).
|
||||||
username_toggle: z.string().optional().default("none").describe("Toggle username visibility"),
|
const inputUndoDefault = process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z"
|
||||||
status_view: z.string().optional().default("<leader>s").describe("View status"),
|
|
||||||
session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
|
const KeybindsSchema = Schema.Struct({
|
||||||
session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
|
leader: keybind("ctrl+x", "Leader key for keybind combinations"),
|
||||||
session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
|
app_exit: keybind("ctrl+c,ctrl+d,<leader>q", "Exit the application"),
|
||||||
session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
|
editor_open: keybind("<leader>e", "Open external editor"),
|
||||||
session_fork: z.string().optional().default("none").describe("Fork session from message"),
|
theme_list: keybind("<leader>t", "List available themes"),
|
||||||
session_rename: z.string().optional().default("ctrl+r").describe("Rename session"),
|
sidebar_toggle: keybind("<leader>b", "Toggle sidebar"),
|
||||||
session_delete: z.string().optional().default("ctrl+d").describe("Delete session"),
|
scrollbar_toggle: keybind("none", "Toggle session scrollbar"),
|
||||||
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
|
username_toggle: keybind("none", "Toggle username visibility"),
|
||||||
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
|
status_view: keybind("<leader>s", "View status"),
|
||||||
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
|
session_export: keybind("<leader>x", "Export session to editor"),
|
||||||
session_share: z.string().optional().default("none").describe("Share current session"),
|
session_new: keybind("<leader>n", "Create a new session"),
|
||||||
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
|
session_list: keybind("<leader>l", "List all sessions"),
|
||||||
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
|
session_timeline: keybind("<leader>g", "Show session timeline"),
|
||||||
session_compact: z.string().optional().default("<leader>c").describe("Compact the session"),
|
session_fork: keybind("none", "Fork session from message"),
|
||||||
messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"),
|
session_rename: keybind("ctrl+r", "Rename session"),
|
||||||
messages_page_down: z
|
session_delete: keybind("ctrl+d", "Delete session"),
|
||||||
.string()
|
stash_delete: keybind("ctrl+d", "Delete stash entry"),
|
||||||
.optional()
|
model_provider_list: keybind("ctrl+a", "Open provider list from model dialog"),
|
||||||
.default("pagedown,ctrl+alt+f")
|
model_favorite_toggle: keybind("ctrl+f", "Toggle model favorite status"),
|
||||||
.describe("Scroll messages down by one page"),
|
session_share: keybind("none", "Share current session"),
|
||||||
messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"),
|
session_unshare: keybind("none", "Unshare current session"),
|
||||||
messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"),
|
session_interrupt: keybind("escape", "Interrupt current session"),
|
||||||
messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"),
|
session_compact: keybind("<leader>c", "Compact the session"),
|
||||||
messages_half_page_down: z.string().optional().default("ctrl+alt+d").describe("Scroll messages down by half page"),
|
messages_page_up: keybind("pageup,ctrl+alt+b", "Scroll messages up by one page"),
|
||||||
messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"),
|
messages_page_down: keybind("pagedown,ctrl+alt+f", "Scroll messages down by one page"),
|
||||||
messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"),
|
messages_line_up: keybind("ctrl+alt+y", "Scroll messages up by one line"),
|
||||||
messages_next: z.string().optional().default("none").describe("Navigate to next message"),
|
messages_line_down: keybind("ctrl+alt+e", "Scroll messages down by one line"),
|
||||||
messages_previous: z.string().optional().default("none").describe("Navigate to previous message"),
|
messages_half_page_up: keybind("ctrl+alt+u", "Scroll messages up by half page"),
|
||||||
messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"),
|
messages_half_page_down: keybind("ctrl+alt+d", "Scroll messages down by half page"),
|
||||||
messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
|
messages_first: keybind("ctrl+g,home", "Navigate to first message"),
|
||||||
messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
|
messages_last: keybind("ctrl+alt+g,end", "Navigate to last message"),
|
||||||
messages_redo: z.string().optional().default("<leader>r").describe("Redo message"),
|
messages_next: keybind("none", "Navigate to next message"),
|
||||||
messages_toggle_conceal: z
|
messages_previous: keybind("none", "Navigate to previous message"),
|
||||||
.string()
|
messages_last_user: keybind("none", "Navigate to last user message"),
|
||||||
.optional()
|
messages_copy: keybind("<leader>y", "Copy message"),
|
||||||
.default("<leader>h")
|
messages_undo: keybind("<leader>u", "Undo message"),
|
||||||
.describe("Toggle code block concealment in messages"),
|
messages_redo: keybind("<leader>r", "Redo message"),
|
||||||
tool_details: z.string().optional().default("none").describe("Toggle tool details visibility"),
|
messages_toggle_conceal: keybind("<leader>h", "Toggle code block concealment in messages"),
|
||||||
model_list: z.string().optional().default("<leader>m").describe("List available models"),
|
tool_details: keybind("none", "Toggle tool details visibility"),
|
||||||
model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"),
|
model_list: keybind("<leader>m", "List available models"),
|
||||||
model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"),
|
model_cycle_recent: keybind("f2", "Next recently used model"),
|
||||||
model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"),
|
model_cycle_recent_reverse: keybind("shift+f2", "Previous recently used model"),
|
||||||
model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"),
|
model_cycle_favorite: keybind("none", "Next favorite model"),
|
||||||
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
|
model_cycle_favorite_reverse: keybind("none", "Previous favorite model"),
|
||||||
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
|
command_list: keybind("ctrl+p", "List available commands"),
|
||||||
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
|
agent_list: keybind("<leader>a", "List agents"),
|
||||||
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
|
agent_cycle: keybind("tab", "Next agent"),
|
||||||
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
|
agent_cycle_reverse: keybind("shift+tab", "Previous agent"),
|
||||||
variant_list: z.string().optional().default("none").describe("List model variants"),
|
variant_cycle: keybind("ctrl+t", "Cycle model variants"),
|
||||||
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
|
variant_list: keybind("none", "List model variants"),
|
||||||
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
|
input_clear: keybind("ctrl+c", "Clear input field"),
|
||||||
input_submit: z.string().optional().default("return").describe("Submit input"),
|
input_paste: keybind("ctrl+v", "Paste from clipboard"),
|
||||||
input_newline: z
|
input_submit: keybind("return", "Submit input"),
|
||||||
.string()
|
input_newline: keybind("shift+return,ctrl+return,alt+return,ctrl+j", "Insert newline in input"),
|
||||||
.optional()
|
input_move_left: keybind("left,ctrl+b", "Move cursor left in input"),
|
||||||
.default("shift+return,ctrl+return,alt+return,ctrl+j")
|
input_move_right: keybind("right,ctrl+f", "Move cursor right in input"),
|
||||||
.describe("Insert newline in input"),
|
input_move_up: keybind("up", "Move cursor up in input"),
|
||||||
input_move_left: z.string().optional().default("left,ctrl+b").describe("Move cursor left in input"),
|
input_move_down: keybind("down", "Move cursor down in input"),
|
||||||
input_move_right: z.string().optional().default("right,ctrl+f").describe("Move cursor right in input"),
|
input_select_left: keybind("shift+left", "Select left in input"),
|
||||||
input_move_up: z.string().optional().default("up").describe("Move cursor up in input"),
|
input_select_right: keybind("shift+right", "Select right in input"),
|
||||||
input_move_down: z.string().optional().default("down").describe("Move cursor down in input"),
|
input_select_up: keybind("shift+up", "Select up in input"),
|
||||||
input_select_left: z.string().optional().default("shift+left").describe("Select left in input"),
|
input_select_down: keybind("shift+down", "Select down in input"),
|
||||||
input_select_right: z.string().optional().default("shift+right").describe("Select right in input"),
|
input_line_home: keybind("ctrl+a", "Move to start of line in input"),
|
||||||
input_select_up: z.string().optional().default("shift+up").describe("Select up in input"),
|
input_line_end: keybind("ctrl+e", "Move to end of line in input"),
|
||||||
input_select_down: z.string().optional().default("shift+down").describe("Select down in input"),
|
input_select_line_home: keybind("ctrl+shift+a", "Select to start of line in input"),
|
||||||
input_line_home: z.string().optional().default("ctrl+a").describe("Move to start of line in input"),
|
input_select_line_end: keybind("ctrl+shift+e", "Select to end of line in input"),
|
||||||
input_line_end: z.string().optional().default("ctrl+e").describe("Move to end of line in input"),
|
input_visual_line_home: keybind("alt+a", "Move to start of visual line in input"),
|
||||||
input_select_line_home: z.string().optional().default("ctrl+shift+a").describe("Select to start of line in input"),
|
input_visual_line_end: keybind("alt+e", "Move to end of visual line in input"),
|
||||||
input_select_line_end: z.string().optional().default("ctrl+shift+e").describe("Select to end of line in input"),
|
input_select_visual_line_home: keybind("alt+shift+a", "Select to start of visual line in input"),
|
||||||
input_visual_line_home: z.string().optional().default("alt+a").describe("Move to start of visual line in input"),
|
input_select_visual_line_end: keybind("alt+shift+e", "Select to end of visual line in input"),
|
||||||
input_visual_line_end: z.string().optional().default("alt+e").describe("Move to end of visual line in input"),
|
input_buffer_home: keybind("home", "Move to start of buffer in input"),
|
||||||
input_select_visual_line_home: z
|
input_buffer_end: keybind("end", "Move to end of buffer in input"),
|
||||||
.string()
|
input_select_buffer_home: keybind("shift+home", "Select to start of buffer in input"),
|
||||||
.optional()
|
input_select_buffer_end: keybind("shift+end", "Select to end of buffer in input"),
|
||||||
.default("alt+shift+a")
|
input_delete_line: keybind("ctrl+shift+d", "Delete line in input"),
|
||||||
.describe("Select to start of visual line in input"),
|
input_delete_to_line_end: keybind("ctrl+k", "Delete to end of line in input"),
|
||||||
input_select_visual_line_end: z
|
input_delete_to_line_start: keybind("ctrl+u", "Delete to start of line in input"),
|
||||||
.string()
|
input_backspace: keybind("backspace,shift+backspace", "Backspace in input"),
|
||||||
.optional()
|
input_delete: keybind("ctrl+d,delete,shift+delete", "Delete character in input"),
|
||||||
.default("alt+shift+e")
|
input_undo: keybind(inputUndoDefault, "Undo in input"),
|
||||||
.describe("Select to end of visual line in input"),
|
input_redo: keybind("ctrl+.,super+shift+z", "Redo in input"),
|
||||||
input_buffer_home: z.string().optional().default("home").describe("Move to start of buffer in input"),
|
input_word_forward: keybind("alt+f,alt+right,ctrl+right", "Move word forward in input"),
|
||||||
input_buffer_end: z.string().optional().default("end").describe("Move to end of buffer in input"),
|
input_word_backward: keybind("alt+b,alt+left,ctrl+left", "Move word backward in input"),
|
||||||
input_select_buffer_home: z
|
input_select_word_forward: keybind("alt+shift+f,alt+shift+right", "Select word forward in input"),
|
||||||
.string()
|
input_select_word_backward: keybind("alt+shift+b,alt+shift+left", "Select word backward in input"),
|
||||||
.optional()
|
input_delete_word_forward: keybind("alt+d,alt+delete,ctrl+delete", "Delete word forward in input"),
|
||||||
.default("shift+home")
|
input_delete_word_backward: keybind("ctrl+w,ctrl+backspace,alt+backspace", "Delete word backward in input"),
|
||||||
.describe("Select to start of buffer in input"),
|
history_previous: keybind("up", "Previous history item"),
|
||||||
input_select_buffer_end: z.string().optional().default("shift+end").describe("Select to end of buffer in input"),
|
history_next: keybind("down", "Next history item"),
|
||||||
input_delete_line: z.string().optional().default("ctrl+shift+d").describe("Delete line in input"),
|
session_child_first: keybind("<leader>down", "Go to first child session"),
|
||||||
input_delete_to_line_end: z.string().optional().default("ctrl+k").describe("Delete to end of line in input"),
|
session_child_cycle: keybind("right", "Go to next child session"),
|
||||||
input_delete_to_line_start: z.string().optional().default("ctrl+u").describe("Delete to start of line in input"),
|
session_child_cycle_reverse: keybind("left", "Go to previous child session"),
|
||||||
input_backspace: z.string().optional().default("backspace,shift+backspace").describe("Backspace in input"),
|
session_parent: keybind("up", "Go to parent session"),
|
||||||
input_delete: z.string().optional().default("ctrl+d,delete,shift+delete").describe("Delete character in input"),
|
// `terminal_suspend` was formerly `.default("ctrl+z").transform((v) => win32 ? "none" : v)`,
|
||||||
input_undo: z
|
// but `tui.ts` already forces the binding to "none" on win32 before calling
|
||||||
.string()
|
// `Keybinds.parse(...)`, so the schema-level transform was redundant.
|
||||||
.optional()
|
terminal_suspend: keybind("ctrl+z", "Suspend terminal"),
|
||||||
// On Windows prepend ctrl+z since terminal_suspend releases the binding.
|
terminal_title_toggle: keybind("none", "Toggle terminal title"),
|
||||||
.default(process.platform === "win32" ? "ctrl+z,ctrl+-,super+z" : "ctrl+-,super+z")
|
tips_toggle: keybind("<leader>h", "Toggle tips on home screen"),
|
||||||
.describe("Undo in input"),
|
plugin_manager: keybind("none", "Open plugin manager dialog"),
|
||||||
input_redo: z.string().optional().default("ctrl+.,super+shift+z").describe("Redo in input"),
|
display_thinking: keybind("none", "Toggle thinking blocks visibility"),
|
||||||
input_word_forward: z
|
}).annotate({ identifier: "KeybindsConfig" })
|
||||||
.string()
|
|
||||||
.optional()
|
export type Keybinds = Schema.Schema.Type<typeof KeybindsSchema>
|
||||||
.default("alt+f,alt+right,ctrl+right")
|
|
||||||
.describe("Move word forward in input"),
|
// Consumers access `Keybinds.shape` and `Keybinds.shape.X.parse(undefined)`,
|
||||||
input_word_backward: z
|
// which requires the runtime type to be a ZodObject, not just ZodType. Every
|
||||||
.string()
|
// field is `string().optional().default(...)` at runtime, so widen to that.
|
||||||
.optional()
|
export const Keybinds = zod(KeybindsSchema) as unknown as z.ZodObject<
|
||||||
.default("alt+b,alt+left,ctrl+left")
|
Record<keyof Keybinds, z.ZodDefault<z.ZodOptional<z.ZodString>>>
|
||||||
.describe("Move word backward in input"),
|
>
|
||||||
input_select_word_forward: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.default("alt+shift+f,alt+shift+right")
|
|
||||||
.describe("Select word forward in input"),
|
|
||||||
input_select_word_backward: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.default("alt+shift+b,alt+shift+left")
|
|
||||||
.describe("Select word backward in input"),
|
|
||||||
input_delete_word_forward: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.default("alt+d,alt+delete,ctrl+delete")
|
|
||||||
.describe("Delete word forward in input"),
|
|
||||||
input_delete_word_backward: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.default("ctrl+w,ctrl+backspace,alt+backspace")
|
|
||||||
.describe("Delete word backward in input"),
|
|
||||||
history_previous: z.string().optional().default("up").describe("Previous history item"),
|
|
||||||
history_next: z.string().optional().default("down").describe("Next history item"),
|
|
||||||
session_child_first: z.string().optional().default("<leader>down").describe("Go to first child session"),
|
|
||||||
session_child_cycle: z.string().optional().default("right").describe("Go to next child session"),
|
|
||||||
session_child_cycle_reverse: z.string().optional().default("left").describe("Go to previous child session"),
|
|
||||||
session_parent: z.string().optional().default("up").describe("Go to parent session"),
|
|
||||||
terminal_suspend: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.default("ctrl+z")
|
|
||||||
.transform((v) => (process.platform === "win32" ? "none" : v))
|
|
||||||
.describe("Suspend terminal"),
|
|
||||||
terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
|
|
||||||
tips_toggle: z.string().optional().default("<leader>h").describe("Toggle tips on home screen"),
|
|
||||||
plugin_manager: z.string().optional().default("none").describe("Open plugin manager dialog"),
|
|
||||||
display_thinking: z.string().optional().default("none").describe("Toggle thinking blocks visibility"),
|
|
||||||
})
|
|
||||||
.strict()
|
|
||||||
.meta({
|
|
||||||
ref: "KeybindsConfig",
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,16 +1,8 @@
|
|||||||
export * as ConfigPermission from "./permission"
|
export * as ConfigPermission from "./permission"
|
||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import z from "zod"
|
import { zod, ZodPreprocess } from "@/util/effect-zod"
|
||||||
import { zod } from "@/util/effect-zod"
|
|
||||||
import { withStatics } from "@/util/schema"
|
import { withStatics } from "@/util/schema"
|
||||||
|
|
||||||
const permissionPreprocess = (val: unknown) => {
|
|
||||||
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
|
|
||||||
return { __originalKeys: globalThis.Object.keys(val), ...val }
|
|
||||||
}
|
|
||||||
return val
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Action = Schema.Literals(["ask", "allow", "deny"])
|
export const Action = Schema.Literals(["ask", "allow", "deny"])
|
||||||
.annotate({ identifier: "PermissionActionConfig" })
|
.annotate({ identifier: "PermissionActionConfig" })
|
||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
@@ -26,6 +18,48 @@ export const Rule = Schema.Union([Action, Object])
|
|||||||
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
.pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||||
export type Rule = Schema.Schema.Type<typeof Rule>
|
export type Rule = Schema.Schema.Type<typeof Rule>
|
||||||
|
|
||||||
|
// Captures the user's original property insertion order before Schema.Struct
|
||||||
|
// canonicalises the object. See the `ZodPreprocess` comment in
|
||||||
|
// `util/effect-zod.ts` for the full rationale — in short: rule precedence is
|
||||||
|
// encoded in JSON key order (`evaluate.ts` uses `findLast`, so later keys win)
|
||||||
|
// and `Schema.StructWithRest` would otherwise drop that order.
|
||||||
|
const permissionPreprocess = (val: unknown) => {
|
||||||
|
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
|
||||||
|
return { __originalKeys: globalThis.Object.keys(val), ...val }
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
const ObjectShape = Schema.StructWithRest(
|
||||||
|
Schema.Struct({
|
||||||
|
__originalKeys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
||||||
|
read: Schema.optional(Rule),
|
||||||
|
edit: Schema.optional(Rule),
|
||||||
|
glob: Schema.optional(Rule),
|
||||||
|
grep: Schema.optional(Rule),
|
||||||
|
list: Schema.optional(Rule),
|
||||||
|
bash: Schema.optional(Rule),
|
||||||
|
task: Schema.optional(Rule),
|
||||||
|
external_directory: Schema.optional(Rule),
|
||||||
|
todowrite: Schema.optional(Action),
|
||||||
|
question: Schema.optional(Action),
|
||||||
|
webfetch: Schema.optional(Action),
|
||||||
|
websearch: Schema.optional(Action),
|
||||||
|
codesearch: Schema.optional(Action),
|
||||||
|
lsp: Schema.optional(Rule),
|
||||||
|
doom_loop: Schema.optional(Action),
|
||||||
|
skill: Schema.optional(Rule),
|
||||||
|
}),
|
||||||
|
[Schema.Record(Schema.String, Rule)],
|
||||||
|
)
|
||||||
|
|
||||||
|
const InnerSchema = Schema.Union([ObjectShape, Action]).annotate({
|
||||||
|
[ZodPreprocess]: permissionPreprocess,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Post-parse: drop the __originalKeys metadata and rebuild the rule map in the
|
||||||
|
// user's original insertion order. A plain string input (the Action branch of
|
||||||
|
// the union) becomes `{ "*": action }`.
|
||||||
const transform = (x: unknown): Record<string, Rule> => {
|
const transform = (x: unknown): Record<string, Rule> => {
|
||||||
if (typeof x === "string") return { "*": x as Action }
|
if (typeof x === "string") return { "*": x as Action }
|
||||||
const obj = x as { __originalKeys?: string[] } & Record<string, unknown>
|
const obj = x as { __originalKeys?: string[] } & Record<string, unknown>
|
||||||
@@ -38,34 +72,5 @@ const transform = (x: unknown): Record<string, Rule> => {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Info = z
|
export const Info = zod(InnerSchema).transform(transform).meta({ ref: "PermissionConfig" })
|
||||||
.preprocess(
|
export type Info = Record<string, Rule>
|
||||||
permissionPreprocess,
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
__originalKeys: z.string().array().optional(),
|
|
||||||
read: Rule.zod.optional(),
|
|
||||||
edit: Rule.zod.optional(),
|
|
||||||
glob: Rule.zod.optional(),
|
|
||||||
grep: Rule.zod.optional(),
|
|
||||||
list: Rule.zod.optional(),
|
|
||||||
bash: Rule.zod.optional(),
|
|
||||||
task: Rule.zod.optional(),
|
|
||||||
external_directory: Rule.zod.optional(),
|
|
||||||
todowrite: Action.zod.optional(),
|
|
||||||
question: Action.zod.optional(),
|
|
||||||
webfetch: Action.zod.optional(),
|
|
||||||
websearch: Action.zod.optional(),
|
|
||||||
codesearch: Action.zod.optional(),
|
|
||||||
lsp: Rule.zod.optional(),
|
|
||||||
doom_loop: Action.zod.optional(),
|
|
||||||
skill: Rule.zod.optional(),
|
|
||||||
})
|
|
||||||
.catchall(Rule.zod)
|
|
||||||
.or(Action.zod),
|
|
||||||
)
|
|
||||||
.transform(transform)
|
|
||||||
.meta({
|
|
||||||
ref: "PermissionConfig",
|
|
||||||
})
|
|
||||||
export type Info = z.infer<typeof Info>
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export const create = fn(CreateInput, async (input) => {
|
|||||||
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
|
OPENCODE_EXPERIMENTAL_WORKSPACES: "true",
|
||||||
OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS,
|
OTEL_EXPORTER_OTLP_HEADERS: process.env.OTEL_EXPORTER_OTLP_HEADERS,
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
OTEL_EXPORTER_OTLP_ENDPOINT: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
||||||
|
OTEL_RESOURCE_ATTRIBUTES: process.env.OTEL_RESOURCE_ATTRIBUTES,
|
||||||
}
|
}
|
||||||
await adaptor.create(config, env)
|
await adaptor.create(config, env)
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,29 @@ const headers = Flag.OTEL_EXPORTER_OTLP_HEADERS
|
|||||||
)
|
)
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
function resource() {
|
export function resource(): { serviceName: string; serviceVersion: string; attributes: Record<string, string> } {
|
||||||
const processMetadata = ensureProcessMetadata("main")
|
const processMetadata = ensureProcessMetadata("main")
|
||||||
|
const attributes: Record<string, string> = (() => {
|
||||||
|
const value = process.env.OTEL_RESOURCE_ATTRIBUTES
|
||||||
|
if (!value) return {}
|
||||||
|
try {
|
||||||
|
return Object.fromEntries(
|
||||||
|
value.split(",").map((entry) => {
|
||||||
|
const index = entry.indexOf("=")
|
||||||
|
if (index < 1) throw new Error("Invalid OTEL_RESOURCE_ATTRIBUTES entry")
|
||||||
|
return [decodeURIComponent(entry.slice(0, index)), decodeURIComponent(entry.slice(index + 1))]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serviceName: "opencode",
|
serviceName: "opencode",
|
||||||
serviceVersion: InstallationVersion,
|
serviceVersion: InstallationVersion,
|
||||||
attributes: {
|
attributes: {
|
||||||
|
...attributes,
|
||||||
"deployment.environment.name": InstallationChannel,
|
"deployment.environment.name": InstallationChannel,
|
||||||
"opencode.client": Flag.OPENCODE_CLIENT,
|
"opencode.client": Flag.OPENCODE_CLIENT,
|
||||||
"opencode.process_role": processMetadata.processRole,
|
"opencode.process_role": processMetadata.processRole,
|
||||||
|
|||||||
@@ -150,13 +150,17 @@ export const layer = Layer.effect(
|
|||||||
if (!canWrite) return
|
if (!canWrite) return
|
||||||
|
|
||||||
const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
|
const add = input?.add.map((pkg) => [pkg.name, pkg.version].filter(Boolean).join("@")) ?? []
|
||||||
yield* Effect.gen(function* () {
|
if (
|
||||||
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
|
yield* Effect.gen(function* () {
|
||||||
if (!nodeModulesExists) {
|
const nodeModulesExists = yield* afs.existsSafe(path.join(dir, "node_modules"))
|
||||||
yield* reify({ add, dir })
|
if (!nodeModulesExists) {
|
||||||
return
|
yield* reify({ add, dir })
|
||||||
}
|
return true
|
||||||
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
|
}
|
||||||
|
return false
|
||||||
|
}).pipe(Effect.withSpan("Npm.checkNodeModules"))
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
yield* Effect.gen(function* () {
|
yield* Effect.gen(function* () {
|
||||||
const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
|
const pkg = yield* afs.readJson(path.join(dir, "package.json")).pipe(Effect.orElseSucceed(() => ({})))
|
||||||
|
|||||||
@@ -635,6 +635,9 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
|||||||
reasoningConfig: {
|
reasoningConfig: {
|
||||||
type: "adaptive",
|
type: "adaptive",
|
||||||
maxReasoningEffort: effort,
|
maxReasoningEffort: effort,
|
||||||
|
...(model.api.id.includes("opus-4-7") || model.api.id.includes("opus-4.7")
|
||||||
|
? { display: "summarized" }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { MessageTable, PartTable, SessionTable } from "./session.sql"
|
|||||||
import { ProviderError } from "@/provider"
|
import { ProviderError } from "@/provider"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { errorMessage } from "@/util/error"
|
import { errorMessage } from "@/util/error"
|
||||||
|
import { isMedia } from "@/util/media"
|
||||||
import type { SystemError } from "bun"
|
import type { SystemError } from "bun"
|
||||||
import type { Provider } from "@/provider"
|
import type { Provider } from "@/provider"
|
||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
@@ -25,10 +26,7 @@ interface FetchDecompressionError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
|
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
|
||||||
|
export { isMedia }
|
||||||
export function isMedia(mime: string) {
|
|
||||||
return mime.startsWith("image/") || mime === "application/pdf"
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
|
export const OutputLengthError = NamedError.create("MessageOutputLengthError", z.object({}))
|
||||||
export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() }))
|
export const AbortedError = NamedError.create("MessageAbortedError", z.object({ message: z.string() }))
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Effect, Scope } from "effect"
|
import { Effect, Option, Scope } from "effect"
|
||||||
import { createReadStream } from "fs"
|
import { createReadStream } from "fs"
|
||||||
import { open } from "fs/promises"
|
|
||||||
import * as path from "path"
|
import * as path from "path"
|
||||||
import { createInterface } from "readline"
|
import { createInterface } from "readline"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
@@ -11,12 +10,14 @@ import DESCRIPTION from "./read.txt"
|
|||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { assertExternalDirectoryEffect } from "./external-directory"
|
import { assertExternalDirectoryEffect } from "./external-directory"
|
||||||
import { Instruction } from "../session/instruction"
|
import { Instruction } from "../session/instruction"
|
||||||
|
import { isImageAttachment, isPdfAttachment, sniffAttachmentMime } from "@/util/media"
|
||||||
|
|
||||||
const DEFAULT_READ_LIMIT = 2000
|
const DEFAULT_READ_LIMIT = 2000
|
||||||
const MAX_LINE_LENGTH = 2000
|
const MAX_LINE_LENGTH = 2000
|
||||||
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`
|
||||||
const MAX_BYTES = 50 * 1024
|
const MAX_BYTES = 50 * 1024
|
||||||
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
|
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`
|
||||||
|
const SAMPLE_BYTES = 4096
|
||||||
|
|
||||||
const parameters = z.object({
|
const parameters = z.object({
|
||||||
filePath: z.string().describe("The absolute path to the file or directory to read"),
|
filePath: z.string().describe("The absolute path to the file or directory to read"),
|
||||||
@@ -77,6 +78,68 @@ export const ReadTool = Tool.define(
|
|||||||
yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
|
yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const readSample = Effect.fn("ReadTool.readSample")(function* (
|
||||||
|
filepath: string,
|
||||||
|
fileSize: number,
|
||||||
|
sampleSize: number,
|
||||||
|
) {
|
||||||
|
if (fileSize === 0) return new Uint8Array()
|
||||||
|
|
||||||
|
return yield* Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = yield* fs.open(filepath, { flag: "r" })
|
||||||
|
return Option.getOrElse(yield* file.readAlloc(Math.min(sampleSize, fileSize)), () => new Uint8Array())
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isBinaryFile = (filepath: string, bytes: Uint8Array) => {
|
||||||
|
const ext = path.extname(filepath).toLowerCase()
|
||||||
|
switch (ext) {
|
||||||
|
case ".zip":
|
||||||
|
case ".tar":
|
||||||
|
case ".gz":
|
||||||
|
case ".exe":
|
||||||
|
case ".dll":
|
||||||
|
case ".so":
|
||||||
|
case ".class":
|
||||||
|
case ".jar":
|
||||||
|
case ".war":
|
||||||
|
case ".7z":
|
||||||
|
case ".doc":
|
||||||
|
case ".docx":
|
||||||
|
case ".xls":
|
||||||
|
case ".xlsx":
|
||||||
|
case ".ppt":
|
||||||
|
case ".pptx":
|
||||||
|
case ".odt":
|
||||||
|
case ".ods":
|
||||||
|
case ".odp":
|
||||||
|
case ".bin":
|
||||||
|
case ".dat":
|
||||||
|
case ".obj":
|
||||||
|
case ".o":
|
||||||
|
case ".a":
|
||||||
|
case ".lib":
|
||||||
|
case ".wasm":
|
||||||
|
case ".pyc":
|
||||||
|
case ".pyo":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytes.length === 0) return false
|
||||||
|
|
||||||
|
let nonPrintableCount = 0
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
if (bytes[i] === 0) return true
|
||||||
|
if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
|
||||||
|
nonPrintableCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nonPrintableCount / bytes.length > 0.3
|
||||||
|
}
|
||||||
|
|
||||||
const run = Effect.fn("ReadTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
|
const run = Effect.fn("ReadTool.execute")(function* (params: z.infer<typeof parameters>, ctx: Tool.Context) {
|
||||||
if (params.offset !== undefined && params.offset < 1) {
|
if (params.offset !== undefined && params.offset < 1) {
|
||||||
return yield* Effect.fail(new Error("offset must be greater than or equal to 1"))
|
return yield* Effect.fail(new Error("offset must be greater than or equal to 1"))
|
||||||
@@ -141,12 +204,12 @@ export const ReadTool = Tool.define(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID)
|
const loaded = yield* instruction.resolve(ctx.messages, filepath, ctx.messageID)
|
||||||
|
const sample = yield* readSample(filepath, Number(stat.size), SAMPLE_BYTES)
|
||||||
|
|
||||||
const mime = AppFileSystem.mimeType(filepath)
|
const mime = sniffAttachmentMime(sample, AppFileSystem.mimeType(filepath))
|
||||||
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
|
if (isImageAttachment(mime) || isPdfAttachment(mime)) {
|
||||||
const isPdf = mime === "application/pdf"
|
const bytes = yield* fs.readFile(filepath)
|
||||||
if (isImage || isPdf) {
|
const msg = isPdfAttachment(mime) ? "PDF read successfully" : "Image read successfully"
|
||||||
const msg = `${isImage ? "Image" : "PDF"} read successfully`
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
output: msg,
|
output: msg,
|
||||||
@@ -159,13 +222,13 @@ export const ReadTool = Tool.define(
|
|||||||
{
|
{
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
mime,
|
mime,
|
||||||
url: `data:${mime};base64,${Buffer.from(yield* fs.readFile(filepath)).toString("base64")}`,
|
url: `data:${mime};base64,${Buffer.from(bytes).toString("base64")}`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (yield* Effect.promise(() => isBinaryFile(filepath, Number(stat.size)))) {
|
if (isBinaryFile(filepath, sample)) {
|
||||||
return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`))
|
return yield* Effect.fail(new Error(`Cannot read binary file: ${filepath}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,63 +324,3 @@ async function lines(filepath: string, opts: { limit: number; offset: number })
|
|||||||
|
|
||||||
return { raw, count, cut, more, offset: opts.offset }
|
return { raw, count, cut, more, offset: opts.offset }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isBinaryFile(filepath: string, fileSize: number): Promise<boolean> {
|
|
||||||
const ext = path.extname(filepath).toLowerCase()
|
|
||||||
// binary check for common non-text extensions
|
|
||||||
switch (ext) {
|
|
||||||
case ".zip":
|
|
||||||
case ".tar":
|
|
||||||
case ".gz":
|
|
||||||
case ".exe":
|
|
||||||
case ".dll":
|
|
||||||
case ".so":
|
|
||||||
case ".class":
|
|
||||||
case ".jar":
|
|
||||||
case ".war":
|
|
||||||
case ".7z":
|
|
||||||
case ".doc":
|
|
||||||
case ".docx":
|
|
||||||
case ".xls":
|
|
||||||
case ".xlsx":
|
|
||||||
case ".ppt":
|
|
||||||
case ".pptx":
|
|
||||||
case ".odt":
|
|
||||||
case ".ods":
|
|
||||||
case ".odp":
|
|
||||||
case ".bin":
|
|
||||||
case ".dat":
|
|
||||||
case ".obj":
|
|
||||||
case ".o":
|
|
||||||
case ".a":
|
|
||||||
case ".lib":
|
|
||||||
case ".wasm":
|
|
||||||
case ".pyc":
|
|
||||||
case ".pyo":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fileSize === 0) return false
|
|
||||||
|
|
||||||
const fh = await open(filepath, "r")
|
|
||||||
try {
|
|
||||||
const sampleSize = Math.min(4096, fileSize)
|
|
||||||
const bytes = Buffer.alloc(sampleSize)
|
|
||||||
const result = await fh.read(bytes, 0, sampleSize, 0)
|
|
||||||
if (result.bytesRead === 0) return false
|
|
||||||
|
|
||||||
let nonPrintableCount = 0
|
|
||||||
for (let i = 0; i < result.bytesRead; i++) {
|
|
||||||
if (bytes[i] === 0) return true
|
|
||||||
if (bytes[i] < 9 || (bytes[i] > 13 && bytes[i] < 32)) {
|
|
||||||
nonPrintableCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If >30% non-printable characters, consider it binary
|
|
||||||
return nonPrintableCount / result.bytesRead > 0.3
|
|
||||||
} finally {
|
|
||||||
await fh.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { pathToFileURL } from "url"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { EffectLogger } from "@/effect"
|
|
||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
import { Skill } from "../skill"
|
import { Skill } from "../skill"
|
||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
|
import DESCRIPTION from "./skill.txt"
|
||||||
|
|
||||||
const Parameters = z.object({
|
const Parameters = z.object({
|
||||||
name: z.string().describe("The name of the skill from available_skills"),
|
name: z.string().describe("The name of the skill from available_skills"),
|
||||||
@@ -18,82 +18,59 @@ export const SkillTool = Tool.define(
|
|||||||
const skill = yield* Skill.Service
|
const skill = yield* Skill.Service
|
||||||
const rg = yield* Ripgrep.Service
|
const rg = yield* Ripgrep.Service
|
||||||
|
|
||||||
return () =>
|
return {
|
||||||
Effect.gen(function* () {
|
description: DESCRIPTION,
|
||||||
const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer))
|
parameters: Parameters,
|
||||||
|
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const info = yield* skill.get(params.name)
|
||||||
|
if (!info) {
|
||||||
|
const all = yield* skill.all()
|
||||||
|
const available = all.map((item) => item.name).join(", ")
|
||||||
|
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
||||||
|
}
|
||||||
|
|
||||||
const description =
|
yield* ctx.ask({
|
||||||
list.length === 0
|
permission: "skill",
|
||||||
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
|
patterns: [params.name],
|
||||||
: [
|
always: [params.name],
|
||||||
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
metadata: {},
|
||||||
"",
|
})
|
||||||
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
|
||||||
"",
|
|
||||||
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
|
|
||||||
"",
|
|
||||||
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
|
|
||||||
"",
|
|
||||||
"The following skills provide specialized sets of instructions for particular tasks",
|
|
||||||
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
|
|
||||||
"",
|
|
||||||
Skill.fmt(list, { verbose: false }),
|
|
||||||
].join("\n")
|
|
||||||
|
|
||||||
return {
|
const dir = path.dirname(info.location)
|
||||||
description,
|
const base = pathToFileURL(dir).href
|
||||||
parameters: Parameters,
|
const limit = 10
|
||||||
execute: (params: z.infer<typeof Parameters>, ctx: Tool.Context) =>
|
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
|
||||||
Effect.gen(function* () {
|
Stream.filter((file) => !file.includes("SKILL.md")),
|
||||||
const info = yield* skill.get(params.name)
|
Stream.map((file) => path.resolve(dir, file)),
|
||||||
if (!info) {
|
Stream.take(limit),
|
||||||
const all = yield* skill.all()
|
Stream.runCollect,
|
||||||
const available = all.map((item) => item.name).join(", ")
|
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
|
||||||
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
yield* ctx.ask({
|
return {
|
||||||
permission: "skill",
|
title: `Loaded skill: ${info.name}`,
|
||||||
patterns: [params.name],
|
output: [
|
||||||
always: [params.name],
|
`<skill_content name="${info.name}">`,
|
||||||
metadata: {},
|
`# Skill: ${info.name}`,
|
||||||
})
|
"",
|
||||||
|
info.content.trim(),
|
||||||
const dir = path.dirname(info.location)
|
"",
|
||||||
const base = pathToFileURL(dir).href
|
`Base directory for this skill: ${base}`,
|
||||||
const limit = 10
|
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
||||||
const files = yield* rg.files({ cwd: dir, follow: false, hidden: true, signal: ctx.abort }).pipe(
|
"Note: file list is sampled.",
|
||||||
Stream.filter((file) => !file.includes("SKILL.md")),
|
"",
|
||||||
Stream.map((file) => path.resolve(dir, file)),
|
"<skill_files>",
|
||||||
Stream.take(limit),
|
files,
|
||||||
Stream.runCollect,
|
"</skill_files>",
|
||||||
Effect.map((chunk) => [...chunk].map((file) => `<file>${file}</file>`).join("\n")),
|
"</skill_content>",
|
||||||
)
|
].join("\n"),
|
||||||
|
metadata: {
|
||||||
return {
|
name: info.name,
|
||||||
title: `Loaded skill: ${info.name}`,
|
dir,
|
||||||
output: [
|
},
|
||||||
`<skill_content name="${info.name}">`,
|
}
|
||||||
`# Skill: ${info.name}`,
|
}).pipe(Effect.orDie),
|
||||||
"",
|
}
|
||||||
info.content.trim(),
|
|
||||||
"",
|
|
||||||
`Base directory for this skill: ${base}`,
|
|
||||||
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
|
|
||||||
"Note: file list is sampled.",
|
|
||||||
"",
|
|
||||||
"<skill_files>",
|
|
||||||
files,
|
|
||||||
"</skill_files>",
|
|
||||||
"</skill_content>",
|
|
||||||
].join("\n"),
|
|
||||||
metadata: {
|
|
||||||
name: info.name,
|
|
||||||
dir,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}).pipe(Effect.orDie),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
Load a specialized skill when the task at hand matches one of the skills listed in the system prompt.
|
||||||
|
|
||||||
|
Use this tool to inject the skill's instructions and resources into current conversation. The output may contain detailed workflow guidance as well as references to scripts, files, etc in the same directory as the skill.
|
||||||
|
|
||||||
|
The skill name must match one of the skills listed in your system prompt.
|
||||||
@@ -4,6 +4,7 @@ import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|||||||
import * as Tool from "./tool"
|
import * as Tool from "./tool"
|
||||||
import TurndownService from "turndown"
|
import TurndownService from "turndown"
|
||||||
import DESCRIPTION from "./webfetch.txt"
|
import DESCRIPTION from "./webfetch.txt"
|
||||||
|
import { isImageAttachment } from "@/util/media"
|
||||||
|
|
||||||
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
|
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||||
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
|
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
|
||||||
@@ -104,10 +105,7 @@ export const WebFetchTool = Tool.define(
|
|||||||
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
|
const mime = contentType.split(";")[0]?.trim().toLowerCase() || ""
|
||||||
const title = `${params.url} (${contentType})`
|
const title = `${params.url} (${contentType})`
|
||||||
|
|
||||||
// Check if response is an image
|
if (isImageAttachment(mime)) {
|
||||||
const isImage = mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
|
|
||||||
|
|
||||||
if (isImage) {
|
|
||||||
const base64Content = Buffer.from(arrayBuffer).toString("base64")
|
const base64Content = Buffer.from(arrayBuffer).toString("base64")
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
const startsWith = (bytes: Uint8Array, prefix: number[]) => prefix.every((value, index) => bytes[index] === value)
|
||||||
|
|
||||||
|
export function isPdfAttachment(mime: string) {
|
||||||
|
return mime === "application/pdf"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMedia(mime: string) {
|
||||||
|
return mime.startsWith("image/") || isPdfAttachment(mime)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isImageAttachment(mime: string) {
|
||||||
|
return mime.startsWith("image/") && mime !== "image/svg+xml" && mime !== "image/vnd.fastbidsheet"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sniffAttachmentMime(bytes: Uint8Array, fallback: string) {
|
||||||
|
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"
|
||||||
|
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"
|
||||||
|
if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) return "image/gif"
|
||||||
|
if (startsWith(bytes, [0x42, 0x4d])) return "image/bmp"
|
||||||
|
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"
|
||||||
|
if (startsWith(bytes, [0x52, 0x49, 0x46, 0x46]) && startsWith(bytes.subarray(8), [0x57, 0x45, 0x42, 0x50])) {
|
||||||
|
return "image/webp"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
import { produce, type WritableDraft } from "immer"
|
||||||
|
import { SessionEvent } from "./session-event"
|
||||||
|
import { SessionEntry } from "./session-entry"
|
||||||
|
|
||||||
|
export type MemoryState = {
|
||||||
|
entries: SessionEntry.Entry[]
|
||||||
|
pending: SessionEntry.Entry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Adapter<Result> {
|
||||||
|
readonly getCurrentAssistant: () => SessionEntry.Assistant | undefined
|
||||||
|
readonly updateAssistant: (assistant: SessionEntry.Assistant) => void
|
||||||
|
readonly appendEntry: (entry: SessionEntry.Entry) => void
|
||||||
|
readonly appendPending: (entry: SessionEntry.Entry) => void
|
||||||
|
readonly finish: () => Result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function memory(state: MemoryState): Adapter<MemoryState> {
|
||||||
|
const activeAssistantIndex = () =>
|
||||||
|
state.entries.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed)
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCurrentAssistant() {
|
||||||
|
const index = activeAssistantIndex()
|
||||||
|
if (index < 0) return
|
||||||
|
const assistant = state.entries[index]
|
||||||
|
return assistant?.type === "assistant" ? assistant : undefined
|
||||||
|
},
|
||||||
|
updateAssistant(assistant) {
|
||||||
|
const index = activeAssistantIndex()
|
||||||
|
if (index < 0) return
|
||||||
|
const current = state.entries[index]
|
||||||
|
if (current?.type !== "assistant") return
|
||||||
|
state.entries[index] = assistant
|
||||||
|
},
|
||||||
|
appendEntry(entry) {
|
||||||
|
state.entries.push(entry)
|
||||||
|
},
|
||||||
|
appendPending(entry) {
|
||||||
|
state.pending.push(entry)
|
||||||
|
},
|
||||||
|
finish() {
|
||||||
|
return state
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stepWith<Result>(adapter: Adapter<Result>, event: SessionEvent.Event): Result {
|
||||||
|
const currentAssistant = adapter.getCurrentAssistant()
|
||||||
|
type DraftAssistant = WritableDraft<SessionEntry.Assistant>
|
||||||
|
type DraftTool = WritableDraft<SessionEntry.AssistantTool>
|
||||||
|
type DraftText = WritableDraft<SessionEntry.AssistantText>
|
||||||
|
type DraftReasoning = WritableDraft<SessionEntry.AssistantReasoning>
|
||||||
|
|
||||||
|
const latestTool = (assistant: DraftAssistant | undefined, callID?: string) =>
|
||||||
|
assistant?.content.findLast(
|
||||||
|
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID),
|
||||||
|
)
|
||||||
|
|
||||||
|
const latestText = (assistant: DraftAssistant | undefined) =>
|
||||||
|
assistant?.content.findLast((item): item is DraftText => item.type === "text")
|
||||||
|
|
||||||
|
const latestReasoning = (assistant: DraftAssistant | undefined) =>
|
||||||
|
assistant?.content.findLast((item): item is DraftReasoning => item.type === "reasoning")
|
||||||
|
|
||||||
|
SessionEvent.Event.match(event, {
|
||||||
|
prompt: (event) => {
|
||||||
|
const entry = SessionEntry.User.fromEvent(event)
|
||||||
|
if (currentAssistant) {
|
||||||
|
adapter.appendPending(entry)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adapter.appendEntry(entry)
|
||||||
|
},
|
||||||
|
synthetic: (event) => {
|
||||||
|
adapter.appendEntry(SessionEntry.Synthetic.fromEvent(event))
|
||||||
|
},
|
||||||
|
"step.started": (event) => {
|
||||||
|
if (currentAssistant) {
|
||||||
|
adapter.updateAssistant(
|
||||||
|
produce(currentAssistant, (draft) => {
|
||||||
|
draft.time.completed = event.timestamp
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
adapter.appendEntry(SessionEntry.Assistant.fromEvent(event))
|
||||||
|
},
|
||||||
|
"step.ended": (event) => {
|
||||||
|
if (currentAssistant) {
|
||||||
|
adapter.updateAssistant(
|
||||||
|
produce(currentAssistant, (draft) => {
|
||||||
|
draft.time.completed = event.timestamp
|
||||||
|
draft.cost = event.cost
|
||||||
|
draft.tokens = event.tokens
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text.started": () => {
|
||||||
|
if (currentAssistant) {
|
||||||
|
adapter.updateAssistant(
|
||||||
|
produce(currentAssistant, (draft) => {
|
||||||
|
draft.content.push({
|
||||||
|
type: "text",
|
||||||
|
text: "",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text.delta": (event) => {
|
||||||
|
if (currentAssistant) {
|
||||||
|
adapter.updateAssistant(
|
||||||
|
produce(currentAssistant, (draft) => {
|
||||||
|
const match = latestText(draft)
|
||||||
|
if (match) match.text += event.delta
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"text.ended": () => {},
|
||||||
|
"tool.input.started": (event) => {
|
||||||
|
if (currentAssistant) {
|
||||||
|
adapter.updateAssistant(
|
||||||
|
produce(currentAssistant, (draft) => {
|
||||||
|
draft.content.push({
|
||||||
|
type: "tool",
|
||||||
|
callID: event.callID,
|
||||||
|
name: event.name,
|
||||||
|
time: {
|
||||||
|
created: event.timestamp,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
status: "pending",
|
||||||
|
input: "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tool.input.delta": (event) => {
|
||||||
|
if (currentAssistant) {
|
||||||
|
adapter.updateAssistant(
|
||||||
|
produce(currentAssistant, (draft) => {
|
||||||
|
const match = latestTool(draft, event.callID)
|
||||||
|
// oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
|
||||||
|
if (match && match.state.status === "pending") match.state.input += event.delta
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tool.input.ended": () => {},
|
||||||
|
"tool.called": (event) => {
|
||||||
|
if (currentAssistant) {
|
||||||
|
adapter.updateAssistant(
|
||||||
|
produce(currentAssistant, (draft) => {
|
||||||
|
const match = latestTool(draft, event.callID)
|
||||||
|
if (match) {
|
||||||
|
match.time.ran = event.timestamp
|
||||||
|
match.state = {
|
||||||
|
status: "running",
|
||||||
|
input: event.input,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tool.success": (event) => {
|
||||||
|
if (currentAssistant) {
|
||||||
|
adapter.updateAssistant(
|
||||||
|
produce(currentAssistant, (draft) => {
|
||||||
|
const match = latestTool(draft, event.callID)
|
||||||
|
if (match && match.state.status === "running") {
|
||||||
|
match.state = {
|
||||||
|
status: "completed",
|
||||||
|
input: match.state.input,
|
||||||
|
output: event.output ?? "",
|
||||||
|
title: event.title,
|
||||||
|
metadata: event.metadata ?? {},
|
||||||
|
attachments: [...(event.attachments ?? [])],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tool.error": (event) => {
|
||||||
|
if (currentAssistant) {
|
||||||
|
adapter.updateAssistant(
|
||||||
|
produce(currentAssistant, (draft) => {
|
||||||
|
const match = latestTool(draft, event.callID)
|
||||||
|
if (match && match.state.status === "running") {
|
||||||
|
match.state = {
|
||||||
|
status: "error",
|
||||||
|
error: event.error,
|
||||||
|
input: match.state.input,
|
||||||
|
metadata: event.metadata ?? {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reasoning.started": () => {
|
||||||
|
if (currentAssistant) {
|
||||||
|
adapter.updateAssistant(
|
||||||
|
produce(currentAssistant, (draft) => {
|
||||||
|
draft.content.push({
|
||||||
|
type: "reasoning",
|
||||||
|
text: "",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reasoning.delta": (event) => {
|
||||||
|
if (currentAssistant) {
|
||||||
|
adapter.updateAssistant(
|
||||||
|
produce(currentAssistant, (draft) => {
|
||||||
|
const match = latestReasoning(draft)
|
||||||
|
if (match) match.text += event.delta
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reasoning.ended": (event) => {
|
||||||
|
if (currentAssistant) {
|
||||||
|
adapter.updateAssistant(
|
||||||
|
produce(currentAssistant, (draft) => {
|
||||||
|
const match = latestReasoning(draft)
|
||||||
|
if (match) match.text = event.text
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
retried: (event) => {
|
||||||
|
if (currentAssistant) {
|
||||||
|
adapter.updateAssistant(
|
||||||
|
produce(currentAssistant, (draft) => {
|
||||||
|
draft.retries = [...(draft.retries ?? []), SessionEntry.AssistantRetry.fromEvent(event)]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
compacted: (event) => {
|
||||||
|
adapter.appendEntry(SessionEntry.Compaction.fromEvent(event))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return adapter.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function step(old: MemoryState, event: SessionEvent.Event): MemoryState {
|
||||||
|
return produce(old, (draft) => {
|
||||||
|
stepWith(memory(draft as MemoryState), event)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as SessionEntryStepper from "./session-entry-stepper"
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Schema } from "effect"
|
import { Schema } from "effect"
|
||||||
import { SessionEvent } from "./session-event"
|
import { SessionEvent } from "./session-event"
|
||||||
import { castDraft, produce } from "immer"
|
|
||||||
|
|
||||||
export const ID = SessionEvent.ID
|
export const ID = SessionEvent.ID
|
||||||
export type ID = Schema.Schema.Type<typeof ID>
|
export type ID = Schema.Schema.Type<typeof ID>
|
||||||
@@ -40,7 +39,14 @@ export class Synthetic extends Schema.Class<Synthetic>("Session.Entry.Synthetic"
|
|||||||
...SessionEvent.Synthetic.fields,
|
...SessionEvent.Synthetic.fields,
|
||||||
...Base,
|
...Base,
|
||||||
type: Schema.Literal("synthetic"),
|
type: Schema.Literal("synthetic"),
|
||||||
}) {}
|
}) {
|
||||||
|
static fromEvent(event: SessionEvent.Synthetic) {
|
||||||
|
return new Synthetic({
|
||||||
|
...event,
|
||||||
|
time: { created: event.timestamp },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Entry.ToolState.Pending")({
|
export class ToolStatePending extends Schema.Class<ToolStatePending>("Session.Entry.ToolState.Pending")({
|
||||||
status: Schema.Literal("pending"),
|
status: Schema.Literal("pending"),
|
||||||
@@ -98,6 +104,24 @@ export class AssistantReasoning extends Schema.Class<AssistantReasoning>("Sessio
|
|||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
export class AssistantRetry extends Schema.Class<AssistantRetry>("Session.Entry.Assistant.Retry")({
|
||||||
|
attempt: Schema.Number,
|
||||||
|
error: SessionEvent.RetryError,
|
||||||
|
time: Schema.Struct({
|
||||||
|
created: Schema.DateTimeUtc,
|
||||||
|
}),
|
||||||
|
}) {
|
||||||
|
static fromEvent(event: SessionEvent.Retried) {
|
||||||
|
return new AssistantRetry({
|
||||||
|
attempt: event.attempt,
|
||||||
|
error: event.error,
|
||||||
|
time: {
|
||||||
|
created: event.timestamp,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
|
export const AssistantContent = Schema.Union([AssistantText, AssistantReasoning, AssistantTool]).pipe(
|
||||||
Schema.toTaggedUnion("type"),
|
Schema.toTaggedUnion("type"),
|
||||||
)
|
)
|
||||||
@@ -107,6 +131,7 @@ export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant"
|
|||||||
...Base,
|
...Base,
|
||||||
type: Schema.Literal("assistant"),
|
type: Schema.Literal("assistant"),
|
||||||
content: AssistantContent.pipe(Schema.Array),
|
content: AssistantContent.pipe(Schema.Array),
|
||||||
|
retries: AssistantRetry.pipe(Schema.Array, Schema.optional),
|
||||||
cost: Schema.Number.pipe(Schema.optional),
|
cost: Schema.Number.pipe(Schema.optional),
|
||||||
tokens: Schema.Struct({
|
tokens: Schema.Struct({
|
||||||
input: Schema.Number,
|
input: Schema.Number,
|
||||||
@@ -122,13 +147,33 @@ export class Assistant extends Schema.Class<Assistant>("Session.Entry.Assistant"
|
|||||||
created: Schema.DateTimeUtc,
|
created: Schema.DateTimeUtc,
|
||||||
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
completed: Schema.DateTimeUtc.pipe(Schema.optional),
|
||||||
}),
|
}),
|
||||||
}) {}
|
}) {
|
||||||
|
static fromEvent(event: SessionEvent.Step.Started) {
|
||||||
|
return new Assistant({
|
||||||
|
id: event.id,
|
||||||
|
type: "assistant",
|
||||||
|
time: {
|
||||||
|
created: event.timestamp,
|
||||||
|
},
|
||||||
|
content: [],
|
||||||
|
retries: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compaction")({
|
export class Compaction extends Schema.Class<Compaction>("Session.Entry.Compaction")({
|
||||||
...SessionEvent.Compacted.fields,
|
...SessionEvent.Compacted.fields,
|
||||||
type: Schema.Literal("compaction"),
|
type: Schema.Literal("compaction"),
|
||||||
...Base,
|
...Base,
|
||||||
}) {}
|
}) {
|
||||||
|
static fromEvent(event: SessionEvent.Compacted) {
|
||||||
|
return new Compaction({
|
||||||
|
...event,
|
||||||
|
type: "compaction",
|
||||||
|
time: { created: event.timestamp },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type"))
|
export const Entry = Schema.Union([User, Synthetic, Assistant, Compaction]).pipe(Schema.toTaggedUnion("type"))
|
||||||
|
|
||||||
@@ -136,151 +181,6 @@ export type Entry = Schema.Schema.Type<typeof Entry>
|
|||||||
|
|
||||||
export type Type = Entry["type"]
|
export type Type = Entry["type"]
|
||||||
|
|
||||||
export type History = {
|
|
||||||
entries: Entry[]
|
|
||||||
pending: Entry[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function step(old: History, event: SessionEvent.Event): History {
|
|
||||||
return produce(old, (draft) => {
|
|
||||||
const lastAssistant = draft.entries.findLast((x) => x.type === "assistant")
|
|
||||||
const pendingAssistant = lastAssistant && !lastAssistant.time.completed ? lastAssistant : undefined
|
|
||||||
type DraftContent = NonNullable<typeof pendingAssistant>["content"][number]
|
|
||||||
type DraftTool = Extract<DraftContent, { type: "tool" }>
|
|
||||||
|
|
||||||
const latestTool = (callID?: string) =>
|
|
||||||
pendingAssistant?.content.findLast(
|
|
||||||
(item): item is DraftTool => item.type === "tool" && (callID === undefined || item.callID === callID),
|
|
||||||
)
|
|
||||||
const latestText = () => pendingAssistant?.content.findLast((item) => item.type === "text")
|
|
||||||
const latestReasoning = () => pendingAssistant?.content.findLast((item) => item.type === "reasoning")
|
|
||||||
|
|
||||||
SessionEvent.Event.match(event, {
|
|
||||||
prompt: (event) => {
|
|
||||||
const entry = User.fromEvent(event)
|
|
||||||
if (pendingAssistant) {
|
|
||||||
draft.pending.push(castDraft(entry))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
draft.entries.push(castDraft(entry))
|
|
||||||
},
|
|
||||||
synthetic: (event) => {
|
|
||||||
draft.entries.push(new Synthetic({ ...event, time: { created: event.timestamp } }))
|
|
||||||
},
|
|
||||||
"step.started": (event) => {
|
|
||||||
if (pendingAssistant) pendingAssistant.time.completed = event.timestamp
|
|
||||||
draft.entries.push({
|
|
||||||
id: event.id,
|
|
||||||
type: "assistant",
|
|
||||||
time: {
|
|
||||||
created: event.timestamp,
|
|
||||||
},
|
|
||||||
content: [],
|
|
||||||
})
|
|
||||||
},
|
|
||||||
"step.ended": (event) => {
|
|
||||||
if (!pendingAssistant) return
|
|
||||||
pendingAssistant.time.completed = event.timestamp
|
|
||||||
pendingAssistant.cost = event.cost
|
|
||||||
pendingAssistant.tokens = event.tokens
|
|
||||||
},
|
|
||||||
"text.started": () => {
|
|
||||||
if (!pendingAssistant) return
|
|
||||||
pendingAssistant.content.push({
|
|
||||||
type: "text",
|
|
||||||
text: "",
|
|
||||||
})
|
|
||||||
},
|
|
||||||
"text.delta": (event) => {
|
|
||||||
if (!pendingAssistant) return
|
|
||||||
const match = latestText()
|
|
||||||
if (match) match.text += event.delta
|
|
||||||
},
|
|
||||||
"text.ended": () => {},
|
|
||||||
"tool.input.started": (event) => {
|
|
||||||
if (!pendingAssistant) return
|
|
||||||
pendingAssistant.content.push({
|
|
||||||
type: "tool",
|
|
||||||
callID: event.callID,
|
|
||||||
name: event.name,
|
|
||||||
time: {
|
|
||||||
created: event.timestamp,
|
|
||||||
},
|
|
||||||
state: {
|
|
||||||
status: "pending",
|
|
||||||
input: "",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
"tool.input.delta": (event) => {
|
|
||||||
if (!pendingAssistant) return
|
|
||||||
const match = latestTool(event.callID)
|
|
||||||
// oxlint-disable-next-line no-base-to-string -- event.delta is a Schema.String (runtime string)
|
|
||||||
if (match) match.state.input += event.delta
|
|
||||||
},
|
|
||||||
"tool.input.ended": () => {},
|
|
||||||
"tool.called": (event) => {
|
|
||||||
if (!pendingAssistant) return
|
|
||||||
const match = latestTool(event.callID)
|
|
||||||
if (match) {
|
|
||||||
match.time.ran = event.timestamp
|
|
||||||
match.state = {
|
|
||||||
status: "running",
|
|
||||||
input: event.input,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tool.success": (event) => {
|
|
||||||
if (!pendingAssistant) return
|
|
||||||
const match = latestTool(event.callID)
|
|
||||||
if (match && match.state.status === "running") {
|
|
||||||
match.state = {
|
|
||||||
status: "completed",
|
|
||||||
input: match.state.input,
|
|
||||||
output: event.output ?? "",
|
|
||||||
title: event.title,
|
|
||||||
metadata: event.metadata ?? {},
|
|
||||||
attachments: [...(event.attachments ?? [])],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tool.error": (event) => {
|
|
||||||
if (!pendingAssistant) return
|
|
||||||
const match = latestTool(event.callID)
|
|
||||||
if (match && match.state.status === "running") {
|
|
||||||
match.state = {
|
|
||||||
status: "error",
|
|
||||||
error: event.error,
|
|
||||||
input: match.state.input,
|
|
||||||
metadata: event.metadata ?? {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"reasoning.started": () => {
|
|
||||||
if (!pendingAssistant) return
|
|
||||||
pendingAssistant.content.push({
|
|
||||||
type: "reasoning",
|
|
||||||
text: "",
|
|
||||||
})
|
|
||||||
},
|
|
||||||
"reasoning.delta": (event) => {
|
|
||||||
if (!pendingAssistant) return
|
|
||||||
const match = latestReasoning()
|
|
||||||
if (match) match.text += event.delta
|
|
||||||
},
|
|
||||||
"reasoning.ended": (event) => {
|
|
||||||
if (!pendingAssistant) return
|
|
||||||
const match = latestReasoning()
|
|
||||||
if (match) match.text = event.text
|
|
||||||
},
|
|
||||||
retried: () => {},
|
|
||||||
compacted: (event) => {
|
|
||||||
draft.entries.push(new Compaction({ ...event, type: "compaction", time: { created: event.timestamp } }))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry
|
readonly decode: (row: typeof SessionEntryTable.$inferSelect) => Entry
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ export namespace SessionEvent {
|
|||||||
source: Source.pipe(Schema.optional),
|
source: Source.pipe(Schema.optional),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
export class RetryError extends Schema.Class<RetryError>("Session.Event.Retry.Error")({
|
||||||
|
message: Schema.String,
|
||||||
|
statusCode: Schema.Number.pipe(Schema.optional),
|
||||||
|
isRetryable: Schema.Boolean,
|
||||||
|
responseHeaders: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||||
|
responseBody: Schema.String.pipe(Schema.optional),
|
||||||
|
metadata: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
|
||||||
|
}) {}
|
||||||
|
|
||||||
export class Prompt extends Schema.Class<Prompt>("Session.Event.Prompt")({
|
export class Prompt extends Schema.Class<Prompt>("Session.Event.Prompt")({
|
||||||
...Base,
|
...Base,
|
||||||
type: Schema.Literal("prompt"),
|
type: Schema.Literal("prompt"),
|
||||||
@@ -386,14 +395,16 @@ export namespace SessionEvent {
|
|||||||
export class Retried extends Schema.Class<Retried>("Session.Event.Retried")({
|
export class Retried extends Schema.Class<Retried>("Session.Event.Retried")({
|
||||||
...Base,
|
...Base,
|
||||||
type: Schema.Literal("retried"),
|
type: Schema.Literal("retried"),
|
||||||
error: Schema.String,
|
attempt: Schema.Number,
|
||||||
|
error: RetryError,
|
||||||
}) {
|
}) {
|
||||||
static create(input: BaseInput & { error: string }) {
|
static create(input: BaseInput & { attempt: number; error: RetryError }) {
|
||||||
return new Retried({
|
return new Retried({
|
||||||
id: input.id ?? ID.create(),
|
id: input.id ?? ID.create(),
|
||||||
type: "retried",
|
type: "retried",
|
||||||
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
timestamp: input.timestamp ?? DateTime.makeUnsafe(Date.now()),
|
||||||
metadata: input.metadata,
|
metadata: input.metadata,
|
||||||
|
attempt: input.attempt,
|
||||||
error: input.error,
|
error: input.error,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -365,13 +365,14 @@ export const layer: Layer.Layer<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
|
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
|
||||||
if (Instance.project.vcs !== "git") {
|
const ctx = yield* InstanceState.context
|
||||||
|
if (ctx.project.vcs !== "git") {
|
||||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const directory = yield* canonical(input.directory)
|
const directory = yield* canonical(input.directory)
|
||||||
|
|
||||||
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
|
||||||
if (list.code !== 0) {
|
if (list.code !== 0) {
|
||||||
throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
|
throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
|
||||||
}
|
}
|
||||||
@@ -389,9 +390,9 @@ export const layer: Layer.Layer<
|
|||||||
}
|
}
|
||||||
|
|
||||||
yield* stopFsmonitor(entry.path)
|
yield* stopFsmonitor(entry.path)
|
||||||
const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree })
|
const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: ctx.worktree })
|
||||||
if (removed.code !== 0) {
|
if (removed.code !== 0) {
|
||||||
const next = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
const next = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
|
||||||
if (next.code !== 0) {
|
if (next.code !== 0) {
|
||||||
throw new RemoveFailedError({
|
throw new RemoveFailedError({
|
||||||
message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree",
|
message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree",
|
||||||
@@ -408,7 +409,7 @@ export const layer: Layer.Layer<
|
|||||||
|
|
||||||
const branch = entry.branch?.replace(/^refs\/heads\//, "")
|
const branch = entry.branch?.replace(/^refs\/heads\//, "")
|
||||||
if (branch) {
|
if (branch) {
|
||||||
const deleted = yield* git(["branch", "-D", branch], { cwd: Instance.worktree })
|
const deleted = yield* git(["branch", "-D", branch], { cwd: ctx.worktree })
|
||||||
if (deleted.code !== 0) {
|
if (deleted.code !== 0) {
|
||||||
throw new RemoveFailedError({
|
throw new RemoveFailedError({
|
||||||
message: deleted.stderr || deleted.text || "Failed to delete worktree branch",
|
message: deleted.stderr || deleted.text || "Failed to delete worktree branch",
|
||||||
@@ -498,17 +499,18 @@ export const layer: Layer.Layer<
|
|||||||
})
|
})
|
||||||
|
|
||||||
const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
|
const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
|
||||||
if (Instance.project.vcs !== "git") {
|
const ctx = yield* InstanceState.context
|
||||||
|
if (ctx.project.vcs !== "git") {
|
||||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const directory = yield* canonical(input.directory)
|
const directory = yield* canonical(input.directory)
|
||||||
const primary = yield* canonical(Instance.worktree)
|
const primary = yield* canonical(ctx.worktree)
|
||||||
if (directory === primary) {
|
if (directory === primary) {
|
||||||
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
|
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
|
const list = yield* git(["worktree", "list", "--porcelain"], { cwd: ctx.worktree })
|
||||||
if (list.code !== 0) {
|
if (list.code !== 0) {
|
||||||
throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
|
throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
|
||||||
}
|
}
|
||||||
@@ -520,7 +522,7 @@ export const layer: Layer.Layer<
|
|||||||
|
|
||||||
const worktreePath = entry.path
|
const worktreePath = entry.path
|
||||||
|
|
||||||
const base = yield* gitSvc.defaultBranch(Instance.worktree)
|
const base = yield* gitSvc.defaultBranch(ctx.worktree)
|
||||||
if (!base) {
|
if (!base) {
|
||||||
throw new ResetFailedError({ message: "Default branch not found" })
|
throw new ResetFailedError({ message: "Default branch not found" })
|
||||||
}
|
}
|
||||||
@@ -531,7 +533,7 @@ export const layer: Layer.Layer<
|
|||||||
const branch = base.ref.slice(sep + 1)
|
const branch = base.ref.slice(sep + 1)
|
||||||
yield* gitExpect(
|
yield* gitExpect(
|
||||||
["fetch", remote, branch],
|
["fetch", remote, branch],
|
||||||
{ cwd: Instance.worktree },
|
{ cwd: ctx.worktree },
|
||||||
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }),
|
(r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${base.ref}` }),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -574,7 +576,7 @@ export const layer: Layer.Layer<
|
|||||||
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
|
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* runStartScripts(worktreePath, { projectID: Instance.project.id }).pipe(
|
yield* runStartScripts(worktreePath, { projectID: ctx.project.id }).pipe(
|
||||||
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))),
|
Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))),
|
||||||
Effect.forkIn(scope),
|
Effect.forkIn(scope),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { resource } from "../../src/effect/observability"
|
||||||
|
|
||||||
|
const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
|
||||||
|
const opencodeClient = process.env.OPENCODE_CLIENT
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (otelResourceAttributes === undefined) delete process.env.OTEL_RESOURCE_ATTRIBUTES
|
||||||
|
else process.env.OTEL_RESOURCE_ATTRIBUTES = otelResourceAttributes
|
||||||
|
|
||||||
|
if (opencodeClient === undefined) delete process.env.OPENCODE_CLIENT
|
||||||
|
else process.env.OPENCODE_CLIENT = opencodeClient
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("resource", () => {
|
||||||
|
test("parses and decodes OTEL resource attributes", () => {
|
||||||
|
process.env.OTEL_RESOURCE_ATTRIBUTES =
|
||||||
|
"service.namespace=anomalyco,team=platform%2Cobservability,label=hello%3Dworld,key%2Fname=value%20here"
|
||||||
|
|
||||||
|
expect(resource().attributes).toMatchObject({
|
||||||
|
"service.namespace": "anomalyco",
|
||||||
|
team: "platform,observability",
|
||||||
|
label: "hello=world",
|
||||||
|
"key/name": "value here",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("drops OTEL resource attributes when any entry is invalid", () => {
|
||||||
|
process.env.OTEL_RESOURCE_ATTRIBUTES = "service.namespace=anomalyco,broken"
|
||||||
|
|
||||||
|
expect(resource().attributes["service.namespace"]).toBeUndefined()
|
||||||
|
expect(resource().attributes["opencode.client"]).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps built-in attributes when env values conflict", () => {
|
||||||
|
process.env.OPENCODE_CLIENT = "cli"
|
||||||
|
process.env.OTEL_RESOURCE_ATTRIBUTES =
|
||||||
|
"opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
|
||||||
|
|
||||||
|
expect(resource().attributes).toMatchObject({
|
||||||
|
"opencode.client": "cli",
|
||||||
|
"service.namespace": "anomalyco",
|
||||||
|
})
|
||||||
|
expect(resource().attributes["service.instance.id"]).not.toBe("override")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { APICallError } from "ai"
|
import { APICallError } from "ai"
|
||||||
import { MessageV2 } from "../../src/session/message-v2"
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
|
import { ProviderTransform } from "../../src/provider"
|
||||||
import type { Provider } from "../../src/provider"
|
import type { Provider } from "../../src/provider"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { SessionID, MessageID, PartID } from "../../src/session/schema"
|
import { SessionID, MessageID, PartID } from "../../src/session/schema"
|
||||||
@@ -359,6 +360,89 @@ describe("session.message-v2.toModelMessage", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("preserves jpeg tool-result media for anthropic models", async () => {
|
||||||
|
const anthropicModel: Provider.Model = {
|
||||||
|
...model,
|
||||||
|
id: ModelID.make("anthropic/claude-opus-4-7"),
|
||||||
|
providerID: ProviderID.make("anthropic"),
|
||||||
|
api: {
|
||||||
|
id: "claude-opus-4-7-20250805",
|
||||||
|
url: "https://api.anthropic.com",
|
||||||
|
npm: "@ai-sdk/anthropic",
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
...model.capabilities,
|
||||||
|
attachment: true,
|
||||||
|
input: {
|
||||||
|
...model.capabilities.input,
|
||||||
|
image: true,
|
||||||
|
pdf: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]).toString(
|
||||||
|
"base64",
|
||||||
|
)
|
||||||
|
const userID = "m-user-anthropic"
|
||||||
|
const assistantID = "m-assistant-anthropic"
|
||||||
|
const input: MessageV2.WithParts[] = [
|
||||||
|
{
|
||||||
|
info: userInfo(userID),
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
...basePart(userID, "u1-anthropic"),
|
||||||
|
type: "text",
|
||||||
|
text: "run tool",
|
||||||
|
},
|
||||||
|
] as MessageV2.Part[],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
info: assistantInfo(assistantID, userID),
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
...basePart(assistantID, "a1-anthropic"),
|
||||||
|
type: "tool",
|
||||||
|
callID: "call-anthropic-1",
|
||||||
|
tool: "read",
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: { filePath: "/tmp/rails-demo.png" },
|
||||||
|
output: "Image read successfully",
|
||||||
|
title: "Read",
|
||||||
|
metadata: {},
|
||||||
|
time: { start: 0, end: 1 },
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
...basePart(assistantID, "file-anthropic-1"),
|
||||||
|
type: "file",
|
||||||
|
mime: "image/jpeg",
|
||||||
|
filename: "rails-demo.png",
|
||||||
|
url: `data:image/jpeg;base64,${jpeg}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as MessageV2.Part[],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = ProviderTransform.message(await MessageV2.toModelMessages(input, anthropicModel), anthropicModel, {})
|
||||||
|
expect(result).toHaveLength(3)
|
||||||
|
expect(result[2].role).toBe("tool")
|
||||||
|
expect(result[2].content[0]).toMatchObject({
|
||||||
|
type: "tool-result",
|
||||||
|
toolCallId: "call-anthropic-1",
|
||||||
|
toolName: "read",
|
||||||
|
output: {
|
||||||
|
type: "content",
|
||||||
|
value: [
|
||||||
|
{ type: "text", text: "Image read successfully" },
|
||||||
|
{ type: "media", mediaType: "image/jpeg", data: jpeg },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("omits provider metadata when assistant model differs", async () => {
|
test("omits provider metadata when assistant model differs", async () => {
|
||||||
const userID = "m-user"
|
const userID = "m-user"
|
||||||
const assistantID = "m-assistant"
|
const assistantID = "m-assistant"
|
||||||
|
|||||||
@@ -0,0 +1,916 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import * as DateTime from "effect/DateTime"
|
||||||
|
import * as FastCheck from "effect/testing/FastCheck"
|
||||||
|
import { SessionEntry } from "../../src/v2/session-entry"
|
||||||
|
import { SessionEntryStepper } from "../../src/v2/session-entry-stepper"
|
||||||
|
import { SessionEvent } from "../../src/v2/session-event"
|
||||||
|
|
||||||
|
const time = (n: number) => DateTime.makeUnsafe(n)
|
||||||
|
|
||||||
|
const word = FastCheck.string({ minLength: 1, maxLength: 8 })
|
||||||
|
const text = FastCheck.string({ maxLength: 16 })
|
||||||
|
const texts = FastCheck.array(text, { maxLength: 8 })
|
||||||
|
const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 }))
|
||||||
|
const dict = FastCheck.dictionary(word, val, { maxKeys: 4 })
|
||||||
|
const files = FastCheck.array(
|
||||||
|
word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })),
|
||||||
|
{ maxLength: 2 },
|
||||||
|
)
|
||||||
|
|
||||||
|
function maybe<A>(arb: FastCheck.Arbitrary<A>) {
|
||||||
|
return FastCheck.oneof(FastCheck.constant(undefined), arb)
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistant() {
|
||||||
|
return new SessionEntry.Assistant({
|
||||||
|
id: SessionEvent.ID.create(),
|
||||||
|
type: "assistant",
|
||||||
|
time: { created: time(0) },
|
||||||
|
content: [],
|
||||||
|
retries: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryError(message: string) {
|
||||||
|
return new SessionEvent.RetryError({
|
||||||
|
message,
|
||||||
|
isRetryable: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function retry(attempt: number, message: string, created: number) {
|
||||||
|
return new SessionEntry.AssistantRetry({
|
||||||
|
attempt,
|
||||||
|
error: retryError(message),
|
||||||
|
time: {
|
||||||
|
created: time(created),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function memoryState() {
|
||||||
|
const state: SessionEntryStepper.MemoryState = {
|
||||||
|
entries: [],
|
||||||
|
pending: [],
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
function active() {
|
||||||
|
const state: SessionEntryStepper.MemoryState = {
|
||||||
|
entries: [assistant()],
|
||||||
|
pending: [],
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(events: SessionEvent.Event[], state = memoryState()) {
|
||||||
|
return events.reduce<SessionEntryStepper.MemoryState>((state, event) => SessionEntryStepper.step(state, event), state)
|
||||||
|
}
|
||||||
|
|
||||||
|
function last(state: SessionEntryStepper.MemoryState) {
|
||||||
|
const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant")
|
||||||
|
expect(entry?.type).toBe("assistant")
|
||||||
|
return entry?.type === "assistant" ? entry : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function texts_of(state: SessionEntryStepper.MemoryState) {
|
||||||
|
const entry = last(state)
|
||||||
|
if (!entry) return []
|
||||||
|
return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text")
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasons(state: SessionEntryStepper.MemoryState) {
|
||||||
|
const entry = last(state)
|
||||||
|
if (!entry) return []
|
||||||
|
return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning")
|
||||||
|
}
|
||||||
|
|
||||||
|
function tools(state: SessionEntryStepper.MemoryState) {
|
||||||
|
const entry = last(state)
|
||||||
|
if (!entry) return []
|
||||||
|
return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool")
|
||||||
|
}
|
||||||
|
|
||||||
|
function tool(state: SessionEntryStepper.MemoryState, callID: string) {
|
||||||
|
return tools(state).find((x) => x.callID === callID)
|
||||||
|
}
|
||||||
|
|
||||||
|
function retriesOf(state: SessionEntryStepper.MemoryState) {
|
||||||
|
const entry = last(state)
|
||||||
|
if (!entry) return []
|
||||||
|
return entry.retries ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
function adapterStore() {
|
||||||
|
return {
|
||||||
|
committed: [] as SessionEntry.Entry[],
|
||||||
|
deferred: [] as SessionEntry.Entry[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adapterFor(store: ReturnType<typeof adapterStore>): SessionEntryStepper.Adapter<typeof store> {
|
||||||
|
const activeAssistantIndex = () =>
|
||||||
|
store.committed.findLastIndex((entry) => entry.type === "assistant" && !entry.time.completed)
|
||||||
|
|
||||||
|
const getCurrentAssistant = () => {
|
||||||
|
const index = activeAssistantIndex()
|
||||||
|
if (index < 0) return
|
||||||
|
const assistant = store.committed[index]
|
||||||
|
return assistant?.type === "assistant" ? assistant : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCurrentAssistant,
|
||||||
|
updateAssistant(assistant) {
|
||||||
|
const index = activeAssistantIndex()
|
||||||
|
if (index < 0) return
|
||||||
|
const current = store.committed[index]
|
||||||
|
if (current?.type !== "assistant") return
|
||||||
|
store.committed[index] = assistant
|
||||||
|
},
|
||||||
|
appendEntry(entry) {
|
||||||
|
store.committed.push(entry)
|
||||||
|
},
|
||||||
|
appendPending(entry) {
|
||||||
|
store.deferred.push(entry)
|
||||||
|
},
|
||||||
|
finish() {
|
||||||
|
return store
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("session-entry-stepper", () => {
|
||||||
|
describe("stepWith", () => {
|
||||||
|
test("reduces through a custom adapter", () => {
|
||||||
|
const store = adapterStore()
|
||||||
|
store.committed.push(assistant())
|
||||||
|
|
||||||
|
SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Prompt.create({ text: "hello", timestamp: time(1) }))
|
||||||
|
SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Reasoning.Started.create({ timestamp: time(2) }))
|
||||||
|
SessionEntryStepper.stepWith(
|
||||||
|
adapterFor(store),
|
||||||
|
SessionEvent.Reasoning.Delta.create({ delta: "thinking", timestamp: time(3) }),
|
||||||
|
)
|
||||||
|
SessionEntryStepper.stepWith(
|
||||||
|
adapterFor(store),
|
||||||
|
SessionEvent.Reasoning.Ended.create({ text: "thought", timestamp: time(4) }),
|
||||||
|
)
|
||||||
|
SessionEntryStepper.stepWith(adapterFor(store), SessionEvent.Text.Started.create({ timestamp: time(5) }))
|
||||||
|
SessionEntryStepper.stepWith(
|
||||||
|
adapterFor(store),
|
||||||
|
SessionEvent.Text.Delta.create({ delta: "world", timestamp: time(6) }),
|
||||||
|
)
|
||||||
|
SessionEntryStepper.stepWith(
|
||||||
|
adapterFor(store),
|
||||||
|
SessionEvent.Step.Ended.create({
|
||||||
|
reason: "stop",
|
||||||
|
cost: 1,
|
||||||
|
tokens: {
|
||||||
|
input: 1,
|
||||||
|
output: 2,
|
||||||
|
reasoning: 3,
|
||||||
|
cache: {
|
||||||
|
read: 4,
|
||||||
|
write: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamp: time(7),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(store.deferred).toHaveLength(1)
|
||||||
|
expect(store.deferred[0]?.type).toBe("user")
|
||||||
|
expect(store.committed).toHaveLength(1)
|
||||||
|
expect(store.committed[0]?.type).toBe("assistant")
|
||||||
|
if (store.committed[0]?.type !== "assistant") return
|
||||||
|
|
||||||
|
expect(store.committed[0].content).toEqual([
|
||||||
|
{ type: "reasoning", text: "thought" },
|
||||||
|
{ type: "text", text: "world" },
|
||||||
|
])
|
||||||
|
expect(store.committed[0].time.completed).toEqual(time(7))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("aggregates retry events onto the current assistant", () => {
|
||||||
|
const store = adapterStore()
|
||||||
|
store.committed.push(assistant())
|
||||||
|
|
||||||
|
SessionEntryStepper.stepWith(
|
||||||
|
adapterFor(store),
|
||||||
|
SessionEvent.Retried.create({
|
||||||
|
attempt: 1,
|
||||||
|
error: retryError("rate limited"),
|
||||||
|
timestamp: time(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
SessionEntryStepper.stepWith(
|
||||||
|
adapterFor(store),
|
||||||
|
SessionEvent.Retried.create({
|
||||||
|
attempt: 2,
|
||||||
|
error: retryError("provider overloaded"),
|
||||||
|
timestamp: time(2),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(store.committed[0]?.type).toBe("assistant")
|
||||||
|
if (store.committed[0]?.type !== "assistant") return
|
||||||
|
|
||||||
|
expect(store.committed[0].retries).toEqual([retry(1, "rate limited", 1), retry(2, "provider overloaded", 2)])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("memory", () => {
|
||||||
|
test("tracks and replaces the current assistant", () => {
|
||||||
|
const state = active()
|
||||||
|
const adapter = SessionEntryStepper.memory(state)
|
||||||
|
const current = adapter.getCurrentAssistant()
|
||||||
|
|
||||||
|
expect(current?.type).toBe("assistant")
|
||||||
|
if (!current) return
|
||||||
|
|
||||||
|
adapter.updateAssistant(
|
||||||
|
new SessionEntry.Assistant({
|
||||||
|
...current,
|
||||||
|
content: [new SessionEntry.AssistantText({ type: "text", text: "done" })],
|
||||||
|
time: {
|
||||||
|
...current.time,
|
||||||
|
completed: time(1),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(adapter.getCurrentAssistant()).toBeUndefined()
|
||||||
|
expect(state.entries[0]?.type).toBe("assistant")
|
||||||
|
if (state.entries[0]?.type !== "assistant") return
|
||||||
|
|
||||||
|
expect(state.entries[0].content).toEqual([{ type: "text", text: "done" }])
|
||||||
|
expect(state.entries[0].time.completed).toEqual(time(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
test("appends committed and pending entries", () => {
|
||||||
|
const state = memoryState()
|
||||||
|
const adapter = SessionEntryStepper.memory(state)
|
||||||
|
const committed = SessionEntry.User.fromEvent(
|
||||||
|
SessionEvent.Prompt.create({ text: "committed", timestamp: time(1) }),
|
||||||
|
)
|
||||||
|
const pending = SessionEntry.User.fromEvent(SessionEvent.Prompt.create({ text: "pending", timestamp: time(2) }))
|
||||||
|
|
||||||
|
adapter.appendEntry(committed)
|
||||||
|
adapter.appendPending(pending)
|
||||||
|
|
||||||
|
expect(state.entries).toEqual([committed])
|
||||||
|
expect(state.pending).toEqual([pending])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stepWith through memory records reasoning", () => {
|
||||||
|
const state = active()
|
||||||
|
|
||||||
|
SessionEntryStepper.stepWith(
|
||||||
|
SessionEntryStepper.memory(state),
|
||||||
|
SessionEvent.Reasoning.Started.create({ timestamp: time(1) }),
|
||||||
|
)
|
||||||
|
SessionEntryStepper.stepWith(
|
||||||
|
SessionEntryStepper.memory(state),
|
||||||
|
SessionEvent.Reasoning.Delta.create({ delta: "draft", timestamp: time(2) }),
|
||||||
|
)
|
||||||
|
SessionEntryStepper.stepWith(
|
||||||
|
SessionEntryStepper.memory(state),
|
||||||
|
SessionEvent.Reasoning.Ended.create({ text: "final", timestamp: time(3) }),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(reasons(state)).toEqual([{ type: "reasoning", text: "final" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stepWith through memory records retries", () => {
|
||||||
|
const state = active()
|
||||||
|
|
||||||
|
SessionEntryStepper.stepWith(
|
||||||
|
SessionEntryStepper.memory(state),
|
||||||
|
SessionEvent.Retried.create({
|
||||||
|
attempt: 1,
|
||||||
|
error: retryError("rate limited"),
|
||||||
|
timestamp: time(1),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(retriesOf(state)).toEqual([retry(1, "rate limited", 1)])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("step", () => {
|
||||||
|
describe("seeded pending assistant", () => {
|
||||||
|
test("stores prompts in entries when no assistant is pending", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(word, (body) => {
|
||||||
|
const next = SessionEntryStepper.step(
|
||||||
|
memoryState(),
|
||||||
|
SessionEvent.Prompt.create({ text: body, timestamp: time(1) }),
|
||||||
|
)
|
||||||
|
expect(next.entries).toHaveLength(1)
|
||||||
|
expect(next.entries[0]?.type).toBe("user")
|
||||||
|
if (next.entries[0]?.type !== "user") return
|
||||||
|
expect(next.entries[0].text).toBe(body)
|
||||||
|
}),
|
||||||
|
{ numRuns: 50 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("stores prompts in pending when an assistant is pending", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(word, (body) => {
|
||||||
|
const next = SessionEntryStepper.step(
|
||||||
|
active(),
|
||||||
|
SessionEvent.Prompt.create({ text: body, timestamp: time(1) }),
|
||||||
|
)
|
||||||
|
expect(next.pending).toHaveLength(1)
|
||||||
|
expect(next.pending[0]?.type).toBe("user")
|
||||||
|
if (next.pending[0]?.type !== "user") return
|
||||||
|
expect(next.pending[0].text).toBe(body)
|
||||||
|
}),
|
||||||
|
{ numRuns: 50 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("accumulates text deltas on the latest text part", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(texts, (parts) => {
|
||||||
|
const next = parts.reduce(
|
||||||
|
(state, part, i) =>
|
||||||
|
SessionEntryStepper.step(
|
||||||
|
state,
|
||||||
|
SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) }),
|
||||||
|
),
|
||||||
|
SessionEntryStepper.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(texts_of(next)).toEqual([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: parts.join(""),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("routes later text deltas to the latest text segment", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(texts, texts, (a, b) => {
|
||||||
|
const next = run(
|
||||||
|
[
|
||||||
|
SessionEvent.Text.Started.create({ timestamp: time(1) }),
|
||||||
|
...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })),
|
||||||
|
SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }),
|
||||||
|
...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })),
|
||||||
|
],
|
||||||
|
active(),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(texts_of(next)).toEqual([
|
||||||
|
{ type: "text", text: a.join("") },
|
||||||
|
{ type: "text", text: b.join("") },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
{ numRuns: 50 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reasoning.ended replaces buffered reasoning text", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(texts, text, (parts, end) => {
|
||||||
|
const next = run(
|
||||||
|
[
|
||||||
|
SessionEvent.Reasoning.Started.create({ timestamp: time(1) }),
|
||||||
|
...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })),
|
||||||
|
SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }),
|
||||||
|
],
|
||||||
|
active(),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(reasons(next)).toEqual([
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: end,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("tool.success completes the latest running tool", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(
|
||||||
|
word,
|
||||||
|
word,
|
||||||
|
dict,
|
||||||
|
maybe(text),
|
||||||
|
maybe(dict),
|
||||||
|
maybe(files),
|
||||||
|
texts,
|
||||||
|
(callID, title, input, output, metadata, attachments, parts) => {
|
||||||
|
const next = run(
|
||||||
|
[
|
||||||
|
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
|
||||||
|
...parts.map((x, i) =>
|
||||||
|
SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }),
|
||||||
|
),
|
||||||
|
SessionEvent.Tool.Called.create({
|
||||||
|
callID,
|
||||||
|
tool: "bash",
|
||||||
|
input,
|
||||||
|
provider: { executed: true },
|
||||||
|
timestamp: time(parts.length + 2),
|
||||||
|
}),
|
||||||
|
SessionEvent.Tool.Success.create({
|
||||||
|
callID,
|
||||||
|
title,
|
||||||
|
output,
|
||||||
|
metadata,
|
||||||
|
attachments,
|
||||||
|
provider: { executed: true },
|
||||||
|
timestamp: time(parts.length + 3),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
active(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const match = tool(next, callID)
|
||||||
|
expect(match?.state.status).toBe("completed")
|
||||||
|
if (match?.state.status !== "completed") return
|
||||||
|
|
||||||
|
expect(match.time.ran).toEqual(time(parts.length + 2))
|
||||||
|
expect(match.state.input).toEqual(input)
|
||||||
|
expect(match.state.output).toBe(output ?? "")
|
||||||
|
expect(match.state.title).toBe(title)
|
||||||
|
expect(match.state.metadata).toEqual(metadata ?? {})
|
||||||
|
expect(match.state.attachments).toEqual(attachments ?? [])
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{ numRuns: 50 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("tool.error completes the latest running tool with an error", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => {
|
||||||
|
const next = run(
|
||||||
|
[
|
||||||
|
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
|
||||||
|
SessionEvent.Tool.Called.create({
|
||||||
|
callID,
|
||||||
|
tool: "bash",
|
||||||
|
input,
|
||||||
|
provider: { executed: true },
|
||||||
|
timestamp: time(2),
|
||||||
|
}),
|
||||||
|
SessionEvent.Tool.Error.create({
|
||||||
|
callID,
|
||||||
|
error,
|
||||||
|
metadata,
|
||||||
|
provider: { executed: true },
|
||||||
|
timestamp: time(3),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
active(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const match = tool(next, callID)
|
||||||
|
expect(match?.state.status).toBe("error")
|
||||||
|
if (match?.state.status !== "error") return
|
||||||
|
|
||||||
|
expect(match.time.ran).toEqual(time(2))
|
||||||
|
expect(match.state.input).toEqual(input)
|
||||||
|
expect(match.state.error).toBe(error)
|
||||||
|
expect(match.state.metadata).toEqual(metadata ?? {})
|
||||||
|
}),
|
||||||
|
{ numRuns: 50 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("tool.success is ignored before tool.called promotes the tool to running", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(word, word, (callID, title) => {
|
||||||
|
const next = run(
|
||||||
|
[
|
||||||
|
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
|
||||||
|
SessionEvent.Tool.Success.create({
|
||||||
|
callID,
|
||||||
|
title,
|
||||||
|
provider: { executed: true },
|
||||||
|
timestamp: time(2),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
active(),
|
||||||
|
)
|
||||||
|
const match = tool(next, callID)
|
||||||
|
expect(match?.state).toEqual({
|
||||||
|
status: "pending",
|
||||||
|
input: "",
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
{ numRuns: 50 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("step.ended copies completion fields onto the pending assistant", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => {
|
||||||
|
const event = SessionEvent.Step.Ended.create({
|
||||||
|
reason: "stop",
|
||||||
|
cost: 1,
|
||||||
|
tokens: {
|
||||||
|
input: 1,
|
||||||
|
output: 2,
|
||||||
|
reasoning: 3,
|
||||||
|
cache: {
|
||||||
|
read: 4,
|
||||||
|
write: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamp: time(n),
|
||||||
|
})
|
||||||
|
const next = SessionEntryStepper.step(active(), event)
|
||||||
|
const entry = last(next)
|
||||||
|
expect(entry).toBeDefined()
|
||||||
|
if (!entry) return
|
||||||
|
|
||||||
|
expect(entry.time.completed).toEqual(event.timestamp)
|
||||||
|
expect(entry.cost).toBe(event.cost)
|
||||||
|
expect(entry.tokens).toEqual(event.tokens)
|
||||||
|
}),
|
||||||
|
{ numRuns: 50 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("known reducer gaps", () => {
|
||||||
|
test("prompt appends immutably when no assistant is pending", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(word, (body) => {
|
||||||
|
const old = memoryState()
|
||||||
|
const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
|
||||||
|
expect(old).not.toBe(next)
|
||||||
|
expect(old.entries).toHaveLength(0)
|
||||||
|
expect(next.entries).toHaveLength(1)
|
||||||
|
}),
|
||||||
|
{ numRuns: 50 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prompt appends immutably when an assistant is pending", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(word, (body) => {
|
||||||
|
const old = active()
|
||||||
|
const next = SessionEntryStepper.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
|
||||||
|
expect(old).not.toBe(next)
|
||||||
|
expect(old.pending).toHaveLength(0)
|
||||||
|
expect(next.pending).toHaveLength(1)
|
||||||
|
}),
|
||||||
|
{ numRuns: 50 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("step.started creates an assistant consumed by follow-up events", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(texts, (parts) => {
|
||||||
|
const next = run([
|
||||||
|
SessionEvent.Step.Started.create({
|
||||||
|
model: {
|
||||||
|
id: "model",
|
||||||
|
providerID: "provider",
|
||||||
|
},
|
||||||
|
timestamp: time(1),
|
||||||
|
}),
|
||||||
|
SessionEvent.Text.Started.create({ timestamp: time(2) }),
|
||||||
|
...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
|
||||||
|
SessionEvent.Step.Ended.create({
|
||||||
|
reason: "stop",
|
||||||
|
cost: 1,
|
||||||
|
tokens: {
|
||||||
|
input: 1,
|
||||||
|
output: 2,
|
||||||
|
reasoning: 3,
|
||||||
|
cache: {
|
||||||
|
read: 4,
|
||||||
|
write: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamp: time(parts.length + 3),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
const entry = last(next)
|
||||||
|
|
||||||
|
expect(entry).toBeDefined()
|
||||||
|
if (!entry) return
|
||||||
|
|
||||||
|
expect(entry.content).toEqual([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: parts.join(""),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(entry.time.completed).toEqual(time(parts.length + 3))
|
||||||
|
}),
|
||||||
|
{ numRuns: 100 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replays prompt -> step -> text -> step.ended", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(word, texts, (body, parts) => {
|
||||||
|
const next = run([
|
||||||
|
SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
|
||||||
|
SessionEvent.Step.Started.create({
|
||||||
|
model: {
|
||||||
|
id: "model",
|
||||||
|
providerID: "provider",
|
||||||
|
},
|
||||||
|
timestamp: time(1),
|
||||||
|
}),
|
||||||
|
SessionEvent.Text.Started.create({ timestamp: time(2) }),
|
||||||
|
...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
|
||||||
|
SessionEvent.Step.Ended.create({
|
||||||
|
reason: "stop",
|
||||||
|
cost: 1,
|
||||||
|
tokens: {
|
||||||
|
input: 1,
|
||||||
|
output: 2,
|
||||||
|
reasoning: 3,
|
||||||
|
cache: {
|
||||||
|
read: 4,
|
||||||
|
write: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamp: time(parts.length + 3),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(next.entries).toHaveLength(2)
|
||||||
|
expect(next.entries[0]?.type).toBe("user")
|
||||||
|
expect(next.entries[1]?.type).toBe("assistant")
|
||||||
|
if (next.entries[1]?.type !== "assistant") return
|
||||||
|
|
||||||
|
expect(next.entries[1].content).toEqual([
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: parts.join(""),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(next.entries[1].time.completed).toEqual(time(parts.length + 3))
|
||||||
|
}),
|
||||||
|
{ numRuns: 50 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(
|
||||||
|
word,
|
||||||
|
texts,
|
||||||
|
text,
|
||||||
|
dict,
|
||||||
|
word,
|
||||||
|
maybe(text),
|
||||||
|
maybe(dict),
|
||||||
|
maybe(files),
|
||||||
|
(body, reason, end, input, title, output, metadata, attachments) => {
|
||||||
|
const callID = "call"
|
||||||
|
const next = run([
|
||||||
|
SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
|
||||||
|
SessionEvent.Step.Started.create({
|
||||||
|
model: {
|
||||||
|
id: "model",
|
||||||
|
providerID: "provider",
|
||||||
|
},
|
||||||
|
timestamp: time(1),
|
||||||
|
}),
|
||||||
|
SessionEvent.Reasoning.Started.create({ timestamp: time(2) }),
|
||||||
|
...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })),
|
||||||
|
SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }),
|
||||||
|
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }),
|
||||||
|
SessionEvent.Tool.Called.create({
|
||||||
|
callID,
|
||||||
|
tool: "bash",
|
||||||
|
input,
|
||||||
|
provider: { executed: true },
|
||||||
|
timestamp: time(reason.length + 5),
|
||||||
|
}),
|
||||||
|
SessionEvent.Tool.Success.create({
|
||||||
|
callID,
|
||||||
|
title,
|
||||||
|
output,
|
||||||
|
metadata,
|
||||||
|
attachments,
|
||||||
|
provider: { executed: true },
|
||||||
|
timestamp: time(reason.length + 6),
|
||||||
|
}),
|
||||||
|
SessionEvent.Step.Ended.create({
|
||||||
|
reason: "stop",
|
||||||
|
cost: 1,
|
||||||
|
tokens: {
|
||||||
|
input: 1,
|
||||||
|
output: 2,
|
||||||
|
reasoning: 3,
|
||||||
|
cache: {
|
||||||
|
read: 4,
|
||||||
|
write: 5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamp: time(reason.length + 7),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(next.entries.at(-1)?.type).toBe("assistant")
|
||||||
|
const entry = next.entries.at(-1)
|
||||||
|
if (entry?.type !== "assistant") return
|
||||||
|
|
||||||
|
expect(entry.content).toHaveLength(2)
|
||||||
|
expect(entry.content[0]).toEqual({
|
||||||
|
type: "reasoning",
|
||||||
|
text: end,
|
||||||
|
})
|
||||||
|
expect(entry.content[1]?.type).toBe("tool")
|
||||||
|
if (entry.content[1]?.type !== "tool") return
|
||||||
|
expect(entry.content[1].state.status).toBe("completed")
|
||||||
|
expect(entry.time.completed).toEqual(time(reason.length + 7))
|
||||||
|
},
|
||||||
|
),
|
||||||
|
{ numRuns: 50 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("starting a new step completes the old assistant and appends a new active assistant", () => {
|
||||||
|
const next = run(
|
||||||
|
[
|
||||||
|
SessionEvent.Step.Started.create({
|
||||||
|
model: {
|
||||||
|
id: "model",
|
||||||
|
providerID: "provider",
|
||||||
|
},
|
||||||
|
timestamp: time(1),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
active(),
|
||||||
|
)
|
||||||
|
expect(next.entries).toHaveLength(2)
|
||||||
|
expect(next.entries[0]?.type).toBe("assistant")
|
||||||
|
expect(next.entries[1]?.type).toBe("assistant")
|
||||||
|
if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return
|
||||||
|
|
||||||
|
expect(next.entries[0].time.completed).toEqual(time(1))
|
||||||
|
expect(next.entries[1].time.created).toEqual(time(1))
|
||||||
|
expect(next.entries[1].time.completed).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles sequential tools independently", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(dict, dict, word, word, (a, b, title, error) => {
|
||||||
|
const next = run(
|
||||||
|
[
|
||||||
|
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
|
||||||
|
SessionEvent.Tool.Called.create({
|
||||||
|
callID: "a",
|
||||||
|
tool: "bash",
|
||||||
|
input: a,
|
||||||
|
provider: { executed: true },
|
||||||
|
timestamp: time(2),
|
||||||
|
}),
|
||||||
|
SessionEvent.Tool.Success.create({
|
||||||
|
callID: "a",
|
||||||
|
title,
|
||||||
|
output: "done",
|
||||||
|
provider: { executed: true },
|
||||||
|
timestamp: time(3),
|
||||||
|
}),
|
||||||
|
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }),
|
||||||
|
SessionEvent.Tool.Called.create({
|
||||||
|
callID: "b",
|
||||||
|
tool: "bash",
|
||||||
|
input: b,
|
||||||
|
provider: { executed: true },
|
||||||
|
timestamp: time(5),
|
||||||
|
}),
|
||||||
|
SessionEvent.Tool.Error.create({
|
||||||
|
callID: "b",
|
||||||
|
error,
|
||||||
|
provider: { executed: true },
|
||||||
|
timestamp: time(6),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
active(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const first = tool(next, "a")
|
||||||
|
const second = tool(next, "b")
|
||||||
|
|
||||||
|
expect(first?.state.status).toBe("completed")
|
||||||
|
if (first?.state.status !== "completed") return
|
||||||
|
expect(first.state.input).toEqual(a)
|
||||||
|
expect(first.state.output).toBe("done")
|
||||||
|
expect(first.state.title).toBe(title)
|
||||||
|
|
||||||
|
expect(second?.state.status).toBe("error")
|
||||||
|
if (second?.state.status !== "error") return
|
||||||
|
expect(second.state.input).toEqual(b)
|
||||||
|
expect(second.state.error).toBe(error)
|
||||||
|
}),
|
||||||
|
{ numRuns: 50 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("routes tool events by callID when tool streams interleave", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => {
|
||||||
|
const next = run(
|
||||||
|
[
|
||||||
|
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
|
||||||
|
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }),
|
||||||
|
SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }),
|
||||||
|
SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }),
|
||||||
|
SessionEvent.Tool.Called.create({
|
||||||
|
callID: "a",
|
||||||
|
tool: "bash",
|
||||||
|
input: a,
|
||||||
|
provider: { executed: true },
|
||||||
|
timestamp: time(5),
|
||||||
|
}),
|
||||||
|
SessionEvent.Tool.Called.create({
|
||||||
|
callID: "b",
|
||||||
|
tool: "grep",
|
||||||
|
input: b,
|
||||||
|
provider: { executed: true },
|
||||||
|
timestamp: time(6),
|
||||||
|
}),
|
||||||
|
SessionEvent.Tool.Success.create({
|
||||||
|
callID: "a",
|
||||||
|
title: titleA,
|
||||||
|
output: "done-a",
|
||||||
|
provider: { executed: true },
|
||||||
|
timestamp: time(7),
|
||||||
|
}),
|
||||||
|
SessionEvent.Tool.Success.create({
|
||||||
|
callID: "b",
|
||||||
|
title: titleB,
|
||||||
|
output: "done-b",
|
||||||
|
provider: { executed: true },
|
||||||
|
timestamp: time(8),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
active(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const first = tool(next, "a")
|
||||||
|
const second = tool(next, "b")
|
||||||
|
|
||||||
|
expect(first?.state.status).toBe("completed")
|
||||||
|
expect(second?.state.status).toBe("completed")
|
||||||
|
if (first?.state.status !== "completed" || second?.state.status !== "completed") return
|
||||||
|
|
||||||
|
expect(first.state.input).toEqual(a)
|
||||||
|
expect(second.state.input).toEqual(b)
|
||||||
|
expect(first.state.title).toBe(titleA)
|
||||||
|
expect(second.state.title).toBe(titleB)
|
||||||
|
}),
|
||||||
|
{ numRuns: 50 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("records synthetic events", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(word, (body) => {
|
||||||
|
const next = SessionEntryStepper.step(
|
||||||
|
memoryState(),
|
||||||
|
SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }),
|
||||||
|
)
|
||||||
|
expect(next.entries).toHaveLength(1)
|
||||||
|
expect(next.entries[0]?.type).toBe("synthetic")
|
||||||
|
if (next.entries[0]?.type !== "synthetic") return
|
||||||
|
expect(next.entries[0].text).toBe(body)
|
||||||
|
}),
|
||||||
|
{ numRuns: 50 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("records compaction events", () => {
|
||||||
|
FastCheck.assert(
|
||||||
|
FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => {
|
||||||
|
const next = SessionEntryStepper.step(
|
||||||
|
memoryState(),
|
||||||
|
SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }),
|
||||||
|
)
|
||||||
|
expect(next.entries).toHaveLength(1)
|
||||||
|
expect(next.entries[0]?.type).toBe("compaction")
|
||||||
|
if (next.entries[0]?.type !== "compaction") return
|
||||||
|
expect(next.entries[0].auto).toBe(auto)
|
||||||
|
expect(next.entries[0].overflow).toBe(overflow)
|
||||||
|
}),
|
||||||
|
{ numRuns: 50 },
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,681 +0,0 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
|
||||||
import * as DateTime from "effect/DateTime"
|
|
||||||
import * as FastCheck from "effect/testing/FastCheck"
|
|
||||||
import { SessionEntry } from "../../src/v2/session-entry"
|
|
||||||
import { SessionEvent } from "../../src/v2/session-event"
|
|
||||||
|
|
||||||
const time = (n: number) => DateTime.makeUnsafe(n)
|
|
||||||
|
|
||||||
const word = FastCheck.string({ minLength: 1, maxLength: 8 })
|
|
||||||
const text = FastCheck.string({ maxLength: 16 })
|
|
||||||
const texts = FastCheck.array(text, { maxLength: 8 })
|
|
||||||
const val = FastCheck.oneof(FastCheck.boolean(), FastCheck.integer(), FastCheck.string({ maxLength: 12 }))
|
|
||||||
const dict = FastCheck.dictionary(word, val, { maxKeys: 4 })
|
|
||||||
const files = FastCheck.array(
|
|
||||||
word.map((x) => SessionEvent.FileAttachment.create({ uri: `file://${encodeURIComponent(x)}`, mime: "text/plain" })),
|
|
||||||
{ maxLength: 2 },
|
|
||||||
)
|
|
||||||
|
|
||||||
function maybe<A>(arb: FastCheck.Arbitrary<A>) {
|
|
||||||
return FastCheck.oneof(FastCheck.constant(undefined), arb)
|
|
||||||
}
|
|
||||||
|
|
||||||
function assistant() {
|
|
||||||
return new SessionEntry.Assistant({
|
|
||||||
id: SessionEvent.ID.create(),
|
|
||||||
type: "assistant",
|
|
||||||
time: { created: time(0) },
|
|
||||||
content: [],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function history() {
|
|
||||||
const state: SessionEntry.History = {
|
|
||||||
entries: [],
|
|
||||||
pending: [],
|
|
||||||
}
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
function active() {
|
|
||||||
const state: SessionEntry.History = {
|
|
||||||
entries: [assistant()],
|
|
||||||
pending: [],
|
|
||||||
}
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
function run(events: SessionEvent.Event[], state = history()) {
|
|
||||||
return events.reduce<SessionEntry.History>((state, event) => SessionEntry.step(state, event), state)
|
|
||||||
}
|
|
||||||
|
|
||||||
function last(state: SessionEntry.History) {
|
|
||||||
const entry = [...state.pending, ...state.entries].reverse().find((x) => x.type === "assistant")
|
|
||||||
expect(entry?.type).toBe("assistant")
|
|
||||||
return entry?.type === "assistant" ? entry : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
function texts_of(state: SessionEntry.History) {
|
|
||||||
const entry = last(state)
|
|
||||||
if (!entry) return []
|
|
||||||
return entry.content.filter((x): x is SessionEntry.AssistantText => x.type === "text")
|
|
||||||
}
|
|
||||||
|
|
||||||
function reasons(state: SessionEntry.History) {
|
|
||||||
const entry = last(state)
|
|
||||||
if (!entry) return []
|
|
||||||
return entry.content.filter((x): x is SessionEntry.AssistantReasoning => x.type === "reasoning")
|
|
||||||
}
|
|
||||||
|
|
||||||
function tools(state: SessionEntry.History) {
|
|
||||||
const entry = last(state)
|
|
||||||
if (!entry) return []
|
|
||||||
return entry.content.filter((x): x is SessionEntry.AssistantTool => x.type === "tool")
|
|
||||||
}
|
|
||||||
|
|
||||||
function tool(state: SessionEntry.History, callID: string) {
|
|
||||||
return tools(state).find((x) => x.callID === callID)
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("session-entry step", () => {
|
|
||||||
describe("seeded pending assistant", () => {
|
|
||||||
test("stores prompts in entries when no assistant is pending", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(word, (body) => {
|
|
||||||
const next = SessionEntry.step(history(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
|
|
||||||
expect(next.entries).toHaveLength(1)
|
|
||||||
expect(next.entries[0]?.type).toBe("user")
|
|
||||||
if (next.entries[0]?.type !== "user") return
|
|
||||||
expect(next.entries[0].text).toBe(body)
|
|
||||||
}),
|
|
||||||
{ numRuns: 50 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("stores prompts in pending when an assistant is pending", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(word, (body) => {
|
|
||||||
const next = SessionEntry.step(active(), SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
|
|
||||||
expect(next.pending).toHaveLength(1)
|
|
||||||
expect(next.pending[0]?.type).toBe("user")
|
|
||||||
if (next.pending[0]?.type !== "user") return
|
|
||||||
expect(next.pending[0].text).toBe(body)
|
|
||||||
}),
|
|
||||||
{ numRuns: 50 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("accumulates text deltas on the latest text part", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(texts, (parts) => {
|
|
||||||
const next = parts.reduce(
|
|
||||||
(state, part, i) =>
|
|
||||||
SessionEntry.step(state, SessionEvent.Text.Delta.create({ delta: part, timestamp: time(i + 2) })),
|
|
||||||
SessionEntry.step(active(), SessionEvent.Text.Started.create({ timestamp: time(1) })),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(texts_of(next)).toEqual([
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: parts.join(""),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("routes later text deltas to the latest text segment", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(texts, texts, (a, b) => {
|
|
||||||
const next = run(
|
|
||||||
[
|
|
||||||
SessionEvent.Text.Started.create({ timestamp: time(1) }),
|
|
||||||
...a.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 2) })),
|
|
||||||
SessionEvent.Text.Started.create({ timestamp: time(a.length + 2) }),
|
|
||||||
...b.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + a.length + 3) })),
|
|
||||||
],
|
|
||||||
active(),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(texts_of(next)).toEqual([
|
|
||||||
{ type: "text", text: a.join("") },
|
|
||||||
{ type: "text", text: b.join("") },
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
{ numRuns: 50 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("reasoning.ended replaces buffered reasoning text", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(texts, text, (parts, end) => {
|
|
||||||
const next = run(
|
|
||||||
[
|
|
||||||
SessionEvent.Reasoning.Started.create({ timestamp: time(1) }),
|
|
||||||
...parts.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 2) })),
|
|
||||||
SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(parts.length + 2) }),
|
|
||||||
],
|
|
||||||
active(),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(reasons(next)).toEqual([
|
|
||||||
{
|
|
||||||
type: "reasoning",
|
|
||||||
text: end,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("tool.success completes the latest running tool", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(
|
|
||||||
word,
|
|
||||||
word,
|
|
||||||
dict,
|
|
||||||
maybe(text),
|
|
||||||
maybe(dict),
|
|
||||||
maybe(files),
|
|
||||||
texts,
|
|
||||||
(callID, title, input, output, metadata, attachments, parts) => {
|
|
||||||
const next = run(
|
|
||||||
[
|
|
||||||
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
|
|
||||||
...parts.map((x, i) =>
|
|
||||||
SessionEvent.Tool.Input.Delta.create({ callID, delta: x, timestamp: time(i + 2) }),
|
|
||||||
),
|
|
||||||
SessionEvent.Tool.Called.create({
|
|
||||||
callID,
|
|
||||||
tool: "bash",
|
|
||||||
input,
|
|
||||||
provider: { executed: true },
|
|
||||||
timestamp: time(parts.length + 2),
|
|
||||||
}),
|
|
||||||
SessionEvent.Tool.Success.create({
|
|
||||||
callID,
|
|
||||||
title,
|
|
||||||
output,
|
|
||||||
metadata,
|
|
||||||
attachments,
|
|
||||||
provider: { executed: true },
|
|
||||||
timestamp: time(parts.length + 3),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
active(),
|
|
||||||
)
|
|
||||||
|
|
||||||
const match = tool(next, callID)
|
|
||||||
expect(match?.state.status).toBe("completed")
|
|
||||||
if (match?.state.status !== "completed") return
|
|
||||||
|
|
||||||
expect(match.time.ran).toEqual(time(parts.length + 2))
|
|
||||||
expect(match.state.input).toEqual(input)
|
|
||||||
expect(match.state.output).toBe(output ?? "")
|
|
||||||
expect(match.state.title).toBe(title)
|
|
||||||
expect(match.state.metadata).toEqual(metadata ?? {})
|
|
||||||
expect(match.state.attachments).toEqual(attachments ?? [])
|
|
||||||
},
|
|
||||||
),
|
|
||||||
{ numRuns: 50 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("tool.error completes the latest running tool with an error", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(word, dict, word, maybe(dict), (callID, input, error, metadata) => {
|
|
||||||
const next = run(
|
|
||||||
[
|
|
||||||
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
|
|
||||||
SessionEvent.Tool.Called.create({
|
|
||||||
callID,
|
|
||||||
tool: "bash",
|
|
||||||
input,
|
|
||||||
provider: { executed: true },
|
|
||||||
timestamp: time(2),
|
|
||||||
}),
|
|
||||||
SessionEvent.Tool.Error.create({
|
|
||||||
callID,
|
|
||||||
error,
|
|
||||||
metadata,
|
|
||||||
provider: { executed: true },
|
|
||||||
timestamp: time(3),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
active(),
|
|
||||||
)
|
|
||||||
|
|
||||||
const match = tool(next, callID)
|
|
||||||
expect(match?.state.status).toBe("error")
|
|
||||||
if (match?.state.status !== "error") return
|
|
||||||
|
|
||||||
expect(match.time.ran).toEqual(time(2))
|
|
||||||
expect(match.state.input).toEqual(input)
|
|
||||||
expect(match.state.error).toBe(error)
|
|
||||||
expect(match.state.metadata).toEqual(metadata ?? {})
|
|
||||||
}),
|
|
||||||
{ numRuns: 50 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("tool.success is ignored before tool.called promotes the tool to running", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(word, word, (callID, title) => {
|
|
||||||
const next = run(
|
|
||||||
[
|
|
||||||
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(1) }),
|
|
||||||
SessionEvent.Tool.Success.create({
|
|
||||||
callID,
|
|
||||||
title,
|
|
||||||
provider: { executed: true },
|
|
||||||
timestamp: time(2),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
active(),
|
|
||||||
)
|
|
||||||
const match = tool(next, callID)
|
|
||||||
expect(match?.state).toEqual({
|
|
||||||
status: "pending",
|
|
||||||
input: "",
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
{ numRuns: 50 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("step.ended copies completion fields onto the pending assistant", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(FastCheck.integer({ min: 1, max: 1000 }), (n) => {
|
|
||||||
const event = SessionEvent.Step.Ended.create({
|
|
||||||
reason: "stop",
|
|
||||||
cost: 1,
|
|
||||||
tokens: {
|
|
||||||
input: 1,
|
|
||||||
output: 2,
|
|
||||||
reasoning: 3,
|
|
||||||
cache: {
|
|
||||||
read: 4,
|
|
||||||
write: 5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
timestamp: time(n),
|
|
||||||
})
|
|
||||||
const next = SessionEntry.step(active(), event)
|
|
||||||
const entry = last(next)
|
|
||||||
expect(entry).toBeDefined()
|
|
||||||
if (!entry) return
|
|
||||||
|
|
||||||
expect(entry.time.completed).toEqual(event.timestamp)
|
|
||||||
expect(entry.cost).toBe(event.cost)
|
|
||||||
expect(entry.tokens).toEqual(event.tokens)
|
|
||||||
}),
|
|
||||||
{ numRuns: 50 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("known reducer gaps", () => {
|
|
||||||
test("prompt appends immutably when no assistant is pending", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(word, (body) => {
|
|
||||||
const old = history()
|
|
||||||
const next = SessionEntry.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
|
|
||||||
expect(old).not.toBe(next)
|
|
||||||
expect(old.entries).toHaveLength(0)
|
|
||||||
expect(next.entries).toHaveLength(1)
|
|
||||||
}),
|
|
||||||
{ numRuns: 50 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("prompt appends immutably when an assistant is pending", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(word, (body) => {
|
|
||||||
const old = active()
|
|
||||||
const next = SessionEntry.step(old, SessionEvent.Prompt.create({ text: body, timestamp: time(1) }))
|
|
||||||
expect(old).not.toBe(next)
|
|
||||||
expect(old.pending).toHaveLength(0)
|
|
||||||
expect(next.pending).toHaveLength(1)
|
|
||||||
}),
|
|
||||||
{ numRuns: 50 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("step.started creates an assistant consumed by follow-up events", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(texts, (parts) => {
|
|
||||||
const next = run([
|
|
||||||
SessionEvent.Step.Started.create({
|
|
||||||
model: {
|
|
||||||
id: "model",
|
|
||||||
providerID: "provider",
|
|
||||||
},
|
|
||||||
timestamp: time(1),
|
|
||||||
}),
|
|
||||||
SessionEvent.Text.Started.create({ timestamp: time(2) }),
|
|
||||||
...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
|
|
||||||
SessionEvent.Step.Ended.create({
|
|
||||||
reason: "stop",
|
|
||||||
cost: 1,
|
|
||||||
tokens: {
|
|
||||||
input: 1,
|
|
||||||
output: 2,
|
|
||||||
reasoning: 3,
|
|
||||||
cache: {
|
|
||||||
read: 4,
|
|
||||||
write: 5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
timestamp: time(parts.length + 3),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
const entry = last(next)
|
|
||||||
|
|
||||||
expect(entry).toBeDefined()
|
|
||||||
if (!entry) return
|
|
||||||
|
|
||||||
expect(entry.content).toEqual([
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: parts.join(""),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(entry.time.completed).toEqual(time(parts.length + 3))
|
|
||||||
}),
|
|
||||||
{ numRuns: 100 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("replays prompt -> step -> text -> step.ended", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(word, texts, (body, parts) => {
|
|
||||||
const next = run([
|
|
||||||
SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
|
|
||||||
SessionEvent.Step.Started.create({
|
|
||||||
model: {
|
|
||||||
id: "model",
|
|
||||||
providerID: "provider",
|
|
||||||
},
|
|
||||||
timestamp: time(1),
|
|
||||||
}),
|
|
||||||
SessionEvent.Text.Started.create({ timestamp: time(2) }),
|
|
||||||
...parts.map((x, i) => SessionEvent.Text.Delta.create({ delta: x, timestamp: time(i + 3) })),
|
|
||||||
SessionEvent.Step.Ended.create({
|
|
||||||
reason: "stop",
|
|
||||||
cost: 1,
|
|
||||||
tokens: {
|
|
||||||
input: 1,
|
|
||||||
output: 2,
|
|
||||||
reasoning: 3,
|
|
||||||
cache: {
|
|
||||||
read: 4,
|
|
||||||
write: 5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
timestamp: time(parts.length + 3),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(next.entries).toHaveLength(2)
|
|
||||||
expect(next.entries[0]?.type).toBe("user")
|
|
||||||
expect(next.entries[1]?.type).toBe("assistant")
|
|
||||||
if (next.entries[1]?.type !== "assistant") return
|
|
||||||
|
|
||||||
expect(next.entries[1].content).toEqual([
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: parts.join(""),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(next.entries[1].time.completed).toEqual(time(parts.length + 3))
|
|
||||||
}),
|
|
||||||
{ numRuns: 50 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("replays prompt -> step -> reasoning -> tool -> success -> step.ended", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(
|
|
||||||
word,
|
|
||||||
texts,
|
|
||||||
text,
|
|
||||||
dict,
|
|
||||||
word,
|
|
||||||
maybe(text),
|
|
||||||
maybe(dict),
|
|
||||||
maybe(files),
|
|
||||||
(body, reason, end, input, title, output, metadata, attachments) => {
|
|
||||||
const callID = "call"
|
|
||||||
const next = run([
|
|
||||||
SessionEvent.Prompt.create({ text: body, timestamp: time(0) }),
|
|
||||||
SessionEvent.Step.Started.create({
|
|
||||||
model: {
|
|
||||||
id: "model",
|
|
||||||
providerID: "provider",
|
|
||||||
},
|
|
||||||
timestamp: time(1),
|
|
||||||
}),
|
|
||||||
SessionEvent.Reasoning.Started.create({ timestamp: time(2) }),
|
|
||||||
...reason.map((x, i) => SessionEvent.Reasoning.Delta.create({ delta: x, timestamp: time(i + 3) })),
|
|
||||||
SessionEvent.Reasoning.Ended.create({ text: end, timestamp: time(reason.length + 3) }),
|
|
||||||
SessionEvent.Tool.Input.Started.create({ callID, name: "bash", timestamp: time(reason.length + 4) }),
|
|
||||||
SessionEvent.Tool.Called.create({
|
|
||||||
callID,
|
|
||||||
tool: "bash",
|
|
||||||
input,
|
|
||||||
provider: { executed: true },
|
|
||||||
timestamp: time(reason.length + 5),
|
|
||||||
}),
|
|
||||||
SessionEvent.Tool.Success.create({
|
|
||||||
callID,
|
|
||||||
title,
|
|
||||||
output,
|
|
||||||
metadata,
|
|
||||||
attachments,
|
|
||||||
provider: { executed: true },
|
|
||||||
timestamp: time(reason.length + 6),
|
|
||||||
}),
|
|
||||||
SessionEvent.Step.Ended.create({
|
|
||||||
reason: "stop",
|
|
||||||
cost: 1,
|
|
||||||
tokens: {
|
|
||||||
input: 1,
|
|
||||||
output: 2,
|
|
||||||
reasoning: 3,
|
|
||||||
cache: {
|
|
||||||
read: 4,
|
|
||||||
write: 5,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
timestamp: time(reason.length + 7),
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(next.entries.at(-1)?.type).toBe("assistant")
|
|
||||||
const entry = next.entries.at(-1)
|
|
||||||
if (entry?.type !== "assistant") return
|
|
||||||
|
|
||||||
expect(entry.content).toHaveLength(2)
|
|
||||||
expect(entry.content[0]).toEqual({
|
|
||||||
type: "reasoning",
|
|
||||||
text: end,
|
|
||||||
})
|
|
||||||
expect(entry.content[1]?.type).toBe("tool")
|
|
||||||
if (entry.content[1]?.type !== "tool") return
|
|
||||||
expect(entry.content[1].state.status).toBe("completed")
|
|
||||||
expect(entry.time.completed).toEqual(time(reason.length + 7))
|
|
||||||
},
|
|
||||||
),
|
|
||||||
{ numRuns: 50 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("starting a new step completes the old assistant and appends a new active assistant", () => {
|
|
||||||
const next = run(
|
|
||||||
[
|
|
||||||
SessionEvent.Step.Started.create({
|
|
||||||
model: {
|
|
||||||
id: "model",
|
|
||||||
providerID: "provider",
|
|
||||||
},
|
|
||||||
timestamp: time(1),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
active(),
|
|
||||||
)
|
|
||||||
expect(next.entries).toHaveLength(2)
|
|
||||||
expect(next.entries[0]?.type).toBe("assistant")
|
|
||||||
expect(next.entries[1]?.type).toBe("assistant")
|
|
||||||
if (next.entries[0]?.type !== "assistant" || next.entries[1]?.type !== "assistant") return
|
|
||||||
|
|
||||||
expect(next.entries[0].time.completed).toEqual(time(1))
|
|
||||||
expect(next.entries[1].time.created).toEqual(time(1))
|
|
||||||
expect(next.entries[1].time.completed).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles sequential tools independently", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(dict, dict, word, word, (a, b, title, error) => {
|
|
||||||
const next = run(
|
|
||||||
[
|
|
||||||
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
|
|
||||||
SessionEvent.Tool.Called.create({
|
|
||||||
callID: "a",
|
|
||||||
tool: "bash",
|
|
||||||
input: a,
|
|
||||||
provider: { executed: true },
|
|
||||||
timestamp: time(2),
|
|
||||||
}),
|
|
||||||
SessionEvent.Tool.Success.create({
|
|
||||||
callID: "a",
|
|
||||||
title,
|
|
||||||
output: "done",
|
|
||||||
provider: { executed: true },
|
|
||||||
timestamp: time(3),
|
|
||||||
}),
|
|
||||||
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(4) }),
|
|
||||||
SessionEvent.Tool.Called.create({
|
|
||||||
callID: "b",
|
|
||||||
tool: "bash",
|
|
||||||
input: b,
|
|
||||||
provider: { executed: true },
|
|
||||||
timestamp: time(5),
|
|
||||||
}),
|
|
||||||
SessionEvent.Tool.Error.create({
|
|
||||||
callID: "b",
|
|
||||||
error,
|
|
||||||
provider: { executed: true },
|
|
||||||
timestamp: time(6),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
active(),
|
|
||||||
)
|
|
||||||
|
|
||||||
const first = tool(next, "a")
|
|
||||||
const second = tool(next, "b")
|
|
||||||
|
|
||||||
expect(first?.state.status).toBe("completed")
|
|
||||||
if (first?.state.status !== "completed") return
|
|
||||||
expect(first.state.input).toEqual(a)
|
|
||||||
expect(first.state.output).toBe("done")
|
|
||||||
expect(first.state.title).toBe(title)
|
|
||||||
|
|
||||||
expect(second?.state.status).toBe("error")
|
|
||||||
if (second?.state.status !== "error") return
|
|
||||||
expect(second.state.input).toEqual(b)
|
|
||||||
expect(second.state.error).toBe(error)
|
|
||||||
}),
|
|
||||||
{ numRuns: 50 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("routes tool events by callID when tool streams interleave", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(dict, dict, word, word, text, text, (a, b, titleA, titleB, deltaA, deltaB) => {
|
|
||||||
const next = run(
|
|
||||||
[
|
|
||||||
SessionEvent.Tool.Input.Started.create({ callID: "a", name: "bash", timestamp: time(1) }),
|
|
||||||
SessionEvent.Tool.Input.Started.create({ callID: "b", name: "grep", timestamp: time(2) }),
|
|
||||||
SessionEvent.Tool.Input.Delta.create({ callID: "a", delta: deltaA, timestamp: time(3) }),
|
|
||||||
SessionEvent.Tool.Input.Delta.create({ callID: "b", delta: deltaB, timestamp: time(4) }),
|
|
||||||
SessionEvent.Tool.Called.create({
|
|
||||||
callID: "a",
|
|
||||||
tool: "bash",
|
|
||||||
input: a,
|
|
||||||
provider: { executed: true },
|
|
||||||
timestamp: time(5),
|
|
||||||
}),
|
|
||||||
SessionEvent.Tool.Called.create({
|
|
||||||
callID: "b",
|
|
||||||
tool: "grep",
|
|
||||||
input: b,
|
|
||||||
provider: { executed: true },
|
|
||||||
timestamp: time(6),
|
|
||||||
}),
|
|
||||||
SessionEvent.Tool.Success.create({
|
|
||||||
callID: "a",
|
|
||||||
title: titleA,
|
|
||||||
output: "done-a",
|
|
||||||
provider: { executed: true },
|
|
||||||
timestamp: time(7),
|
|
||||||
}),
|
|
||||||
SessionEvent.Tool.Success.create({
|
|
||||||
callID: "b",
|
|
||||||
title: titleB,
|
|
||||||
output: "done-b",
|
|
||||||
provider: { executed: true },
|
|
||||||
timestamp: time(8),
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
active(),
|
|
||||||
)
|
|
||||||
|
|
||||||
const first = tool(next, "a")
|
|
||||||
const second = tool(next, "b")
|
|
||||||
|
|
||||||
expect(first?.state.status).toBe("completed")
|
|
||||||
expect(second?.state.status).toBe("completed")
|
|
||||||
if (first?.state.status !== "completed" || second?.state.status !== "completed") return
|
|
||||||
|
|
||||||
expect(first.state.input).toEqual(a)
|
|
||||||
expect(second.state.input).toEqual(b)
|
|
||||||
expect(first.state.title).toBe(titleA)
|
|
||||||
expect(second.state.title).toBe(titleB)
|
|
||||||
}),
|
|
||||||
{ numRuns: 50 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("records synthetic events", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(word, (body) => {
|
|
||||||
const next = SessionEntry.step(history(), SessionEvent.Synthetic.create({ text: body, timestamp: time(1) }))
|
|
||||||
expect(next.entries).toHaveLength(1)
|
|
||||||
expect(next.entries[0]?.type).toBe("synthetic")
|
|
||||||
if (next.entries[0]?.type !== "synthetic") return
|
|
||||||
expect(next.entries[0].text).toBe(body)
|
|
||||||
}),
|
|
||||||
{ numRuns: 50 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
test("records compaction events", () => {
|
|
||||||
FastCheck.assert(
|
|
||||||
FastCheck.property(FastCheck.boolean(), maybe(FastCheck.boolean()), (auto, overflow) => {
|
|
||||||
const next = SessionEntry.step(
|
|
||||||
history(),
|
|
||||||
SessionEvent.Compacted.create({ auto, overflow, timestamp: time(1) }),
|
|
||||||
)
|
|
||||||
expect(next.entries).toHaveLength(1)
|
|
||||||
expect(next.entries[0]?.type).toBe("compaction")
|
|
||||||
if (next.entries[0]?.type !== "compaction") return
|
|
||||||
expect(next.entries[0].auto).toBe(auto)
|
|
||||||
expect(next.entries[0].overflow).toBe(overflow)
|
|
||||||
}),
|
|
||||||
{ numRuns: 50 },
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -394,6 +394,19 @@ describe("tool.read truncation", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("detects attachment media from file contents", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const dir = yield* tmpdirScoped()
|
||||||
|
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01])
|
||||||
|
yield* put(path.join(dir, "image.bin"), jpeg)
|
||||||
|
|
||||||
|
const result = yield* exec(dir, { filePath: path.join(dir, "image.bin") })
|
||||||
|
expect(result.output).toBe("Image read successfully")
|
||||||
|
expect(result.attachments?.[0].mime).toBe("image/jpeg")
|
||||||
|
expect(result.attachments?.[0].url.startsWith("data:image/jpeg;base64,")).toBe(true)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("large image files are properly attached without error", () =>
|
it.live("large image files are properly attached without error", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const result = yield* exec(FIXTURES_DIR, { filePath: path.join(FIXTURES_DIR, "large-image.png") })
|
const result = yield* exec(FIXTURES_DIR, { filePath: path.join(FIXTURES_DIR, "large-image.png") })
|
||||||
|
|||||||
@@ -31,102 +31,6 @@ const node = CrossSpawnSpawner.defaultLayer
|
|||||||
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
|
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
|
||||||
|
|
||||||
describe("tool.skill", () => {
|
describe("tool.skill", () => {
|
||||||
it.live("description lists skill location URL", () =>
|
|
||||||
provideTmpdirInstance(
|
|
||||||
(dir) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const skill = path.join(dir, ".opencode", "skill", "tool-skill")
|
|
||||||
yield* Effect.promise(() =>
|
|
||||||
Bun.write(
|
|
||||||
path.join(skill, "SKILL.md"),
|
|
||||||
`---
|
|
||||||
name: tool-skill
|
|
||||||
description: Skill for tool tests.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Tool Skill
|
|
||||||
`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const home = process.env.OPENCODE_TEST_HOME
|
|
||||||
process.env.OPENCODE_TEST_HOME = dir
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
process.env.OPENCODE_TEST_HOME = home
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const registry = yield* ToolRegistry.Service
|
|
||||||
const desc =
|
|
||||||
(yield* registry.tools({
|
|
||||||
providerID: "opencode" as any,
|
|
||||||
modelID: "gpt-5" as any,
|
|
||||||
agent: { name: "build", mode: "primary", permission: [], options: {} },
|
|
||||||
})).find((tool) => tool.id === SkillTool.id)?.description ?? ""
|
|
||||||
expect(desc).toContain("**tool-skill**: Skill for tool tests.")
|
|
||||||
}),
|
|
||||||
{ git: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("description sorts skills by name and is stable across calls", () =>
|
|
||||||
provideTmpdirInstance(
|
|
||||||
(dir) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
for (const [name, description] of [
|
|
||||||
["zeta-skill", "Zeta skill."],
|
|
||||||
["alpha-skill", "Alpha skill."],
|
|
||||||
["middle-skill", "Middle skill."],
|
|
||||||
]) {
|
|
||||||
const skill = path.join(dir, ".opencode", "skill", name)
|
|
||||||
yield* Effect.promise(() =>
|
|
||||||
Bun.write(
|
|
||||||
path.join(skill, "SKILL.md"),
|
|
||||||
`---
|
|
||||||
name: ${name}
|
|
||||||
description: ${description}
|
|
||||||
---
|
|
||||||
|
|
||||||
# ${name}
|
|
||||||
`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
const home = process.env.OPENCODE_TEST_HOME
|
|
||||||
process.env.OPENCODE_TEST_HOME = dir
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.sync(() => {
|
|
||||||
process.env.OPENCODE_TEST_HOME = home
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
|
|
||||||
const registry = yield* ToolRegistry.Service
|
|
||||||
const load = Effect.fnUntraced(function* () {
|
|
||||||
return (
|
|
||||||
(yield* registry.tools({
|
|
||||||
providerID: "opencode" as any,
|
|
||||||
modelID: "gpt-5" as any,
|
|
||||||
agent,
|
|
||||||
})).find((tool) => tool.id === SkillTool.id)?.description ?? ""
|
|
||||||
)
|
|
||||||
})
|
|
||||||
const first = yield* load()
|
|
||||||
const second = yield* load()
|
|
||||||
|
|
||||||
expect(first).toBe(second)
|
|
||||||
|
|
||||||
const alpha = first.indexOf("**alpha-skill**: Alpha skill.")
|
|
||||||
const middle = first.indexOf("**middle-skill**: Middle skill.")
|
|
||||||
const zeta = first.indexOf("**zeta-skill**: Zeta skill.")
|
|
||||||
|
|
||||||
expect(alpha).toBeGreaterThan(-1)
|
|
||||||
expect(middle).toBeGreaterThan(alpha)
|
|
||||||
expect(zeta).toBeGreaterThan(middle)
|
|
||||||
}),
|
|
||||||
{ git: true },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.live("execute returns skill content block with files", () =>
|
it.live("execute returns skill content block with files", () =>
|
||||||
provideTmpdirInstance(
|
provideTmpdirInstance(
|
||||||
(dir) =>
|
(dir) =>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,76 +1,38 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { Script } from "@opencode-ai/script"
|
import { Script } from "@opencode-ai/script"
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { Effect } from "effect"
|
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||||
process.chdir(dir)
|
process.chdir(dir)
|
||||||
|
|
||||||
type PackageJson = {
|
async function published(name: string, version: string) {
|
||||||
|
return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
await $`bun tsc`
|
||||||
|
const originalText = await Bun.file("package.json").text()
|
||||||
|
const pkg = JSON.parse(originalText) as {
|
||||||
name: string
|
name: string
|
||||||
version: string
|
version: string
|
||||||
exports: Record<string, string>
|
exports: Record<string, string>
|
||||||
}
|
}
|
||||||
|
if (await published(pkg.name, pkg.version)) {
|
||||||
const packageJson = (value: unknown) => {
|
console.log(`already published ${pkg.name}@${pkg.version}`)
|
||||||
if (
|
} else {
|
||||||
typeof value === "object" &&
|
for (const [key, value] of Object.entries(pkg.exports)) {
|
||||||
value !== null &&
|
const file = value.replace("./src/", "./dist/").replace(".ts", "")
|
||||||
"name" in value &&
|
// @ts-ignore
|
||||||
typeof value.name === "string" &&
|
pkg.exports[key] = {
|
||||||
"version" in value &&
|
import: file + ".js",
|
||||||
typeof value.version === "string" &&
|
types: file + ".d.ts",
|
||||||
"exports" in value &&
|
|
||||||
typeof value.exports === "object" &&
|
|
||||||
value.exports !== null
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
name: value.name,
|
|
||||||
version: value.version,
|
|
||||||
exports: Object.fromEntries(
|
|
||||||
Object.entries(value.exports).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error("invalid plugin package manifest")
|
await Bun.write("package.json", JSON.stringify(pkg, null, 2))
|
||||||
|
try {
|
||||||
|
await $`bun pm pack`
|
||||||
|
await $`npm publish *.tgz --tag ${Script.channel} --access public`
|
||||||
|
} finally {
|
||||||
|
await Bun.write("package.json", originalText)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const published = (name: string, version: string) =>
|
|
||||||
Effect.promise(() => $`npm view ${name}@${version} version`.nothrow()).pipe(
|
|
||||||
Effect.map((result) => result.exitCode === 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
const withPackageJson = (
|
|
||||||
pkg: PackageJson,
|
|
||||||
next: { name: string; version: string; exports: Record<string, { import: string; types: string }> },
|
|
||||||
) =>
|
|
||||||
Effect.promise(() => Bun.write("package.json", JSON.stringify(next, null, 2))).pipe(
|
|
||||||
Effect.zipRight(Effect.promise(() => $`bun pm pack && npm publish *.tgz --tag ${Script.channel} --access public`)),
|
|
||||||
Effect.ensuring(Effect.promise(() => Bun.write("package.json", JSON.stringify(pkg, null, 2)))),
|
|
||||||
)
|
|
||||||
|
|
||||||
const program = Effect.gen(function* () {
|
|
||||||
yield* Effect.promise(() => $`bun tsc`)
|
|
||||||
|
|
||||||
const pkg = packageJson(yield* Effect.promise(() => import("../package.json").then((m) => m.default)))
|
|
||||||
if (yield* published(pkg.name, pkg.version)) {
|
|
||||||
console.log(`already published ${pkg.name}@${pkg.version}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = {
|
|
||||||
...pkg,
|
|
||||||
exports: Object.fromEntries(
|
|
||||||
Object.entries(pkg.exports).map(([key, value]) => {
|
|
||||||
const file = value.replace("./src/", "./dist/").replace(".ts", "")
|
|
||||||
return [key, { import: file + ".js", types: file + ".d.ts" }]
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* withPackageJson(pkg, next)
|
|
||||||
})
|
|
||||||
|
|
||||||
await Effect.runPromise(program)
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -2,45 +2,21 @@
|
|||||||
|
|
||||||
import { Script } from "@opencode-ai/script"
|
import { Script } from "@opencode-ai/script"
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { Effect } from "effect"
|
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||||
process.chdir(dir)
|
process.chdir(dir)
|
||||||
|
|
||||||
const packageJson = (value: unknown) => {
|
async function published(name: string, version: string) {
|
||||||
if (
|
return (await $`npm view ${name}@${version} version`.nothrow()).exitCode === 0
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
"name" in value &&
|
|
||||||
typeof value.name === "string" &&
|
|
||||||
"version" in value &&
|
|
||||||
typeof value.version === "string" &&
|
|
||||||
"exports" in value &&
|
|
||||||
typeof value.exports === "object" &&
|
|
||||||
value.exports !== null
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
name: value.name,
|
|
||||||
version: value.version,
|
|
||||||
exports: value.exports,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Error("invalid sdk package manifest")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const published = (name: string, version: string) =>
|
const originalText = await Bun.file("package.json").text()
|
||||||
Effect.promise(() => $`npm view ${name}@${version} version`.nothrow()).pipe(
|
const pkg = JSON.parse(originalText) as {
|
||||||
Effect.map((result) => result.exitCode === 0),
|
name: string
|
||||||
)
|
version: string
|
||||||
|
exports: Record<string, unknown>
|
||||||
const withPackageJson = (pkg: ReturnType<typeof packageJson>, next: ReturnType<typeof packageJson>) =>
|
}
|
||||||
Effect.promise(() => Bun.write("package.json", JSON.stringify(next, null, 2))).pipe(
|
|
||||||
Effect.zipRight(Effect.promise(() => $`bun pm pack`)),
|
|
||||||
Effect.zipRight(Effect.promise(() => $`npm publish *.tgz --tag ${Script.channel} --access public`)),
|
|
||||||
Effect.ensuring(Effect.promise(() => Bun.write("package.json", JSON.stringify(pkg, null, 2)))),
|
|
||||||
)
|
|
||||||
|
|
||||||
function transformExports(exports: Record<string, unknown>) {
|
function transformExports(exports: Record<string, unknown>) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(exports).map(([key, value]) => {
|
Object.entries(exports).map(([key, value]) => {
|
||||||
@@ -48,25 +24,22 @@ function transformExports(exports: Record<string, unknown>) {
|
|||||||
const file = value.replace("./src/", "./dist/").replace(".ts", "")
|
const file = value.replace("./src/", "./dist/").replace(".ts", "")
|
||||||
return [key, { import: file + ".js", types: file + ".d.ts" }]
|
return [key, { import: file + ".js", types: file + ".d.ts" }]
|
||||||
}
|
}
|
||||||
if (typeof value === "object" && value !== null && !Array.isArray(value)) return [key, transformExports(value)]
|
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||||
|
return [key, transformExports(value)]
|
||||||
|
}
|
||||||
return [key, value]
|
return [key, value]
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (await published(pkg.name, pkg.version)) {
|
||||||
const program = Effect.gen(function* () {
|
console.log(`already published ${pkg.name}@${pkg.version}`)
|
||||||
const pkg = packageJson(yield* Effect.promise(() => import("../package.json").then((m) => m.default)))
|
} else {
|
||||||
if (yield* published(pkg.name, pkg.version)) {
|
pkg.exports = transformExports(pkg.exports)
|
||||||
console.log(`already published ${pkg.name}@${pkg.version}`)
|
await Bun.write("package.json", JSON.stringify(pkg, null, 2))
|
||||||
return
|
try {
|
||||||
|
await $`bun pm pack`
|
||||||
|
await $`npm publish *.tgz --tag ${Script.channel} --access public`
|
||||||
|
} finally {
|
||||||
|
await Bun.write("package.json", originalText)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
const next = {
|
|
||||||
...pkg,
|
|
||||||
exports: transformExports(pkg.exports),
|
|
||||||
}
|
|
||||||
|
|
||||||
yield* withPackageJson(pkg, next)
|
|
||||||
})
|
|
||||||
|
|
||||||
await Effect.runPromise(program)
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"name": "@opencode-ai/shared",
|
"name": "@opencode-ai/shared",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1269,7 +1269,7 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
|
|||||||
<Accordion
|
<Accordion
|
||||||
multiple
|
multiple
|
||||||
data-scope="apply-patch"
|
data-scope="apply-patch"
|
||||||
style={{ "--sticky-accordion-offset": "40px" }}
|
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
|
||||||
defaultValue={[value()]}
|
defaultValue={[value()]}
|
||||||
>
|
>
|
||||||
<Accordion.Item value={value()}>
|
<Accordion.Item value={value()}>
|
||||||
@@ -2061,7 +2061,7 @@ ToolRegistry.register({
|
|||||||
<Accordion
|
<Accordion
|
||||||
multiple
|
multiple
|
||||||
data-scope="apply-patch"
|
data-scope="apply-patch"
|
||||||
style={{ "--sticky-accordion-offset": "40px" }}
|
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
|
||||||
value={expanded()}
|
value={expanded()}
|
||||||
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||||
|
|||||||
+79
-63
@@ -2,94 +2,110 @@
|
|||||||
|
|
||||||
import { Script } from "@opencode-ai/script"
|
import { Script } from "@opencode-ai/script"
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { Effect } from "effect"
|
|
||||||
import { fileURLToPath } from "url"
|
import { fileURLToPath } from "url"
|
||||||
|
|
||||||
console.log("=== publishing ===\n")
|
console.log("=== publishing ===\n")
|
||||||
|
|
||||||
const tag = `v${Script.version}`
|
const tag = `v${Script.version}`
|
||||||
|
|
||||||
const pkgjsons = await Array.fromAsync(
|
const pkgjsons = await Array.fromAsync(
|
||||||
new Bun.Glob("**/package.json").scan({
|
new Bun.Glob("**/package.json").scan({
|
||||||
absolute: true,
|
absolute: true,
|
||||||
}),
|
}),
|
||||||
).then((arr) => arr.filter((x) => !x.includes("node_modules") && !x.includes("dist")))
|
).then((arr) => arr.filter((x) => !x.includes("node_modules") && !x.includes("dist")))
|
||||||
|
|
||||||
const extensionToml = fileURLToPath(new URL("../packages/extensions/zed/extension.toml", import.meta.url))
|
const extensionToml = fileURLToPath(new URL("../packages/extensions/zed/extension.toml", import.meta.url))
|
||||||
|
const publishPackageJsons = ["packages/plugin/package.json", "packages/sdk/js/package.json"]
|
||||||
|
|
||||||
const readText = (path: string) => Effect.promise(() => Bun.file(path).text())
|
async function hasChanges() {
|
||||||
const writeText = (path: string, value: string) => Effect.promise(() => Bun.write(path, value))
|
return (await $`git diff --quiet && git diff --cached --quiet`.nothrow()).exitCode !== 0
|
||||||
const shell = <A>(run: () => Promise<A>) => Effect.promise(run)
|
}
|
||||||
const log = (message: string) => Effect.sync(() => console.log(message))
|
|
||||||
|
|
||||||
const hasChanges = shell(() => $`git diff --quiet && git diff --cached --quiet`.nothrow()).pipe(
|
async function hasPublishPackageJsonChanges() {
|
||||||
Effect.map((result) => result.exitCode !== 0),
|
if ((await $`git diff --quiet -- ${publishPackageJsons}`.nothrow()).exitCode !== 0) return true
|
||||||
)
|
return (await $`git diff --cached --quiet -- ${publishPackageJsons}`.nothrow()).exitCode !== 0
|
||||||
|
}
|
||||||
|
|
||||||
const releaseTagExists = shell(() => $`git rev-parse -q --verify refs/tags/${tag}`.nothrow()).pipe(
|
async function logPublishPackageJsonChanges() {
|
||||||
Effect.map((result) => result.exitCode === 0),
|
await $`git status --short -- ${publishPackageJsons}`
|
||||||
)
|
await $`git diff -- ${publishPackageJsons}`
|
||||||
|
await $`git diff --cached -- ${publishPackageJsons}`
|
||||||
|
}
|
||||||
|
|
||||||
const prepareReleaseFiles = Effect.gen(function* () {
|
async function releaseTagExists() {
|
||||||
yield* Effect.forEach(pkgjsons, (file) =>
|
return (await $`git rev-parse -q --verify refs/tags/${tag}`.nothrow()).exitCode === 0
|
||||||
Effect.gen(function* () {
|
}
|
||||||
const next = (yield* readText(file)).replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`)
|
|
||||||
yield* log(`updated: ${file}`)
|
|
||||||
yield* writeText(file, next)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const nextToml = (yield* readText(extensionToml))
|
async function prepareReleaseFiles() {
|
||||||
.replace(/^version = "[^"]+"/m, `version = "${Script.version}"`)
|
for (const file of pkgjsons) {
|
||||||
.replaceAll(/releases\/download\/v[^/]+\//g, `releases/download/v${Script.version}/`)
|
let pkg = await Bun.file(file).text()
|
||||||
yield* log(`updated: ${extensionToml}`)
|
pkg = pkg.replaceAll(/"version": "[^"]+"/g, `"version": "${Script.version}"`)
|
||||||
yield* writeText(extensionToml, nextToml)
|
console.log("updated:", file)
|
||||||
yield* shell(() => $`bun install`)
|
await Bun.file(file).write(pkg)
|
||||||
yield* shell(() => $`./packages/sdk/js/script/build.ts`)
|
|
||||||
})
|
|
||||||
|
|
||||||
const program = Effect.gen(function* () {
|
|
||||||
if (Script.release && !Script.preview) {
|
|
||||||
yield* shell(() => $`git fetch origin --tags`)
|
|
||||||
yield* shell(() => $`git switch --detach`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* prepareReleaseFiles
|
let toml = await Bun.file(extensionToml).text()
|
||||||
|
toml = toml.replace(/^version = "[^"]+"/m, `version = "${Script.version}"`)
|
||||||
|
toml = toml.replaceAll(/releases\/download\/v[^/]+\//g, `releases/download/v${Script.version}/`)
|
||||||
|
console.log("updated:", extensionToml)
|
||||||
|
await Bun.file(extensionToml).write(toml)
|
||||||
|
|
||||||
if (Script.release && !Script.preview) {
|
await $`bun install`
|
||||||
if (yield* releaseTagExists) yield* log(`release tag ${tag} already exists, skipping tag creation`)
|
await $`./packages/sdk/js/script/build.ts`
|
||||||
else {
|
}
|
||||||
yield* shell(() => $`git commit -am "release: ${tag}"`)
|
|
||||||
yield* shell(() => $`git tag ${tag}`)
|
if (Script.release && !Script.preview) {
|
||||||
yield* shell(() => $`git push origin refs/tags/${tag} --no-verify`)
|
await $`git fetch origin --tags`
|
||||||
yield* shell(() => new Promise((resolve) => setTimeout(resolve, 5_000)))
|
await $`git switch --detach`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await prepareReleaseFiles()
|
||||||
|
|
||||||
|
if (Script.release && !Script.preview) {
|
||||||
|
if (await releaseTagExists()) {
|
||||||
|
console.log(`release tag ${tag} already exists, skipping tag creation`)
|
||||||
|
} else {
|
||||||
|
await $`git commit -am "release: ${tag}"`
|
||||||
|
await $`git tag ${tag}`
|
||||||
|
await $`git push origin refs/tags/${tag} --no-verify`
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5_000))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
yield* log("\n=== cli ===\n")
|
console.log("\n=== cli ===\n")
|
||||||
yield* shell(() => import(`../packages/opencode/script/publish.ts`))
|
await import(`../packages/opencode/script/publish.ts`)
|
||||||
yield* log("\n=== sdk ===\n")
|
|
||||||
yield* shell(() => import(`../packages/sdk/js/script/publish.ts`))
|
|
||||||
yield* log("\n=== plugin ===\n")
|
|
||||||
yield* shell(() => import(`../packages/plugin/script/publish.ts`))
|
|
||||||
|
|
||||||
if (Script.release) {
|
console.log("\n=== sdk ===\n")
|
||||||
yield* shell(() => import(`../packages/desktop/scripts/finalize-latest-json.ts`))
|
await import(`../packages/sdk/js/script/publish.ts`)
|
||||||
yield* shell(() => import(`../packages/desktop-electron/scripts/finalize-latest-yml.ts`))
|
|
||||||
|
console.log("\n=== plugin ===\n")
|
||||||
|
await import(`../packages/plugin/script/publish.ts`)
|
||||||
|
|
||||||
|
if (Script.release) {
|
||||||
|
await import(`../packages/desktop/scripts/finalize-latest-json.ts`)
|
||||||
|
await import(`../packages/desktop-electron/scripts/finalize-latest-yml.ts`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Script.release && !Script.preview) {
|
||||||
|
await $`git fetch origin`
|
||||||
|
if (await hasPublishPackageJsonChanges()) {
|
||||||
|
console.error("publish scripts left package.json changes before syncing dev")
|
||||||
|
await logPublishPackageJsonChanges()
|
||||||
|
throw new Error("packages/plugin/package.json or packages/sdk/js/package.json changed during publish")
|
||||||
}
|
}
|
||||||
|
await $`git checkout -B dev origin/dev`
|
||||||
if (Script.release && !Script.preview) {
|
await prepareReleaseFiles()
|
||||||
yield* shell(() => $`git fetch origin`)
|
if (await hasChanges()) {
|
||||||
yield* shell(() => $`git checkout -B dev origin/dev`)
|
await $`git commit -am "sync release versions for v${Script.version}"`
|
||||||
yield* prepareReleaseFiles
|
await $`git push origin HEAD:dev --no-verify`
|
||||||
if (yield* hasChanges) {
|
} else {
|
||||||
yield* shell(() => $`git commit -am "sync release versions for v${Script.version}"`)
|
console.log(`dev already synced for ${tag}`)
|
||||||
yield* shell(() => $`git push origin HEAD:dev --no-verify`)
|
|
||||||
} else yield* log(`dev already synced for ${tag}`)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (Script.release) yield* shell(() => $`gh release edit ${tag} --draft=false --repo ${process.env.GH_REPO}`)
|
if (Script.release) {
|
||||||
})
|
await $`gh release edit ${tag} --draft=false --repo ${process.env.GH_REPO}`
|
||||||
|
}
|
||||||
await Effect.runPromise(program)
|
|
||||||
|
|
||||||
const dir = fileURLToPath(new URL("..", import.meta.url))
|
const dir = fileURLToPath(new URL("..", import.meta.url))
|
||||||
process.chdir(dir)
|
process.chdir(dir)
|
||||||
|
|||||||
+25
-45
@@ -2,55 +2,35 @@
|
|||||||
|
|
||||||
import { Script } from "@opencode-ai/script"
|
import { Script } from "@opencode-ai/script"
|
||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { Effect } from "effect"
|
|
||||||
|
|
||||||
const tag = `v${Script.version}`
|
const output = [`version=${Script.version}`]
|
||||||
const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim()
|
const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim()
|
||||||
const betaPreview = Script.preview && Script.channel === "beta"
|
|
||||||
|
|
||||||
const changelog = Effect.promise(() => $`bun script/changelog.ts --to ${sha}`.cwd(process.cwd()))
|
if (!Script.preview) {
|
||||||
const readNotes = Effect.promise(() => Bun.file(`${process.cwd()}/UPCOMING_CHANGELOG.md`).text()).pipe(
|
await $`bun script/changelog.ts --to ${sha}`.cwd(process.cwd())
|
||||||
Effect.catchAll(() => Effect.succeed("No notable changes")),
|
const file = `${process.cwd()}/UPCOMING_CHANGELOG.md`
|
||||||
)
|
const body = await Bun.file(file)
|
||||||
const writeOutput = (lines: ReadonlyArray<string>) =>
|
.text()
|
||||||
process.env.GITHUB_OUTPUT
|
.catch(() => "No notable changes")
|
||||||
? Effect.promise(() => Bun.write(process.env.GITHUB_OUTPUT!, lines.join("\n")))
|
const dir = process.env.RUNNER_TEMP ?? "/tmp"
|
||||||
: Effect.void
|
const notesFile = `${dir}/opencode-release-notes.txt`
|
||||||
|
await Bun.write(notesFile, body)
|
||||||
const createRelease = (notesFile?: string) => {
|
await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --notes-file ${notesFile}`
|
||||||
if (!notesFile && betaPreview) {
|
const release = await $`gh release view v${Script.version} --json tagName,databaseId`.json()
|
||||||
return Effect.promise(
|
output.push(`release=${release.databaseId}`)
|
||||||
() => $`gh release create ${tag} -d --target ${sha} --title ${tag} --repo ${process.env.GH_REPO}`,
|
output.push(`tag=${release.tagName}`)
|
||||||
)
|
} else if (Script.channel === "beta") {
|
||||||
}
|
await $`gh release create v${Script.version} -d --target ${sha} --title "v${Script.version}" --repo ${process.env.GH_REPO}`
|
||||||
if (notesFile)
|
const release =
|
||||||
return Effect.promise(() => $`gh release create ${tag} -d --target ${sha} --title ${tag} --notes-file ${notesFile}`)
|
await $`gh release view v${Script.version} --json tagName,databaseId --repo ${process.env.GH_REPO}`.json()
|
||||||
return Effect.void
|
output.push(`release=${release.databaseId}`)
|
||||||
|
output.push(`tag=${release.tagName}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewRelease = betaPreview
|
output.push(`repo=${process.env.GH_REPO}`)
|
||||||
? Effect.promise(() => $`gh release view ${tag} --json tagName,databaseId --repo ${process.env.GH_REPO}`.json())
|
|
||||||
: Effect.promise(() => $`gh release view ${tag} --json tagName,databaseId`.json())
|
|
||||||
|
|
||||||
const output = Effect.gen(function* () {
|
if (process.env.GITHUB_OUTPUT) {
|
||||||
const lines = [`version=${Script.version}`]
|
await Bun.write(process.env.GITHUB_OUTPUT, output.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
if (!Script.preview) {
|
process.exit(0)
|
||||||
yield* changelog
|
|
||||||
const body = yield* readNotes
|
|
||||||
const notesFile = `${process.env.RUNNER_TEMP ?? "/tmp"}/opencode-release-notes.txt`
|
|
||||||
yield* Effect.promise(() => Bun.write(notesFile, body))
|
|
||||||
yield* createRelease(notesFile)
|
|
||||||
const release = yield* viewRelease
|
|
||||||
lines.push(`release=${release.databaseId}`, `tag=${release.tagName}`)
|
|
||||||
} else if (Script.channel === "beta") {
|
|
||||||
yield* createRelease()
|
|
||||||
const release = yield* viewRelease
|
|
||||||
lines.push(`release=${release.databaseId}`, `tag=${release.tagName}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push(`repo=${process.env.GH_REPO}`)
|
|
||||||
yield* writeOutput(lines)
|
|
||||||
})
|
|
||||||
|
|
||||||
await Effect.runPromise(output)
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"displayName": "opencode",
|
"displayName": "opencode",
|
||||||
"description": "opencode for VS Code",
|
"description": "opencode for VS Code",
|
||||||
"version": "1.4.11",
|
"version": "1.4.14",
|
||||||
"publisher": "sst-dev",
|
"publisher": "sst-dev",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
Vendored
-4
@@ -168,10 +168,6 @@ declare module "sst" {
|
|||||||
"type": "sst.sst.Secret"
|
"type": "sst.sst.Secret"
|
||||||
"value": string
|
"value": string
|
||||||
}
|
}
|
||||||
"ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES": {
|
|
||||||
"type": "sst.sst.Secret"
|
|
||||||
"value": string
|
|
||||||
}
|
|
||||||
"ZEN_LITE_PRICE": {
|
"ZEN_LITE_PRICE": {
|
||||||
"firstMonth100Coupon": string
|
"firstMonth100Coupon": string
|
||||||
"firstMonth50Coupon": string
|
"firstMonth50Coupon": string
|
||||||
|
|||||||
Reference in New Issue
Block a user