mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-19 15:17:51 +00:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68a11d3652 | ||
|
|
7125f5f8b5 | ||
|
|
01a6ed8d97 | ||
|
|
004583d598 | ||
|
|
cbd911368e | ||
|
|
8bfb247854 | ||
|
|
dd71cdbd84 | ||
|
|
1c90e8f01c | ||
|
|
9843d13c29 | ||
|
|
871db0025e | ||
|
|
7b7a67080e | ||
|
|
3361953fe1 | ||
|
|
e9201ce933 | ||
|
|
b38d72e174 | ||
|
|
e1e2efe8ec | ||
|
|
c2b171d7c4 | ||
|
|
c3bf8864b9 | ||
|
|
3693f56374 | ||
|
|
971f4e558e | ||
|
|
dd95e3f2ed | ||
|
|
dfde4628e3 | ||
|
|
e755fce13d | ||
|
|
5b565e8923 | ||
|
|
ec8d95f1f2 | ||
|
|
ea52d18ec5 | ||
|
|
0f9504d6c5 | ||
|
|
f6353a48b5 | ||
|
|
4facc03371 | ||
|
|
485a027c1a | ||
|
|
988a66ca4f | ||
|
|
98c6b41573 | ||
|
|
4ee9bf0213 | ||
|
|
681338c533 | ||
|
|
5464aa4a74 | ||
|
|
36083c9520 | ||
|
|
168aba6183 | ||
|
|
ec19dc09c7 | ||
|
|
fca4701688 | ||
|
|
c720ca20ed | ||
|
|
dae1e8abbd | ||
|
|
ffe4d1522c | ||
|
|
b1860465cd | ||
|
|
cab11795e5 | ||
|
|
810e79a6d4 | ||
|
|
d81604714f | ||
|
|
d1e828c217 | ||
|
|
437cdc03a9 | ||
|
|
219ecada68 | ||
|
|
9e1fa80fb7 | ||
|
|
278db3023f | ||
|
|
c2067d59af | ||
|
|
f5d40d6f30 | ||
|
|
c8e654410d | ||
|
|
db80806651 | ||
|
|
47f66de8dd | ||
|
|
c076066c33 | ||
|
|
5c09d051e5 | ||
|
|
0a6111291e |
@@ -184,6 +184,7 @@ const table = sqliteTable("session", {
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep native compaction mechanisms out of `SessionCompaction`. Plugins register `native` strategies through the `SessionCompaction` editor that turn a prepared request into a replacement window (the built-in `NativeCompactionPlugin` handles `@opencode/ai` compaction operations); later registrations win. Core owns the provider-mode decision, route provenance, the retry policy, overflow recovery, interruption, usage accounting, and checkpoint persistence.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -87,13 +87,11 @@
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"uqr": "0.1.3",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@happy-dom/global-registrator": "20.0.11",
|
||||
@@ -101,7 +99,6 @@
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"diff": "catalog:",
|
||||
@@ -114,7 +111,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -178,7 +175,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -204,11 +201,10 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -218,7 +214,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -254,7 +250,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -281,7 +277,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -298,7 +294,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -322,7 +318,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -342,7 +338,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -410,14 +406,12 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
"electron-log": "^5",
|
||||
"electron-store": "11.0.2",
|
||||
"electron-updater": "6.8.9",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"lighthouse": "13.4.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -457,12 +451,11 @@
|
||||
"@lydell/node-pty-linux-x64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-win32-x64": "1.2.0-beta.12",
|
||||
"msgpackr-extract": "3.0.4",
|
||||
},
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -499,7 +492,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -515,7 +508,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -534,7 +527,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -547,7 +540,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -561,7 +554,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -576,7 +569,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -615,7 +608,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -645,7 +638,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -660,7 +653,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -684,7 +677,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -705,7 +698,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -727,7 +720,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -762,7 +755,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -782,7 +775,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -816,7 +809,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -835,7 +828,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -881,7 +874,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -895,7 +888,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -930,7 +923,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -965,7 +958,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -998,7 +991,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1039,7 +1032,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -3345,8 +3338,6 @@
|
||||
|
||||
"ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"ajv-i18n": ["ajv-i18n@4.2.0", "", { "peerDependencies": { "ajv": "^8.0.0-beta.0" } }, "sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg=="],
|
||||
|
||||
"am-i-vibing": ["am-i-vibing@0.4.0", "", { "dependencies": { "process-ancestry": "^0.1.0" }, "bin": { "am-i-vibing": "dist/cli.mjs" } }, "sha512-MxT4XZL7pzLHpuvhDKdMaQHMGGkJDLluKBLsbstn+8wv9sWcFT6h+0ve9qkml95amVTZtZV83gQe2hY+ojgHLg=="],
|
||||
@@ -3641,8 +3632,6 @@
|
||||
|
||||
"condense-newlines": ["condense-newlines@0.2.1", "", { "dependencies": { "extend-shallow": "^2.0.1", "is-whitespace": "^0.3.0", "kind-of": "^3.0.2" } }, "sha512-P7X+QL9Hb9B/c8HI5BFFKmjgBu2XpQuF98WZ9XkO+dBGgk5XgwiQz7o1SmpglNWId3581UcS0SFAWfoIhMHPfg=="],
|
||||
|
||||
"conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="],
|
||||
|
||||
"config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="],
|
||||
|
||||
"configstore": ["configstore@7.1.0", "", { "dependencies": { "atomically": "^2.0.3", "dot-prop": "^9.0.0", "graceful-fs": "^4.2.11", "xdg-basedir": "^5.1.0" } }, "sha512-N4oog6YJWbR9kGyXvS7jEykLDXIE2C0ILYqNBZBp9iwiJpoCBWYsuAdW6PPFn6w06jjnC+3JstVvWHO4cZqvRg=="],
|
||||
@@ -3781,8 +3770,6 @@
|
||||
|
||||
"db0": ["db0@0.3.4", "", { "peerDependencies": { "@electric-sql/pglite": "*", "@libsql/client": "*", "better-sqlite3": "*", "drizzle-orm": "*", "mysql2": "*", "sqlite3": "*" }, "optionalPeers": ["@electric-sql/pglite", "@libsql/client", "better-sqlite3", "drizzle-orm", "mysql2", "sqlite3"] }, "sha512-RiXXi4WaNzPTHEOu8UPQKMooIbqOEyqA1t7Z6MsdxSCeb8iUC9ko3LcmsLmeUt2SM5bctfArZKkRQggKZz7JNw=="],
|
||||
|
||||
"debounce-fn": ["debounce-fn@6.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||
@@ -3909,16 +3896,12 @@
|
||||
|
||||
"electron-publish": ["electron-publish@26.15.3", "", { "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" } }, "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q=="],
|
||||
|
||||
"electron-store": ["electron-store@11.0.2", "", { "dependencies": { "conf": "^15.0.2", "type-fest": "^5.0.1" } }, "sha512-4VkNRdN+BImL2KcCi41WvAYbh6zLX5AUTi4so68yPqiItjbgTjqpEnGAqasgnG+lB6GuAyUltKwVopp6Uv+gwQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.411", "", {}, "sha512-gglkxzokjHfawpGxq75XdBV2/l3BAPzrsMs70qgaZdTW5rpV1tC4MdgJVP9fN126bODA4ZJQkn1wryEzJyQXIg=="],
|
||||
|
||||
"electron-updater": ["electron-updater@6.8.9", "", { "dependencies": { "builder-util-runtime": "9.7.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", "lodash.escaperegexp": "^4.1.2", "lodash.isequal": "^4.5.0", "semver": "~7.7.3", "tiny-typed-emitter": "^2.1.0" } }, "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig=="],
|
||||
|
||||
"electron-vite": ["electron-vite@6.0.0-beta.1", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-arrow-functions": "^7.27.1", "cac": "^7.0.0", "esbuild": "^0.25.11", "magic-string": "^0.30.21", "picocolors": "^1.1.1" }, "peerDependencies": { "@swc/core": "^1.0.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@swc/core"], "bin": { "electron-vite": "bin/electron-vite.js" } }, "sha512-jltST77AwNxIeTTDtYhnIEA8ZM0RW9jmSJcqS8x/dQcgeeLlxlcJoYNNJ1tv7/Swor0AbXMYteBGdezoAOt+Nw=="],
|
||||
|
||||
"electron-window-state": ["electron-window-state@5.0.3", "", { "dependencies": { "jsonfile": "^4.0.0", "mkdirp": "^0.5.1" } }, "sha512-1mNTwCfkolXl3kMf50yW3vE2lZj0y92P/HYWFBrb+v2S/pCka5mdwN3cagKm458A7NjndSwijynXgcLWRodsVg=="],
|
||||
|
||||
"electron-winstaller": ["electron-winstaller@5.4.0", "", { "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", "fs-extra": "^7.0.1", "lodash": "^4.17.21", "temp": "^0.9.0" }, "optionalDependencies": { "@electron/windows-sign": "^1.1.2" } }, "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg=="],
|
||||
|
||||
"emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="],
|
||||
@@ -4463,8 +4446,6 @@
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"json-stringify-nice": ["json-stringify-nice@1.1.4", "", {}, "sha512-5Z5RFW63yxReJ7vANgW6eZFGWaQvnPE3WNmZoOJrSkGju2etKA2L5rrOa1sm877TVTFt57A80BH1bArcmlLfPw=="],
|
||||
|
||||
"json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="],
|
||||
@@ -4475,7 +4456,7 @@
|
||||
|
||||
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||
|
||||
"jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
"jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"jsonparse": ["jsonparse@1.3.1", "", {}, "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg=="],
|
||||
|
||||
@@ -4737,8 +4718,6 @@
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="],
|
||||
|
||||
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
@@ -5493,8 +5472,6 @@
|
||||
|
||||
"svgo": ["svgo@4.0.2", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^5.1.0", "css-tree": "^3.0.1", "css-what": "^6.1.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "^1.5.0" }, "bin": "./bin/svgo.js" }, "sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng=="],
|
||||
|
||||
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
@@ -5619,8 +5596,6 @@
|
||||
|
||||
"ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="],
|
||||
|
||||
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
|
||||
|
||||
"ulid": ["ulid@3.0.1", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-dPJyqPzx8preQhqq24bBG1YNkvigm87K8kVEHCD+ruZg24t6IFEFv00xMWfxcC4djmFtiTLdFuADn4+DOz6R7Q=="],
|
||||
|
||||
"ultrahtml": ["ultrahtml@1.7.0", "", {}, "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g=="],
|
||||
@@ -6447,10 +6422,6 @@
|
||||
|
||||
"condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
|
||||
|
||||
"conf/dot-prop": ["dot-prop@10.2.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw=="],
|
||||
|
||||
"conf/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
|
||||
"config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
"configstore/dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="],
|
||||
@@ -6491,8 +6462,6 @@
|
||||
|
||||
"electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="],
|
||||
|
||||
"electron-store/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
|
||||
|
||||
"electron-updater/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||
|
||||
"electron-updater/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
@@ -6523,8 +6492,6 @@
|
||||
|
||||
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
|
||||
@@ -6927,18 +6894,12 @@
|
||||
|
||||
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
|
||||
|
||||
"@electron/fuses/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
"@electron/get/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
|
||||
"@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"@electron/notarize/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@electron/universal/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@electron/universal/minimatch/brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="],
|
||||
|
||||
"@electron/windows-sign/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="],
|
||||
|
||||
"@expressive-code/plugin-shiki/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="],
|
||||
@@ -7015,8 +6976,6 @@
|
||||
|
||||
"@jsx-email/doiuse-email/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.4", "", { "dependencies": { "@octokit/types": "^17.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-f1cOWoHPmxryJFknxbtDdjODWfV8A9tc8Aae6ermXPNgHFZ/x91AtHIz4gicEjL8hkJiip+u21QHJORfBv/qiA=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@17.0.0", "", { "dependencies": { "@octokit/openapi-types": "^28.0.0" } }, "sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q=="],
|
||||
@@ -7395,8 +7354,6 @@
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"conf/dot-prop/type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
|
||||
@@ -7421,6 +7378,8 @@
|
||||
|
||||
"electron-updater/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"electron-winstaller/fs-extra/jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
|
||||
|
||||
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"electron/@electron/get/env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
|
||||
@@ -7569,8 +7528,6 @@
|
||||
|
||||
"tw-to-css/tailwindcss/postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="],
|
||||
|
||||
"unzipper/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@3.0.15", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-XeZW1CcDF2GMbH4wejW6xBRI2QCOgnkVYUnxoeDadB1mf85riL2bMUeDoh+6gJ/r4mjNfzUPW8OjLjvwTP0u1Q=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.46", "", { "dependencies": { "@ai-sdk/provider": "3.0.15", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8", "undici": "^6.28.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tEtld97plCFiYevsJuOkGkeuhQndeMWFBVrJS4AjnbD5AqrNSXRCe0p+BZ3Cju/sxDeeZ9ym3q9YUV8fASA7aQ=="],
|
||||
@@ -7579,8 +7536,6 @@
|
||||
|
||||
"vscode-languageserver/vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="],
|
||||
|
||||
"workbox-build/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
|
||||
|
||||
"wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-U9IuP/ev6w4urvogOwQyl3rdumY6W4YaY18NkFaOVHU=",
|
||||
"aarch64-linux": "sha256-Wc8OT2DRZpVo56KaoGE0Hsj1NDknakbWXO9w2qy6j+0=",
|
||||
"aarch64-darwin": "sha256-wAea8+jajnMDxZ6XJL+Hsrf0621hwtBtWyD1+dS45dE=",
|
||||
"x86_64-darwin": "sha256-g8PCNBSV6rO+VQjKU9AtYqj+r18o+fhLDXEQq+X2EZ4="
|
||||
"x86_64-linux": "sha256-8mOzCscBAuogG4tm8CroqjTX5E5yzCvTobbhKvxQP0U=",
|
||||
"aarch64-linux": "sha256-drrRSpzxC8bfaTXBpOyaN0QAyBVV7WJzm0NstJ+8sAE=",
|
||||
"aarch64-darwin": "sha256-YY5A/zxLPONvgnI+DZlzcD2K5Q9PIw4F2YbDxKbT4UU=",
|
||||
"x86_64-darwin": "sha256-/c/Ew4onA+9+l6GRKXz3zWq3QFESM7j5RyGLCWRPQFw="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
|
||||
|
||||
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
|
||||
- Prefer forward compatibility for provider-defined options that OpenCode only passes through. For pass-through string enums, expose known values for autocomplete while accepting future values with `Known | (string & {})`, and accept any string at runtime. Closed literals are appropriate when OpenCode branches on a value, transforms its associated structure, or otherwise cannot correctly handle an unknown variant. New options whose shape or behavior requires implementation remain unsupported until they are handled; do not blindly forward unknown structures.
|
||||
- Order reasoning-effort values from lowest to highest: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Provider-specific subsets follow the same relative order in types, schemas, option lists, and tests.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
export type ReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||
|
||||
const Options = Schema.Struct({
|
||||
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
|
||||
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
|
||||
enableThinking: Schema.optional(Schema.Boolean),
|
||||
thinkingBudget: Schema.optional(Schema.Int),
|
||||
preserveThinking: Schema.optional(Schema.Boolean),
|
||||
@@ -19,7 +19,7 @@ const Options = Schema.Struct({
|
||||
}),
|
||||
),
|
||||
toolStream: Schema.optional(Schema.Boolean),
|
||||
parallelToolCalls: OpenResponsesOptions.Options.fields.parallelToolCalls,
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
repetitionPenalty: Schema.optional(Schema.Number),
|
||||
responseFormat: Schema.optional(
|
||||
Schema.Struct({
|
||||
|
||||
@@ -6,9 +6,9 @@ import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
|
||||
const Options = Schema.Struct({
|
||||
reasoningEffort: OpenResponsesOptions.Options.fields.reasoningEffort,
|
||||
reasoningEffort: Schema.optional(OpenResponsesOptions.ReasoningEffort),
|
||||
enableThinking: Schema.optional(Schema.Boolean),
|
||||
store: OpenResponsesOptions.Options.fields.store,
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
previousResponseId: Schema.optional(Schema.String),
|
||||
conversation: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
@@ -21,11 +21,12 @@ import {
|
||||
type JsonSchema,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ProviderOptions,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolResultPart,
|
||||
} from "../schema/index.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, knownString, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
|
||||
import * as Cache from "./utils/cache.js"
|
||||
@@ -52,57 +53,10 @@ const SSE_EVENTS = new Set([
|
||||
])
|
||||
export const framing = Framing.sseEvents(SSE_EVENTS)
|
||||
|
||||
export type ThinkingBlockBinding = {
|
||||
readonly prefix_mismatch_behavior?: "error" | "drop_block" | (string & {})
|
||||
}
|
||||
|
||||
export type ThinkingInput =
|
||||
| {
|
||||
readonly type: "adaptive"
|
||||
readonly display?: "summarized" | "omitted"
|
||||
readonly block_binding?: ThinkingBlockBinding
|
||||
}
|
||||
| {
|
||||
readonly type: "disabled"
|
||||
}
|
||||
| ({
|
||||
readonly type: "enabled"
|
||||
readonly display?: "summarized" | "omitted"
|
||||
readonly block_binding?: ThinkingBlockBinding
|
||||
} & (
|
||||
| { readonly budgetTokens: number; readonly budget_tokens?: number }
|
||||
| { readonly budgetTokens?: number; readonly budget_tokens: number }
|
||||
))
|
||||
|
||||
export interface OptionsInput {
|
||||
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
|
||||
readonly contextManagement?: ContextManagement
|
||||
readonly [key: string]: unknown
|
||||
readonly thinking?: ThinkingInput
|
||||
readonly effort?: string
|
||||
readonly service_tier?: "auto" | "standard_only"
|
||||
readonly serviceTier?: "auto" | "standard_only"
|
||||
// SDK Metadata:2649 {user_id?: string | null}
|
||||
readonly metadata?: { readonly user_id?: string | null }
|
||||
// SDK MessageCreateParamsContainer:2596 ContainerParams|string
|
||||
readonly container?:
|
||||
| string
|
||||
| { readonly id?: string | null; readonly skills?: ReadonlyArray<Record<string, unknown>> | null }
|
||||
readonly inference_geo?: string | null
|
||||
readonly inferenceGeo?: string | null
|
||||
readonly cache_control?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
|
||||
readonly cacheControl?: { readonly type: "ephemeral"; readonly ttl?: "5m" | "1h" }
|
||||
// SDK OutputConfig:2684 {effort, format: JSONOutputFormat}
|
||||
readonly output_config?: {
|
||||
readonly effort?: string | null
|
||||
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
|
||||
}
|
||||
readonly outputConfig?: {
|
||||
readonly effort?: string | null
|
||||
readonly format?: { readonly type: "json_schema"; readonly schema: Record<string, unknown> } | null
|
||||
}
|
||||
}
|
||||
|
||||
export type ThinkingBlockBinding = typeof AnthropicThinkingBlockBinding.Type
|
||||
export type ThinkingInput = typeof Thinking.Encoded
|
||||
/** Caller-facing provider options; unknown keys are accepted and ignored. `Options.Type` is the wire-ready form. */
|
||||
export type OptionsInput = ProviderOptions & typeof Options.Encoded
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
|
||||
export const ContextManagement = Schema.Struct({
|
||||
@@ -129,6 +83,7 @@ const AnthropicCacheControl = Schema.Struct({
|
||||
type: Schema.tag("ephemeral"),
|
||||
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
|
||||
})
|
||||
const AnthropicServiceTier = knownString<"auto" | "standard_only">()
|
||||
|
||||
const AnthropicTextBlock = Schema.Struct({
|
||||
type: Schema.tag("text"),
|
||||
@@ -317,25 +272,21 @@ const AnthropicToolChoice = Schema.Union([
|
||||
])
|
||||
|
||||
const AnthropicThinkingBlockBinding = Schema.Struct({
|
||||
prefix_mismatch_behavior: Schema.optional(Schema.String),
|
||||
prefix_mismatch_behavior: Schema.optional(knownString<"error" | "drop_block">()),
|
||||
})
|
||||
|
||||
const AnthropicThinking = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("adaptive"),
|
||||
display: Schema.optional(Schema.Literals(["summarized", "omitted"])),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("disabled"),
|
||||
}),
|
||||
])
|
||||
const AnthropicThinkingFields = {
|
||||
display: Schema.optional(knownString<"summarized" | "omitted">()),
|
||||
block_binding: Schema.optional(AnthropicThinkingBlockBinding),
|
||||
}
|
||||
const AnthropicThinkingEnabled = Schema.Struct({
|
||||
type: Schema.tag("enabled"),
|
||||
budget_tokens: Schema.Number,
|
||||
...AnthropicThinkingFields,
|
||||
})
|
||||
const AnthropicThinkingAdaptive = Schema.Struct({ type: Schema.tag("adaptive"), ...AnthropicThinkingFields })
|
||||
const AnthropicThinkingDisabled = Schema.Struct({ type: Schema.tag("disabled") })
|
||||
const AnthropicThinking = Schema.Union([AnthropicThinkingEnabled, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
|
||||
type AnthropicThinking = typeof AnthropicThinking.Type
|
||||
|
||||
// SDK OutputConfig:2684 {effort?: "low"|"medium"|"high"|"xhigh"|"max"|null, format?: JSONOutputFormat:2399}
|
||||
@@ -360,6 +311,53 @@ const AnthropicContainer = Schema.Union([
|
||||
}),
|
||||
])
|
||||
|
||||
// =============================================================================
|
||||
// Provider Options
|
||||
// =============================================================================
|
||||
// Callers spell the budget as `budgetTokens` or the wire `budget_tokens`; the
|
||||
// keys are disjoint per variant so the input type requires exactly one and the
|
||||
// transform can narrow on it. Decoding straight to the wire block keeps the
|
||||
// alias out of the rest of the file.
|
||||
const ThinkingEnabledInput = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("enabled"), budgetTokens: Schema.Number, ...AnthropicThinkingFields }),
|
||||
Schema.Struct({ type: Schema.tag("enabled"), budget_tokens: Schema.Number, ...AnthropicThinkingFields }),
|
||||
]).pipe(
|
||||
Schema.decodeTo(AnthropicThinkingEnabled, {
|
||||
decode: SchemaGetter.transform((input) => ({
|
||||
type: "enabled" as const,
|
||||
budget_tokens: "budgetTokens" in input ? input.budgetTokens : input.budget_tokens,
|
||||
display: input.display,
|
||||
block_binding: input.block_binding,
|
||||
})),
|
||||
encode: SchemaGetter.passthrough({ strict: false }),
|
||||
}),
|
||||
)
|
||||
const Thinking = Schema.Union([ThinkingEnabledInput, AnthropicThinkingAdaptive, AnthropicThinkingDisabled])
|
||||
|
||||
const OutputConfigInput = Schema.Struct({
|
||||
effort: optionalNull(Schema.String),
|
||||
format: optionalNull(AnthropicJsonOutputFormat),
|
||||
})
|
||||
|
||||
// Both key spellings are accepted; `fromRequest` prefers the snake_case one.
|
||||
const Options = Schema.Struct({
|
||||
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
|
||||
contextManagement: Schema.optional(ContextManagement),
|
||||
thinking: Schema.optional(Thinking),
|
||||
effort: Schema.optional(Schema.String),
|
||||
service_tier: Schema.optional(AnthropicServiceTier),
|
||||
serviceTier: Schema.optional(AnthropicServiceTier),
|
||||
metadata: Schema.optional(AnthropicMetadata),
|
||||
container: Schema.optional(AnthropicContainer),
|
||||
inference_geo: optionalNull(Schema.String),
|
||||
inferenceGeo: optionalNull(Schema.String),
|
||||
cache_control: Schema.optional(AnthropicCacheControl),
|
||||
cacheControl: Schema.optional(AnthropicCacheControl),
|
||||
output_config: Schema.optional(OutputConfigInput),
|
||||
outputConfig: Schema.optional(OutputConfigInput),
|
||||
})
|
||||
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
|
||||
|
||||
const AnthropicBodyFields = {
|
||||
context_management: Schema.optional(
|
||||
Schema.Struct({
|
||||
@@ -391,7 +389,7 @@ const AnthropicBodyFields = {
|
||||
container: Schema.optional(Schema.NullOr(AnthropicContainer)),
|
||||
inference_geo: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
metadata: Schema.optional(AnthropicMetadata),
|
||||
service_tier: Schema.optional(Schema.Literals(["auto", "standard_only"])),
|
||||
service_tier: Schema.optional(AnthropicServiceTier),
|
||||
}
|
||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
@@ -1001,64 +999,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
return messages
|
||||
})
|
||||
|
||||
const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (request: LLMRequest) {
|
||||
const input = request.providerOptions as Record<string, unknown> | undefined
|
||||
const rawServiceTier =
|
||||
(input as Record<string, unknown> | undefined)?.service_tier ??
|
||||
(input as Record<string, unknown> | undefined)?.serviceTier
|
||||
const service_tier =
|
||||
rawServiceTier === "auto" || rawServiceTier === "standard_only"
|
||||
? (rawServiceTier as "auto" | "standard_only")
|
||||
: undefined
|
||||
const rawMetadata = (input as Record<string, unknown> | undefined)?.metadata
|
||||
const metadata =
|
||||
ProviderShared.isRecord(rawMetadata) && (typeof rawMetadata.user_id === "string" || rawMetadata.user_id === null)
|
||||
? { user_id: rawMetadata.user_id as string | null }
|
||||
: undefined
|
||||
const container =
|
||||
typeof (input as Record<string, unknown> | undefined)?.container === "string" ||
|
||||
ProviderShared.isRecord((input as Record<string, unknown> | undefined)?.container)
|
||||
? ((input as Record<string, unknown>).container as
|
||||
| string
|
||||
| { id?: string | null; skills?: ReadonlyArray<Record<string, unknown>> | null })
|
||||
: undefined
|
||||
const rawInferenceGeo =
|
||||
(input as Record<string, unknown> | undefined)?.inference_geo ??
|
||||
(input as Record<string, unknown> | undefined)?.inferenceGeo
|
||||
const inference_geo = typeof rawInferenceGeo === "string" ? rawInferenceGeo : undefined
|
||||
const rawCacheControl =
|
||||
(input as Record<string, unknown> | undefined)?.cache_control ??
|
||||
(input as Record<string, unknown> | undefined)?.cacheControl
|
||||
const cache_control =
|
||||
ProviderShared.isRecord(rawCacheControl) && rawCacheControl.type === "ephemeral"
|
||||
? (rawCacheControl as { type: "ephemeral"; ttl?: "5m" | "1h" })
|
||||
: undefined
|
||||
const rawOutputConfig =
|
||||
(input as Record<string, unknown> | undefined)?.output_config ??
|
||||
(input as Record<string, unknown> | undefined)?.outputConfig
|
||||
const outputConfigEffort =
|
||||
typeof (input as Record<string, unknown> | undefined)?.effort === "string"
|
||||
? ((input as Record<string, unknown>).effort as string)
|
||||
: ProviderShared.isRecord(rawOutputConfig) && typeof rawOutputConfig.effort === "string"
|
||||
? (rawOutputConfig.effort as string)
|
||||
: undefined
|
||||
const outputConfigFormat =
|
||||
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
|
||||
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
|
||||
: undefined
|
||||
const thinking = yield* resolveThinking(input?.thinking)
|
||||
return {
|
||||
thinking: applyThinkingBindingDefault(request.model, thinking),
|
||||
effort: outputConfigEffort,
|
||||
format: outputConfigFormat,
|
||||
service_tier,
|
||||
metadata,
|
||||
container,
|
||||
inference_geo,
|
||||
cache_control,
|
||||
}
|
||||
})
|
||||
|
||||
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
|
||||
const claudeVersion = (id: string) => {
|
||||
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
|
||||
@@ -1097,35 +1037,12 @@ const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: Anthr
|
||||
}
|
||||
}
|
||||
|
||||
const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function* (input: unknown) {
|
||||
if (!ProviderShared.isRecord(input)) return undefined
|
||||
if (input.type === "disabled") return { type: "disabled" as const }
|
||||
if (input.type !== "adaptive" && input.type !== "enabled") return undefined
|
||||
const block_binding = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(AnthropicThinkingBlockBinding)),
|
||||
)(input.block_binding)
|
||||
const display =
|
||||
input.display === "summarized" || input.display === "omitted"
|
||||
? (input.display as "summarized" | "omitted")
|
||||
: undefined
|
||||
if (input.type === "adaptive") return { type: "adaptive" as const, display, block_binding }
|
||||
const budget =
|
||||
typeof input.budgetTokens === "number"
|
||||
? input.budgetTokens
|
||||
: typeof input.budget_tokens === "number"
|
||||
? input.budget_tokens
|
||||
: undefined
|
||||
if (budget === undefined)
|
||||
return yield* ProviderShared.invalidRequest("Anthropic thinking provider option requires budgetTokens")
|
||||
return { type: "enabled" as const, budget_tokens: budget, display, block_binding }
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const options = yield* resolveOptions(request)
|
||||
const updates = resolveEffortUpdates(request, options.effort)
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const management = options.contextManagement
|
||||
const outputConfig = options.output_config ?? options.outputConfig
|
||||
const format = outputConfig?.format ?? undefined
|
||||
const updates = resolveEffortUpdates(request, options.effort ?? outputConfig?.effort ?? undefined)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
@@ -1161,12 +1078,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
)
|
||||
}
|
||||
const output_config =
|
||||
updates.effort === undefined && options.format === undefined
|
||||
? undefined
|
||||
: {
|
||||
...(updates.effort === undefined ? {} : { effort: updates.effort }),
|
||||
...(options.format === undefined ? {} : { format: options.format }),
|
||||
}
|
||||
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
|
||||
const body = {
|
||||
model: request.model.id,
|
||||
system,
|
||||
@@ -1179,14 +1091,14 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
top_p: generation?.topP,
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: options.thinking,
|
||||
thinking: applyThinkingBindingDefault(request.model, options.thinking),
|
||||
output_config,
|
||||
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
|
||||
cache_control: options.cache_control,
|
||||
cache_control: options.cache_control ?? options.cacheControl,
|
||||
container: options.container,
|
||||
inference_geo: options.inference_geo,
|
||||
inference_geo: options.inference_geo ?? options.inferenceGeo ?? undefined,
|
||||
metadata: options.metadata,
|
||||
service_tier: options.service_tier,
|
||||
service_tier: options.service_tier ?? options.serviceTier,
|
||||
}
|
||||
if (!management) return body
|
||||
return {
|
||||
|
||||
@@ -14,12 +14,13 @@ import {
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ProviderOptions,
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
} from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { JsonObject, knownString, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
@@ -50,35 +51,8 @@ const omitsFunctionCallIds = (modelID: string) => {
|
||||
return match !== null && Number(match[1]) < 3
|
||||
}
|
||||
|
||||
export interface OptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly cachedContent?: string
|
||||
readonly safetySettings?: ReadonlyArray<{
|
||||
readonly category:
|
||||
| "HARM_CATEGORY_UNSPECIFIED"
|
||||
| "HARM_CATEGORY_HATE_SPEECH"
|
||||
| "HARM_CATEGORY_DANGEROUS_CONTENT"
|
||||
| "HARM_CATEGORY_HARASSMENT"
|
||||
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
|
||||
| "HARM_CATEGORY_CIVIC_INTEGRITY"
|
||||
| (string & {})
|
||||
readonly threshold:
|
||||
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
|
||||
| "BLOCK_LOW_AND_ABOVE"
|
||||
| "BLOCK_MEDIUM_AND_ABOVE"
|
||||
| "BLOCK_ONLY_HIGH"
|
||||
| "BLOCK_NONE"
|
||||
| "OFF"
|
||||
| (string & {})
|
||||
}>
|
||||
readonly serviceTier?: "standard" | "flex" | "priority" | (string & {})
|
||||
readonly thinkingConfig?: {
|
||||
readonly thinkingBudget?: number
|
||||
readonly includeThoughts?: boolean
|
||||
readonly thinkingLevel?: "minimal" | "low" | "medium" | "high" | (string & {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller-facing provider options; unknown keys are accepted and ignored. */
|
||||
export type OptionsInput = ProviderOptions & typeof Options.Encoded
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
|
||||
// =============================================================================
|
||||
@@ -161,17 +135,50 @@ const GeminiToolConfig = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
const GeminiThinkingLevel = knownString<"minimal" | "low" | "medium" | "high">()
|
||||
const GeminiThinkingConfig = Schema.Struct({
|
||||
thinkingBudget: Schema.optional(Schema.Number),
|
||||
includeThoughts: Schema.optional(Schema.Boolean),
|
||||
thinkingLevel: Schema.optional(Schema.String),
|
||||
thinkingLevel: Schema.optional(GeminiThinkingLevel),
|
||||
})
|
||||
|
||||
const GeminiSafetySetting = Schema.Struct({
|
||||
category: Schema.String,
|
||||
threshold: Schema.String,
|
||||
category: knownString<
|
||||
| "HARM_CATEGORY_UNSPECIFIED"
|
||||
| "HARM_CATEGORY_HATE_SPEECH"
|
||||
| "HARM_CATEGORY_DANGEROUS_CONTENT"
|
||||
| "HARM_CATEGORY_HARASSMENT"
|
||||
| "HARM_CATEGORY_SEXUALLY_EXPLICIT"
|
||||
| "HARM_CATEGORY_CIVIC_INTEGRITY"
|
||||
>(),
|
||||
threshold: knownString<
|
||||
| "HARM_BLOCK_THRESHOLD_UNSPECIFIED"
|
||||
| "BLOCK_LOW_AND_ABOVE"
|
||||
| "BLOCK_MEDIUM_AND_ABOVE"
|
||||
| "BLOCK_ONLY_HIGH"
|
||||
| "BLOCK_NONE"
|
||||
| "OFF"
|
||||
>(),
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Provider Options
|
||||
// =============================================================================
|
||||
// Malformed fields are dropped rather than failing the request; a `thinkingConfig`
|
||||
// object that omits `includeThoughts` asks for thoughts.
|
||||
const GeminiThinkingConfigInput = Schema.Struct({
|
||||
thinkingBudget: lenient(Schema.Number),
|
||||
includeThoughts: lenient(Schema.Boolean),
|
||||
thinkingLevel: lenient(GeminiThinkingLevel),
|
||||
})
|
||||
const Options = Schema.Struct({
|
||||
cachedContent: lenient(Schema.String),
|
||||
safetySettings: lenient(Schema.Array(GeminiSafetySetting)),
|
||||
serviceTier: lenient(knownString<"standard" | "flex" | "priority">()),
|
||||
thinkingConfig: lenient(GeminiThinkingConfigInput),
|
||||
})
|
||||
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
|
||||
|
||||
const GeminiGenerationConfig = Schema.Struct({
|
||||
maxOutputTokens: Schema.optional(Schema.Number),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
@@ -431,44 +438,11 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
return contents
|
||||
})
|
||||
|
||||
const resolveOptions = (request: LLMRequest) => {
|
||||
const input = request.providerOptions
|
||||
const value = input?.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
thinkingBudget:
|
||||
ProviderShared.isRecord(value) && typeof value.thinkingBudget === "number" ? value.thinkingBudget : undefined,
|
||||
includeThoughts:
|
||||
ProviderShared.isRecord(value) && typeof value.includeThoughts === "boolean"
|
||||
? value.includeThoughts
|
||||
: ProviderShared.isRecord(value)
|
||||
? true
|
||||
: undefined,
|
||||
thinkingLevel:
|
||||
ProviderShared.isRecord(value) && typeof value.thinkingLevel === "string" ? value.thinkingLevel : undefined,
|
||||
}
|
||||
return {
|
||||
cachedContent: typeof input?.cachedContent === "string" ? input.cachedContent : undefined,
|
||||
safetySettings: mapSafetySettings(input?.safetySettings),
|
||||
serviceTier: typeof input?.serviceTier === "string" ? input.serviceTier : undefined,
|
||||
thinkingConfig: Object.values(thinkingConfig).some((item) => item !== undefined) ? thinkingConfig : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function mapSafetySettings(value: unknown) {
|
||||
if (!Array.isArray(value)) return undefined
|
||||
const settings = value.flatMap((item) =>
|
||||
ProviderShared.isRecord(item) && typeof item.category === "string" && typeof item.threshold === "string"
|
||||
? [{ category: item.category, threshold: item.threshold }]
|
||||
: [],
|
||||
)
|
||||
return settings
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) {
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
const hasTools = flattened.tools.length > 0
|
||||
const generation = request.generation
|
||||
const options = resolveOptions(request)
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const generationConfig = {
|
||||
maxOutputTokens: generation?.maxTokens,
|
||||
@@ -479,7 +453,10 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
presencePenalty: generation?.presencePenalty,
|
||||
seed: generation?.seed,
|
||||
stopSequences: generation?.stop,
|
||||
thinkingConfig: options.thinkingConfig,
|
||||
thinkingConfig:
|
||||
options.thinkingConfig === undefined
|
||||
? undefined
|
||||
: { ...options.thinkingConfig, includeThoughts: options.thinkingConfig.includeThoughts ?? true },
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import {
|
||||
@@ -29,6 +29,16 @@ const isJson = Schema.is(Schema.Json)
|
||||
export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
|
||||
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
|
||||
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
|
||||
/** Optional field whose malformed value decodes to `undefined` instead of failing the enclosing struct. */
|
||||
export const lenient = <const S extends Schema.Top>(schema: S) =>
|
||||
Schema.optionalKey(
|
||||
Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined)))),
|
||||
)
|
||||
/** Provider-defined string enum: known values for autocomplete, any string accepted at runtime. */
|
||||
export const knownString = <Known extends string>() =>
|
||||
Schema.declare<Known | (string & {})>((value): value is Known | (string & {}) => typeof value === "string", {
|
||||
expected: "string",
|
||||
})
|
||||
|
||||
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { Schema } from "effect"
|
||||
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
|
||||
import { lenient } from "../shared.js"
|
||||
|
||||
export { ReasoningEffort, ReasoningEfforts }
|
||||
|
||||
@@ -49,21 +50,22 @@ export const StreamOptions = Schema.Struct({
|
||||
includeObfuscation: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
// Malformed options are dropped one at a time so a bad `topLogprobs` cannot discard `store` or `reasoningEffort`.
|
||||
export const Options = Schema.Struct({
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
safetyIdentifier: Schema.optional(Schema.String),
|
||||
streamOptions: Schema.optional(StreamOptions),
|
||||
topLogprobs: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||
reasoningEffort: Schema.optional(ReasoningEffort),
|
||||
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
|
||||
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
|
||||
textVerbosity: Schema.optional(TextVerbositySchema),
|
||||
serviceTier: Schema.optional(ServiceTierSchema),
|
||||
truncation: Schema.optional(TruncationSchema),
|
||||
allowedTools: Schema.optional(AllowedTools),
|
||||
maxToolCalls: Schema.optional(Schema.Int),
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
store: lenient(Schema.Boolean),
|
||||
metadata: lenient(Schema.Record(Schema.String, Schema.String)),
|
||||
safetyIdentifier: lenient(Schema.String),
|
||||
streamOptions: lenient(StreamOptions),
|
||||
topLogprobs: lenient(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 20 }))),
|
||||
reasoningEffort: lenient(ReasoningEffort),
|
||||
reasoningSummary: lenient(Schema.Literals(["auto", "concise", "detailed"])),
|
||||
include: lenient(Schema.Array(ResponseIncludableSchema)),
|
||||
textVerbosity: lenient(TextVerbositySchema),
|
||||
serviceTier: lenient(ServiceTierSchema),
|
||||
truncation: lenient(TruncationSchema),
|
||||
allowedTools: lenient(AllowedTools),
|
||||
maxToolCalls: lenient(Schema.Int),
|
||||
parallelToolCalls: lenient(Schema.Boolean),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
@@ -71,11 +73,10 @@ export type Resolved = Omit<Options, "allowedTools"> & {
|
||||
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
|
||||
}
|
||||
|
||||
const decodeOptions = Schema.decodeUnknownOption(Options)
|
||||
const decodeOptions = Schema.decodeUnknownSync(Options)
|
||||
|
||||
export const resolve = (request: LLMRequest): Resolved => {
|
||||
const input = Option.getOrUndefined(decodeOptions(request.providerOptions))
|
||||
if (!input) return {}
|
||||
const input = decodeOptions(request.providerOptions ?? {})
|
||||
return {
|
||||
...input,
|
||||
include: input.include?.length ? input.include : undefined,
|
||||
|
||||
@@ -66,7 +66,6 @@ const inputDelta = (tool: PendingTool, text: string) =>
|
||||
name: tool.name,
|
||||
namespace: tool.namespace,
|
||||
text,
|
||||
input: Option.getOrElse(parsePartialInput(tool.input), () => ({})),
|
||||
})
|
||||
|
||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||
|
||||
@@ -182,7 +182,7 @@ export const ToolInputDelta = Schema.Struct({
|
||||
name: Schema.String,
|
||||
namespace: Schema.optional(Schema.String),
|
||||
text: Schema.String,
|
||||
/** Best-effort parse of all input fragments received through this delta. */
|
||||
/** Optional best-effort parse supplied by adapters; native routes emit raw text until the final tool call. */
|
||||
input: Schema.optional(Schema.Unknown),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputDelta" })
|
||||
export type ToolInputDelta = Schema.Schema.Type<typeof ToolInputDelta>
|
||||
|
||||
@@ -4,6 +4,22 @@ import { Anthropic } from "../../src/providers.js"
|
||||
const model = Anthropic.provider.model("claude-sonnet-4-5")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { thinking: { type: "adaptive" } } })
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: {
|
||||
serviceTier: "future-tier",
|
||||
thinking: { type: "adaptive", display: "future-display" },
|
||||
},
|
||||
})
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: {
|
||||
// @ts-expect-error Anthropic cache TTL values are protocol constraints.
|
||||
cacheControl: { type: "ephemeral", ttl: "future-ttl" },
|
||||
},
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
|
||||
@@ -166,7 +166,89 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("Anthropic thinking provider option requires budgetTokens")
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("budgetTokens")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers passthrough provider options and accepts either key spelling", () =>
|
||||
Effect.gen(function* () {
|
||||
const snake = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
service_tier: "auto",
|
||||
metadata: { user_id: "user_1" },
|
||||
container: { id: "container_1" },
|
||||
inference_geo: "us",
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const camel = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
serviceTier: "standard_only",
|
||||
container: "container_2",
|
||||
inferenceGeo: "eu",
|
||||
cacheControl: { type: "ephemeral" },
|
||||
outputConfig: { effort: "low" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(snake.body).toMatchObject({
|
||||
service_tier: "auto",
|
||||
metadata: { user_id: "user_1" },
|
||||
container: { id: "container_1" },
|
||||
inference_geo: "us",
|
||||
cache_control: { type: "ephemeral", ttl: "1h" },
|
||||
output_config: { format: { type: "json_schema", schema: { type: "object" } } },
|
||||
})
|
||||
expect(camel.body).toMatchObject({
|
||||
service_tier: "standard_only",
|
||||
container: "container_2",
|
||||
inference_geo: "eu",
|
||||
cache_control: { type: "ephemeral" },
|
||||
output_config: { effort: "low" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards unknown values for pass-through string enums", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
service_tier: "future-tier",
|
||||
thinking: { type: "adaptive", display: "future-display" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
service_tier: "future-tier",
|
||||
thinking: { type: "adaptive", display: "future-display" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores unknown provider options and rejects malformed known ones", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(LLMRequest.update(request, { providerOptions: { unknownOption: true } }))
|
||||
const malformed = [
|
||||
{ service_tier: 1 },
|
||||
{ metadata: { user_id: 42 } },
|
||||
{ cache_control: { type: "ephemeral", ttl: "future-ttl" } },
|
||||
{ output_config: { format: { type: "text" } } },
|
||||
{ thinking: { type: "automatic" } },
|
||||
]
|
||||
const errors = yield* Effect.forEach(malformed, (providerOptions) =>
|
||||
compileRequest(LLMRequest.update(request, { providerOptions })).pipe(Effect.flip),
|
||||
)
|
||||
|
||||
expect(prepared.body).not.toHaveProperty("unknownOption")
|
||||
expect(errors.map((error) => error.reason._tag)).toEqual(malformed.map(() => "InvalidRequest"))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1598,13 +1680,12 @@ describe("Anthropic Messages route", () => {
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
|
||||
@@ -822,13 +822,12 @@ describe("Bedrock Converse route", () => {
|
||||
])
|
||||
const events = response.events.filter((event) => event.type === "tool-input-delta")
|
||||
expect(events).toEqual([
|
||||
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"', input: {} },
|
||||
{ type: "tool-input-delta", id: "tool_1", name: "lookup", text: '{"query"' },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "tool_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
])
|
||||
expect(response.events.at(-1)).toMatchObject({
|
||||
@@ -916,7 +915,6 @@ describe("Bedrock Converse route", () => {
|
||||
id: "tool_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -413,14 +413,12 @@ describe("Mistral Chat", () => {
|
||||
id: "Ab12Cd34E",
|
||||
name: "lookup",
|
||||
text: '{"city":',
|
||||
input: {},
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "Ab12Cd34E",
|
||||
name: "lookup",
|
||||
text: '"Paris"}',
|
||||
input: { city: "Paris" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "Ab12Cd34E", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
|
||||
@@ -313,7 +313,6 @@ describe("Open Responses basic-item lifecycles", () => {
|
||||
id: "call_bc1eb4b42e70ee53",
|
||||
name: "get_weather",
|
||||
text: '{\n "city": "Paris"\n}',
|
||||
input: { city: "Paris" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_bc1eb4b42e70ee53", name: "get_weather", providerMetadata },
|
||||
{
|
||||
|
||||
@@ -248,6 +248,21 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps valid Chat options when a sibling option is malformed", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
|
||||
prompt: "think",
|
||||
providerOptions: { store: true, reasoningEffort: "max", topLogprobs: 25 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.store).toBe(true)
|
||||
expect(prepared.body.reasoning_effort).toBe("max")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps the request prompt cache key when the compatibility flag is set", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -1496,13 +1511,12 @@ describe("OpenAI Chat route", () => {
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
@@ -1553,7 +1567,6 @@ describe("OpenAI Chat route", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{
|
||||
type: "step-finish",
|
||||
@@ -1609,7 +1622,6 @@ describe("OpenAI Chat route", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{
|
||||
type: "step-finish",
|
||||
@@ -1675,7 +1687,6 @@ describe("OpenAI Chat route", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -1765,13 +1776,12 @@ describe("OpenAI Chat route", () => {
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
|
||||
@@ -1945,6 +1945,30 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops a malformed provider option without discarding its siblings", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "hi",
|
||||
providerOptions: {
|
||||
topLogprobs: 25,
|
||||
metadata: { tenant: 7 },
|
||||
reasoningEffort: "high",
|
||||
serviceTier: "priority",
|
||||
maxToolCalls: 4,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.top_logprobs).toBeUndefined()
|
||||
expect(prepared.body.metadata).toBeUndefined()
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "high" })
|
||||
expect(prepared.body.service_tier).toBe("priority")
|
||||
expect(prepared.body.max_tool_calls).toBe(4)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts the full ResponseIncludable union", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -4014,14 +4038,12 @@ describe("OpenAI Responses route", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query"',
|
||||
input: {},
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
{
|
||||
type: "tool-input-end",
|
||||
@@ -4093,8 +4115,8 @@ describe("OpenAI Responses route", () => {
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.filter((event) => event.type === "tool-input-delta")).toEqual([
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', input: { query: "weather" } },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
@@ -4132,7 +4154,6 @@ describe("OpenAI Responses route", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"weather"}',
|
||||
input: { query: "weather" },
|
||||
},
|
||||
])
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "weather" } })
|
||||
@@ -4193,7 +4214,6 @@ describe("OpenAI Responses route", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"streamed"}',
|
||||
input: { query: "streamed" },
|
||||
},
|
||||
])
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "final" } })
|
||||
|
||||
@@ -23,11 +23,9 @@ describe("ToolStream", () => {
|
||||
|
||||
expect(first.events).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"', input: {} },
|
||||
])
|
||||
expect(second.events).toEqual([
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}', input: { query: "weather" } },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
])
|
||||
expect(second.events).toEqual([{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }])
|
||||
expect(finished).toEqual({
|
||||
tools: {},
|
||||
events: [
|
||||
@@ -38,7 +36,7 @@ describe("ToolStream", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
test("exposes cumulative partial string values", () => {
|
||||
test("streams raw deltas without reparsing cumulative input", () => {
|
||||
const result = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
ToolStream.empty<number>(),
|
||||
@@ -53,26 +51,9 @@ describe("ToolStream", () => {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
text: '{"query":"wea',
|
||||
input: { query: "wea" },
|
||||
})
|
||||
})
|
||||
|
||||
test("defaults partial input to an empty object when the accumulated value cannot be parsed", () => {
|
||||
const result = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
ToolStream.empty<number>(),
|
||||
0,
|
||||
{ id: "call_1", name: "lookup", text: "x" },
|
||||
"missing tool",
|
||||
)
|
||||
if (ToolStream.isError(result)) throw result
|
||||
|
||||
expect(result.events).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
|
||||
])
|
||||
})
|
||||
|
||||
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
|
||||
Effect.gen(function* () {
|
||||
const first = ToolStream.appendOrStart(
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
test("pairs locally without checking the server and authenticates subsequent requests", async ({ page, baseURL }) => {
|
||||
const origin = new URL(baseURL ?? "http://127.0.0.1:3000").origin
|
||||
const password = "pairing-secret"
|
||||
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||
const requests: { origin: string; authorization: string | undefined }[] = []
|
||||
await page.addInitScript((origin) => {
|
||||
if (localStorage.getItem("opencode.global.dat:server")) return
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ list: [{ type: "http", http: { url: origin, password: "old-password" } }] }),
|
||||
)
|
||||
}, origin)
|
||||
await page.route("**/api/**", async (route) => {
|
||||
requests.push({
|
||||
origin: new URL(route.request().url()).origin,
|
||||
authorization: route.request().headers().authorization,
|
||||
})
|
||||
// Pairing must succeed even when the API is unavailable.
|
||||
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" })
|
||||
})
|
||||
|
||||
await page.goto(`/connect?data=${encodeURIComponent(JSON.stringify({ username: "opencode", password }))}`)
|
||||
await expect(page).toHaveURL(`${origin}/`)
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}").list))
|
||||
.toEqual([{ type: "http", http: { url: origin, password } }])
|
||||
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
|
||||
expect(
|
||||
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
|
||||
).toBe(true)
|
||||
|
||||
requests.length = 0
|
||||
await page.reload()
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
|
||||
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
|
||||
expect(
|
||||
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("the hosted app hands HTTP-only pairing links over to the server's own web UI", async ({ page, baseURL }) => {
|
||||
const dev = new URL(baseURL ?? "http://127.0.0.1:3000").origin
|
||||
const hosted = "https://app.opencode.ai"
|
||||
const lan = "http://192.168.1.20:49374"
|
||||
// Serve the dev build under the hosted HTTPS origin so mixed-content rules apply to the page.
|
||||
await page.route(`${hosted}/**`, async (route) => {
|
||||
const response = await page.request.fetch(route.request().url().replace(hosted, dev))
|
||||
await route.fulfill({ response })
|
||||
})
|
||||
await page.route(`${lan}/**`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "text/html", body: "<title>served</title>" }),
|
||||
)
|
||||
const requests: string[] = []
|
||||
await page.route("**/api/**", async (route) => {
|
||||
requests.push(route.request().url())
|
||||
await route.abort()
|
||||
})
|
||||
const info = { urls: [lan], username: "opencode", password: "lan-secret" }
|
||||
const fragment = Buffer.from(JSON.stringify(info)).toString("base64url")
|
||||
|
||||
await page.goto(`${hosted}/connect#${fragment}`)
|
||||
await expect(page.getByRole("heading", { name: "This server is on a local network" })).toBeVisible()
|
||||
await expect(page.getByText(lan, { exact: true })).toBeVisible()
|
||||
expect(requests).toEqual([])
|
||||
|
||||
await page.getByRole("button", { name: "Open on local network" }).click()
|
||||
await expect(page).toHaveURL(`${lan}/connect#${fragment}`)
|
||||
})
|
||||
|
||||
test("the unpaired page loads without starting server requests", async ({ page }) => {
|
||||
const requests: string[] = []
|
||||
await page.route("**/api/**", async (route) => {
|
||||
requests.push(route.request().url())
|
||||
await route.abort()
|
||||
})
|
||||
await page.goto("/connect")
|
||||
await expect(page.getByRole("heading", { name: "Connect to a server" })).toBeVisible()
|
||||
await expect(page.getByLabel("Password", { exact: true })).toBeEditable()
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -46,7 +46,6 @@
|
||||
"@sentry/vite-plugin": "catalog:",
|
||||
"@tailwindcss/vite": "4.3.3",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/luxon": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"diff": "catalog:",
|
||||
@@ -88,12 +87,10 @@
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
"luxon": "catalog:",
|
||||
"qr-scanner": "1.4.2",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"solid-presence": "0.2.0",
|
||||
"tailwindcss": "4.3.3",
|
||||
"uqr": "0.1.3"
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
+16
-24
@@ -4,9 +4,9 @@ import { FileComponentProvider } from "@opencode/ui/context/file"
|
||||
import { Font } from "@opencode/ui/font"
|
||||
import { ThemeProvider } from "@opencode/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Router, useLocation } from "@solidjs/router"
|
||||
import { type BaseRouterProps, Router } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
|
||||
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider } from "@/shell/commands/command"
|
||||
import { DesktopCommands } from "@/shell/commands/desktop"
|
||||
@@ -107,29 +107,21 @@ export function AppInterface(props: {
|
||||
// The visual layout lives in the router root so it remains mounted across
|
||||
// route changes. Draft and session routes override only their server-bound data
|
||||
// providers beneath it.
|
||||
const Root = (rootProps: ParentProps) => {
|
||||
const location = useLocation()
|
||||
// Pairing saves credentials before mounting any server connections or health checks.
|
||||
return (
|
||||
<>
|
||||
const Root = (rootProps: ParentProps) => (
|
||||
<TabsProvider>
|
||||
<GlobalProvider>
|
||||
<BodyTypography />
|
||||
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
|
||||
<TabsProvider>
|
||||
<GlobalProvider>
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</GlobalProvider>
|
||||
</TabsProvider>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</GlobalProvider>
|
||||
</TabsProvider>
|
||||
)
|
||||
|
||||
return (
|
||||
<ServersProvider
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import { useI18n } from "@opencode/ui/context/i18n"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
@@ -545,17 +546,21 @@ export function ComposerAttachments(props: {
|
||||
</AttachmentCard>
|
||||
}
|
||||
>
|
||||
{(image) => (
|
||||
<>
|
||||
<img
|
||||
src={image().blob.url}
|
||||
alt={attachment.filename}
|
||||
class="w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||
onClick={() => props.onAttachmentClick?.(attachment)}
|
||||
/>
|
||||
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
|
||||
</>
|
||||
)}
|
||||
{(image) => {
|
||||
// Restored drafts and history carry image ids only; bytes load when shown.
|
||||
const [url] = createResource(() => image().blob, resolveBlobUrl)
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={url() ?? ""}
|
||||
alt={attachment.filename}
|
||||
class="w-[58px] h-[46px] rounded-[6px] object-cover"
|
||||
onClick={() => props.onAttachmentClick?.(attachment)}
|
||||
/>
|
||||
<div class="absolute inset-0 rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-base)] pointer-events-none" />
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</Tooltip>
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Prompt } from "@/composer/state"
|
||||
import { prependHistoryEntry, type PromptHistoryComment } from "./entry"
|
||||
import { prependHistoryEntry, removeHistoryEntry, type PromptHistoryComment } from "./entry"
|
||||
import { Schema } from "effect"
|
||||
import { PromptHistoryState } from "../schema"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
@@ -36,6 +36,24 @@ describe("Composer history", () => {
|
||||
expect(dedupedComments).toBe(commentsOnly)
|
||||
})
|
||||
|
||||
test("removeHistoryEntry drops the entry recorded for a prompt and leaves others alone", () => {
|
||||
const image: Prompt = [
|
||||
{ type: "text", content: "look", start: 0, end: 4 },
|
||||
{ type: "image", id: "img", filename: "big.png", mime: "image/png", blob: { id: "hash", url: "" } },
|
||||
]
|
||||
const entries = prependHistoryEntry(prependHistoryEntry([], text("earlier")), image, [comment("c1")])
|
||||
expect(entries).toHaveLength(2)
|
||||
|
||||
const untouched = removeHistoryEntry(entries, text("never sent"))
|
||||
expect(untouched).toBe(entries)
|
||||
|
||||
const withoutComments = removeHistoryEntry(entries, image)
|
||||
expect(withoutComments).toBe(entries)
|
||||
|
||||
const removed = removeHistoryEntry(entries, image, [comment("c1")])
|
||||
expect(removed).toEqual(prependHistoryEntry([], text("earlier")))
|
||||
})
|
||||
|
||||
test("insertion isolates canonical entries from source mutations", () => {
|
||||
const prompt: Prompt = [
|
||||
{
|
||||
|
||||
@@ -48,6 +48,18 @@ export function prependHistoryEntry(
|
||||
return [entry, ...entries].slice(0, max)
|
||||
}
|
||||
|
||||
// A send that failed puts its prompt back in the composer, so the entry recorded for it would
|
||||
// only duplicate the draft and keep its attachments referenced for as long as history holds it.
|
||||
export function removeHistoryEntry(
|
||||
entries: PromptHistoryStoredEntry[],
|
||||
prompt: Prompt,
|
||||
comments: PromptHistoryComment[] = [],
|
||||
) {
|
||||
const entry = { prompt, comments } satisfies PromptHistoryEntry
|
||||
const next = entries.filter((item) => !isPromptEqual(item, entry))
|
||||
return next.length === entries.length ? entries : next
|
||||
}
|
||||
|
||||
function isCommentEqual(commentA: PromptHistoryComment, commentB: PromptHistoryComment) {
|
||||
return (
|
||||
commentA.path === commentB.path &&
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import {
|
||||
clonePromptHistoryComments,
|
||||
prependHistoryEntry,
|
||||
removeHistoryEntry,
|
||||
type PromptHistoryComment,
|
||||
type PromptHistoryStoredEntry,
|
||||
} from "./entry"
|
||||
@@ -13,6 +14,7 @@ import { PromptHistoryState } from "../schema"
|
||||
export type ComposerHistoryStore = {
|
||||
entries: (mode: "normal" | "shell") => PromptHistoryStoredEntry[]
|
||||
add: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
remove: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
}
|
||||
|
||||
type PromptHistoryState = typeof PromptHistoryState.Type
|
||||
@@ -32,6 +34,13 @@ function createComposerHistoryStore(
|
||||
if (next === current.entries) return
|
||||
setCurrent("entries", next)
|
||||
},
|
||||
remove(prompt, mode, comments) {
|
||||
const current = mode === "shell" ? shell : normal
|
||||
const setCurrent = mode === "shell" ? setShell : setNormal
|
||||
const next = removeHistoryEntry(current.entries, prompt, comments)
|
||||
if (next === current.entries) return
|
||||
setCurrent("entries", next)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,5 +65,12 @@ export function createComposerHistory() {
|
||||
const metadata = clonePromptHistoryComments(comments)
|
||||
void ready.then(() => history.add(saved, mode, metadata))
|
||||
},
|
||||
remove(prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) {
|
||||
const ready = mode === "shell" ? shellInit : normalInit
|
||||
if (!(ready instanceof Promise)) return history.remove(prompt, mode, comments)
|
||||
const saved = clonePrompt(prompt)
|
||||
const metadata = clonePromptHistoryComments(comments)
|
||||
void ready.then(() => history.remove(saved, mode, metadata))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { createSessionTabs } from "@/session/helpers"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
@@ -263,6 +264,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
editor: () => editor,
|
||||
queueScroll: () => requestAnimationFrame(() => editor?.scrollIntoView({ block: "nearest" })),
|
||||
addToHistory: (value, mode) => controller.addHistory(value, mode),
|
||||
removeFromHistory: (value, mode, comments) => history.remove(value, mode, mode === "shell" ? [] : comments),
|
||||
resetHistory: () => controller.resetHistory(),
|
||||
setMode: (next) => controller.dispatch({ type: next === "shell" ? "mode.shell" : "mode.normal" }),
|
||||
closePopover: () => controller.dispatch({ type: "popover.close" }),
|
||||
@@ -322,7 +324,9 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
},
|
||||
openAttachment: (attachment) => {
|
||||
if (attachment.type !== "image") return
|
||||
dialog.show(() => createComponent(ImagePreview, { src: attachment.blob.url, alt: attachment.filename }))
|
||||
void resolveBlobUrl(attachment.blob).then((src) => {
|
||||
if (src) dialog.show(() => createComponent(ImagePreview, { src, alt: attachment.filename }))
|
||||
})
|
||||
},
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
|
||||
@@ -136,7 +136,7 @@ describe("composer persistence schemas", () => {
|
||||
).toEqual(value)
|
||||
})
|
||||
|
||||
test("migrates inline images but never encodes dataUrl or unresolved references", () => {
|
||||
test("migrates inline images, keeps store references without a URL, and never encodes dataUrl", () => {
|
||||
const value = Schema.decodeUnknownSync(
|
||||
Persistence.withInitial(ComposerStore, { prompt: DEFAULT_PROMPT, context: { items: [] } }),
|
||||
)({
|
||||
@@ -149,12 +149,16 @@ describe("composer persistence schemas", () => {
|
||||
{ ...image, blob: { id: "missing" }, dataUrl: "data:image/png;base64,YQ==" },
|
||||
],
|
||||
})
|
||||
expect(value.prompt).toHaveLength(3)
|
||||
expect(value.prompt).toHaveLength(6)
|
||||
expect(value.prompt[0]).toEqual({
|
||||
...image,
|
||||
sourcePath: "/image.png",
|
||||
blob: { id: "data:image/png;base64,YQ==", url: "data:image/png;base64,YQ==" },
|
||||
})
|
||||
// Bytes still in the draft store resolve on use; a non-blob URL is discarded in favour of the id.
|
||||
expect(value.prompt[3]).toEqual({ ...image, blob: { id: "missing", url: "" } })
|
||||
expect(value.prompt[4]).toEqual({ ...image, blob: { id: "bad", url: "" } })
|
||||
expect(value.prompt[5]).toEqual({ ...image, blob: { id: "missing", url: "" } })
|
||||
const encoded = Schema.encodeSync(ComposerStore)(value)
|
||||
expect(JSON.stringify(encoded)).not.toContain("dataUrl")
|
||||
expect(
|
||||
|
||||
@@ -61,11 +61,12 @@ const ImageFields = {
|
||||
}
|
||||
const Image = Persistence.struct({
|
||||
...ImageFields,
|
||||
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:)/)) }),
|
||||
// An empty URL is an image whose bytes are still in the draft store; see `resolveBlobUrl`.
|
||||
blob: Schema.Struct({ id: Schema.NonEmptyString, url: Schema.String.check(Schema.isPattern(/^(blob:|data:|$)/)) }),
|
||||
})
|
||||
|
||||
// Draft storage hydrates content-addressed blobs before this codec runs. Legacy
|
||||
// inline data remains usable, but unresolved references are not renderable.
|
||||
// Draft storage keeps content-addressed blobs in the store until an image is shown or sent; a
|
||||
// reference without a URL resolves through `resolveBlobUrl`. Legacy inline data remains usable.
|
||||
export const ImageAttachmentPart = Schema.Struct({
|
||||
...ImageFields,
|
||||
blob: Persistence.optional(
|
||||
|
||||
@@ -88,6 +88,15 @@ describe("prompt state initialization", () => {
|
||||
start: 5,
|
||||
end: 12,
|
||||
},
|
||||
// A reference without a usable URL keeps its id; the bytes resolve from the draft store on use.
|
||||
{
|
||||
type: "image",
|
||||
id: "missing-blob",
|
||||
filename: "missing.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "content-hash-without-a-url", url: "" },
|
||||
},
|
||||
{ type: "image", id: "invalid-url", filename: "invalid.png", mime: "image/png", blob: { id: "hash", url: "" } },
|
||||
{
|
||||
type: "image",
|
||||
id: "legacy",
|
||||
|
||||
@@ -53,6 +53,7 @@ function submitInput(
|
||||
notify = { missingSelection() {}, failed(_kind: "shell" | "command" | "prompt", _error: unknown) {} },
|
||||
mode: "normal" | "shell" = "normal",
|
||||
commands: () => readonly { name: string }[] | undefined = () => [],
|
||||
history: string[] = [],
|
||||
) {
|
||||
return createComposerSubmit({
|
||||
adapter,
|
||||
@@ -60,7 +61,9 @@ function submitInput(
|
||||
commands,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
addToHistory() {},
|
||||
addToHistory: (prompt) => history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
|
||||
removeFromHistory: (prompt) =>
|
||||
history.push(`remove:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
|
||||
resetHistory() {},
|
||||
setMode() {},
|
||||
closePopover() {},
|
||||
@@ -533,12 +536,8 @@ describe("Composer submission", () => {
|
||||
missingSelection() {},
|
||||
failed: () => (attempts.length === 2 ? first.resolve() : second.resolve()),
|
||||
}
|
||||
const submission = submitInput(
|
||||
adapter,
|
||||
notify,
|
||||
"normal",
|
||||
() => [],
|
||||
)
|
||||
const history: string[] = []
|
||||
const submission = submitInput(adapter, notify, "normal", () => [], history)
|
||||
|
||||
await submission.submit(new Event("submit"))
|
||||
await first.promise
|
||||
@@ -549,6 +548,8 @@ describe("Composer submission", () => {
|
||||
expect(new Set(attempts).size).toBe(1)
|
||||
expect(statuses).toEqual(["running", "idle", "running", "idle"])
|
||||
expect(state.current()).toMatchObject([{ type: "text", content: text }])
|
||||
// The restored prompt is the draft again, so history does not also keep it (and its attachments).
|
||||
expect(history).toEqual([`add:${text}`, `remove:${text}`, `add:${text}`, `remove:${text}`])
|
||||
})
|
||||
|
||||
test("forwards structured mentions to custom commands", async () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ComposerAdapter, ComposerDelivery, ComposerSelection, ComposerSess
|
||||
import { createComposerSubmission } from "./submission-state"
|
||||
import { buildPromptRequest } from "./request"
|
||||
import { setCursorPosition } from "./editor/dom"
|
||||
import { blobDataUrl } from "@/runtime/persistence/drafts"
|
||||
import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { isAttachment } from "./prompt-parts"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
|
||||
@@ -32,6 +32,7 @@ type ComposerSubmitInput = {
|
||||
editor: () => HTMLDivElement | undefined
|
||||
queueScroll: () => void
|
||||
addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
|
||||
removeFromHistory: (prompt: Prompt, mode: "normal" | "shell", comments: PromptHistoryComment[]) => void
|
||||
resetHistory: () => void
|
||||
setMode: (mode: "normal" | "shell") => void
|
||||
closePopover: () => void
|
||||
@@ -59,12 +60,22 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
selection: item.selection ? { ...item.selection } : undefined,
|
||||
})),
|
||||
})
|
||||
const value = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
|
||||
if (!value) {
|
||||
const read = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
|
||||
if (!read) {
|
||||
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
|
||||
return
|
||||
}
|
||||
if (submitting.has(input.adapter.state)) return
|
||||
// Images restored from a draft or history carry ids only; the optimistic message shows their URLs.
|
||||
const value = {
|
||||
...read,
|
||||
images: await Promise.all(
|
||||
read.images.map(async (image) => ({
|
||||
...image,
|
||||
blob: { ...image.blob, url: (await resolveBlobUrl(image.blob)) ?? image.blob.url },
|
||||
})),
|
||||
),
|
||||
}
|
||||
submitting.add(input.adapter.state)
|
||||
const comments = input.comments.capture()
|
||||
// Capture command intent before starting a session in a worktree whose catalog has not loaded.
|
||||
@@ -240,6 +251,8 @@ function restoreSubmission(
|
||||
) {
|
||||
const restored = submission.restore()
|
||||
if (!restored) return false
|
||||
// The prompt is back in the composer; its history entry would only keep attachments referenced.
|
||||
input.removeFromHistory(value.prompt, value.mode, comments)
|
||||
restored.target.set(restored.prompt, promptLength(restored.prompt))
|
||||
restored.target.mode.set(value.mode)
|
||||
restored.target.context.replaceComments(
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode/ui/dialog"
|
||||
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { DateTime } from "luxon"
|
||||
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
|
||||
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
@@ -327,19 +326,19 @@ export function homeSessionSearchKey(record: HomeSessionRecord) {
|
||||
return `${pathKey(record.session.location.directory)}:${record.session.id}`
|
||||
}
|
||||
|
||||
// Calendar day in the local time zone, comparable as a number.
|
||||
function localDay(date: Date) {
|
||||
return date.getFullYear() * 10_000 + date.getMonth() * 100 + date.getDate()
|
||||
}
|
||||
|
||||
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
|
||||
const now = DateTime.local()
|
||||
const yesterday = now.minus({ days: 1 })
|
||||
const todaySessions = records.filter((record) =>
|
||||
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
|
||||
)
|
||||
const yesterdaySessions = records.filter((record) =>
|
||||
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
|
||||
)
|
||||
const olderSessions = records.filter((record) => {
|
||||
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
|
||||
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
|
||||
})
|
||||
const now = new Date()
|
||||
const today = localDay(now)
|
||||
const yesterday = localDay(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1))
|
||||
const day = (record: HomeSessionRecord) => localDay(new Date(record.session.time.updated ?? record.session.time.created))
|
||||
const todaySessions = records.filter((record) => day(record) === today)
|
||||
const yesterdaySessions = records.filter((record) => day(record) === yesterday)
|
||||
const olderSessions = records.filter((record) => day(record) !== today && day(record) !== yesterday)
|
||||
const olderTitle =
|
||||
todaySessions.length === 0 && yesterdaySessions.length === 0
|
||||
? language.t("sidebar.project.recentSessions")
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { DateTime } from "luxon"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
@@ -9,6 +8,8 @@ export type ModelKey = { providerID: string; modelID: string }
|
||||
|
||||
type Visibility = "show" | "hide"
|
||||
const RECENT_LIMIT = 5
|
||||
// luxon's diffNow().as("months") used an average month; keep the same window.
|
||||
const sixMonths = 6 * 30.436875 * 24 * 60 * 60 * 1000
|
||||
|
||||
function modelKey(model: ModelKey) {
|
||||
return `${model.providerID}:${model.modelID}`
|
||||
@@ -29,27 +30,23 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
),
|
||||
)
|
||||
|
||||
// Release dates as epoch ms; an unparseable date is NaN and never counts as recent.
|
||||
const release = createMemo(
|
||||
() =>
|
||||
new Map(
|
||||
available().map((model) => {
|
||||
const parsed = DateTime.fromISO(model.release_date)
|
||||
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
|
||||
}),
|
||||
available().map(
|
||||
(model) => [modelKey({ providerID: model.provider.id, modelID: model.id }), Date.parse(model.release_date)] as const,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const latest = createMemo(() =>
|
||||
pipe(
|
||||
available(),
|
||||
filter(
|
||||
(x) =>
|
||||
Math.abs(
|
||||
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
|
||||
.diffNow()
|
||||
.as("months"),
|
||||
) < 6,
|
||||
),
|
||||
filter((x) => {
|
||||
const released = release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? NaN
|
||||
return Math.abs(Date.now() - released) < sixMonths
|
||||
}),
|
||||
groupBy((x) => x.provider.id),
|
||||
mapValues((models) =>
|
||||
pipe(
|
||||
@@ -101,9 +98,8 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
if (state === "hide") return false
|
||||
if (state === "show") return true
|
||||
if (latestSet().has(key)) return true
|
||||
const date = release().get(key)
|
||||
if (!date?.isValid) return true
|
||||
return false
|
||||
// Models without a parseable release date stay visible.
|
||||
return !Number.isFinite(release().get(key) ?? NaN)
|
||||
}
|
||||
|
||||
const setVisibility = (model: ModelKey, state: boolean) => {
|
||||
|
||||
@@ -440,44 +440,6 @@ export const dict = {
|
||||
"No camera is available to this browser. Enter your connection details manually.",
|
||||
"server.connect.camera.error":
|
||||
"Could not open the camera. Allow camera access or enter your connection details manually.",
|
||||
"server.connect.local.title": "This server is on a local network",
|
||||
"server.connect.local.description":
|
||||
"This page can't reach HTTP servers on your local network. Open the server's own web interface to continue on this device.",
|
||||
"server.connect.local.open": "Open on local network",
|
||||
"server.connect.local.loopback.title": "This server only listens on localhost",
|
||||
"server.connect.local.loopback.description":
|
||||
"Other devices can't reach a server that only listens on localhost. Opening it will only work on the computer running OpenCode.",
|
||||
"server.connect.local.loopback.open": "Open on this computer",
|
||||
"server.connect.local.loopback.fix": "Run this command on your computer, then pair again.",
|
||||
"command.server.pair": "Pair device",
|
||||
"settings.pairing.title": "Pairing",
|
||||
"settings.pairing.connection": "Local Network",
|
||||
"pair.local.description": "View connection details and a QR code to connect a device on the same network.",
|
||||
"pair.local.open": "Show details",
|
||||
"pair.screenActive.title": "Keep screen active",
|
||||
"pair.screenActive.description": "Prevent this computer’s display from sleeping while OpenCode is running.",
|
||||
"pair.screenActive.error": "Could not update the screen activity setting. Try again.",
|
||||
"pair.qr.open": "Show details",
|
||||
"pair.tailscale.enable": "Enable Tailscale",
|
||||
"pair.tailscale.serve": "Tailscale Serve",
|
||||
"pair.description": "Connect another device to this machine's OpenCode server.",
|
||||
"pair.loading": "Loading connection details…",
|
||||
"pair.qr": "Pairing QR code",
|
||||
"pair.copy": "Copy details",
|
||||
"pair.copy.error": "Could not copy pairing details. Try again.",
|
||||
"pair.urls": "URLs",
|
||||
"pair.username": "Username",
|
||||
"pair.password": "Password",
|
||||
"pair.error": "Could not update pairing details. Try again.",
|
||||
"pair.tailscale.open": "Share with Tailscale",
|
||||
"pair.tailscale.opening": "Enabling Tailscale Serve…",
|
||||
"pair.tailscale.title": "Tailscale",
|
||||
"pair.tailscale.description": "Use Tailscale Serve to share this server over HTTPS with devices on your tailnet.",
|
||||
"pair.tailscale.checking": "Checking Tailscale Serve…",
|
||||
"pair.tailscale.inactive": "This server is not shared with Tailscale yet.",
|
||||
"pair.tailscale.error": "Could not load Tailscale Serve status. Try again.",
|
||||
"pair.tailscale.disable": "Disable",
|
||||
"pair.tailscale.disabling": "Disabling Tailscale Serve…",
|
||||
"dialog.server.edit.title": "Edit server",
|
||||
"dialog.server.default.title": "Default server",
|
||||
"dialog.server.default.description":
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createDraftStore, draftTextChunk, draftTextThreshold } from "./drafts"
|
||||
import { createDraftStore, draftTextChunk, draftTextThreshold, resolveBlobUrl } from "./drafts"
|
||||
|
||||
function memoryDriver() {
|
||||
const documents = new Map<string, string>()
|
||||
@@ -287,15 +287,34 @@ describe("draft store image retention", () => {
|
||||
expect(await released(store, 5, shared.url)).toBe(true)
|
||||
})
|
||||
|
||||
test("loading a document pins the images it references", async () => {
|
||||
test("loading a document pins the images it references without fetching their bytes", async () => {
|
||||
const { memory, store } = fresh()
|
||||
const reads: string[] = []
|
||||
const getBlob = memory.driver.getBlob
|
||||
memory.driver.getBlob = (id) => {
|
||||
reads.push(id)
|
||||
return getBlob(id)
|
||||
}
|
||||
const id = await memory.driver.putBlob(image(6))
|
||||
memory.documents.set("loaded", JSON.stringify({ prompt: [{ type: "image", blob: { id } }] }))
|
||||
const url = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob.url
|
||||
const loaded = JSON.parse((await store.getItem("loaded"))!).prompt[0].blob
|
||||
expect(loaded).toEqual({ id })
|
||||
expect(reads).toEqual([])
|
||||
// The first consumer that shows or sends the image loads it; the pin from the load keeps it.
|
||||
const url = (await resolveBlobUrl(loaded))!
|
||||
expect(url.startsWith("blob:")).toBe(true)
|
||||
expect(reads).toEqual([id])
|
||||
expect(await resolveBlobUrl(loaded)).toBe(url)
|
||||
expect(reads).toEqual([id])
|
||||
await tick()
|
||||
expect(await released(store, 6, url)).toBe(false)
|
||||
await store.removeItem("loaded")
|
||||
await tick()
|
||||
expect(await released(store, 6, url)).toBe(true)
|
||||
})
|
||||
|
||||
test("a reference to bytes the store no longer holds resolves to nothing", async () => {
|
||||
fresh()
|
||||
expect(await resolveBlobUrl({ id: "gone" })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -42,6 +42,18 @@ const refs = new Map<string, Set<string>>()
|
||||
// Image ids that were restored under a different id (a store without WebCrypto assigns fresh
|
||||
// ones); live references still carry the original.
|
||||
const aliases = new Map<string, string>()
|
||||
// Fetches image bytes from the store created last. Documents load without their bytes; a consumer
|
||||
// that renders or sends an image resolves its URL through here, so a history full of large
|
||||
// attachments costs nothing at startup.
|
||||
let loader: ((id: string) => Promise<string | undefined>) | undefined
|
||||
|
||||
/** The object URL for an image reference, loading its bytes from the draft store on first use. */
|
||||
export function resolveBlobUrl(blob: { id: string; url?: string }) {
|
||||
if (blob.url) return Promise.resolve(blob.url)
|
||||
const existing = retained.get(aliases.get(blob.id) ?? blob.id)
|
||||
if (existing) return Promise.resolve(existing.url)
|
||||
return loader?.(blob.id) ?? Promise.resolve(undefined)
|
||||
}
|
||||
|
||||
function blobUrl(id: string, blob: Blob, grace?: number) {
|
||||
const existing = retained.get(id)
|
||||
@@ -120,7 +132,7 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
const loading = new Map<string, Promise<string | undefined>>()
|
||||
const loadBlobUrl = (id: string) => {
|
||||
const existing = retained.get(id)
|
||||
if (existing) return existing.url
|
||||
if (existing) return Promise.resolve(existing.url)
|
||||
const pending = loading.get(id)
|
||||
if (pending) return pending
|
||||
const next = driver
|
||||
@@ -130,6 +142,7 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
loading.set(id, next)
|
||||
return next
|
||||
}
|
||||
loader = loadBlobUrl
|
||||
const putBlob = async (blob: Blob) => {
|
||||
const id = await driver.putBlob(blob)
|
||||
return { id, url: blobUrl(id, blob, grace) }
|
||||
@@ -221,9 +234,11 @@ export function createDraftStore(driver: Driver, options: { grace?: number } = {
|
||||
if (ref.kind === "text" && Array.isArray(ref.ids)) {
|
||||
return (await Promise.all(ref.ids.map((id) => loadChunk(String(id))))).join("")
|
||||
}
|
||||
// Bytes stay in the store until something renders or sends the image (see resolveBlobUrl);
|
||||
// only an image already pinned in this page gets its URL back immediately.
|
||||
if (typeof ref.id === "string") {
|
||||
const url = await loadBlobUrl(ref.id)
|
||||
if (url) return { ...item, blob: { id: ref.id, url } }
|
||||
const url = retained.get(aliases.get(ref.id) ?? ref.id)?.url
|
||||
return { ...item, blob: url ? { id: ref.id, url } : { id: ref.id } }
|
||||
}
|
||||
}
|
||||
return Object.fromEntries(
|
||||
@@ -423,7 +438,9 @@ function referenced(json: string) {
|
||||
|
||||
export async function blobDataUrl(blob: BlobReference, mime: string) {
|
||||
const kept = retained.get(aliases.get(blob.id) ?? blob.id)
|
||||
const data = kept ? kept.blob : await fetch(blob.url).then((response) => response.blob())
|
||||
const url = kept ? undefined : await resolveBlobUrl(blob)
|
||||
if (!kept && !url) throw new Error(`Attachment ${blob.id} has no stored bytes`)
|
||||
const data = kept ? kept.blob : await fetch(url!).then((response) => response.blob())
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener("error", () => reject(reader.error))
|
||||
|
||||
@@ -22,12 +22,6 @@ type SaveFilePickerOptions = { title?: string; defaultPath?: string }
|
||||
type PlatformName = "web" | "desktop"
|
||||
type DesktopOS = "macos" | "windows" | "linux"
|
||||
|
||||
export type PairingInfo = {
|
||||
readonly urls: readonly string[]
|
||||
readonly username: "opencode"
|
||||
readonly password: string
|
||||
}
|
||||
|
||||
export type FatalRendererErrorLog = {
|
||||
error: string
|
||||
url: string
|
||||
@@ -107,10 +101,6 @@ type PlatformBase = {
|
||||
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
|
||||
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
|
||||
|
||||
/** Prevent the local display from sleeping while the desktop app is running. */
|
||||
getKeepScreenActive?(): Promise<boolean>
|
||||
setKeepScreenActive?(enabled: boolean): Promise<void>
|
||||
|
||||
/** Run a desktop-only menu action from the app chrome */
|
||||
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
|
||||
|
||||
@@ -134,15 +124,6 @@ type PlatformBase = {
|
||||
|
||||
/** Native browser pane hosted by the platform (desktop only). */
|
||||
browserPane?: BrowserPanePlatform
|
||||
|
||||
/** Pair another device with the local desktop server. */
|
||||
pair?: {
|
||||
info(): Promise<PairingInfo>
|
||||
tailscaleAvailable(): Promise<boolean>
|
||||
tailscaleStatus(): Promise<PairingInfo | null>
|
||||
openTailscale(): Promise<PairingInfo>
|
||||
disableTailscale(): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
export type Platform = PlatformBase &
|
||||
|
||||
@@ -7,10 +7,6 @@ export function isMixedContent(page: string, address: string) {
|
||||
const url = new URL(normalized)
|
||||
if (url.protocol !== "http:") return false
|
||||
// Secure Contexts treats loopback HTTP origins as potentially trustworthy.
|
||||
return !isLoopback(url)
|
||||
}
|
||||
|
||||
export function isLoopback(url: URL) {
|
||||
const host = url.hostname.replace(/\.$/, "")
|
||||
return host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d+){3}$/.test(host)
|
||||
return !(host === "localhost" || host.endsWith(".localhost") || host === "[::1]" || /^127(?:\.\d+){3}$/.test(host))
|
||||
}
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingUrl } from "./pairing"
|
||||
|
||||
describe("pairing URL", () => {
|
||||
test("pairs with the current origin using credentials without server URLs", () => {
|
||||
const info = { username: "opencode" as const, password: "a+b & café" }
|
||||
const origin = "https://computer.tailnet.ts.net:49709"
|
||||
const url = new URL(pairingUrl(info, origin))
|
||||
|
||||
expect(url.origin).toBe(origin)
|
||||
expect(url.pathname).toBe("/connect")
|
||||
expect(JSON.parse(url.searchParams.get("data") ?? "")).toEqual(info)
|
||||
expect(decodePairingUrl(url.search, origin)).toEqual({ urls: [origin], password: info.password })
|
||||
expect(decodePairingCode(JSON.stringify(info))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("encodes the pairing JSON in the data query parameter and decodes it", () => {
|
||||
const info = {
|
||||
urls: ["http://192.168.1.2:4096"],
|
||||
username: "opencode" as const,
|
||||
password: "a+b & café",
|
||||
}
|
||||
const url = new URL(pairingUrl(info, "https://example.com"))
|
||||
|
||||
expect(url.origin).toBe("https://example.com")
|
||||
expect(url.pathname).toBe("/connect")
|
||||
expect(url.searchParams.get("data")).toBe(JSON.stringify(info))
|
||||
expect(decodePairingUrl(url.search)).toEqual({
|
||||
urls: ["http://192.168.1.2:4096"],
|
||||
password: "a+b & café",
|
||||
})
|
||||
})
|
||||
|
||||
test("defaults to the hosted app for desktop pairing", () => {
|
||||
expect(new URL(pairingUrl({ urls: [], username: "opencode", password: "secret" })).origin).toBe(
|
||||
"https://app.opencode.ai",
|
||||
)
|
||||
})
|
||||
|
||||
test("accepts CLI base64url fragments", () => {
|
||||
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "a+b & café" }
|
||||
expect(decodePairingUrl(`#${Buffer.from(JSON.stringify(value)).toString("base64url")}`)).toEqual({
|
||||
urls: value.urls,
|
||||
password: value.password,
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects invalid query data", () => {
|
||||
expect(decodePairingUrl("?data=invalid")).toBeUndefined()
|
||||
expect(decodePairingUrl("?other=value")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("accepts legacy JSON fragments", () => {
|
||||
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "secret" }
|
||||
expect(decodePairingUrl(`#${encodeURIComponent(JSON.stringify(value))}`)).toEqual({
|
||||
urls: value.urls,
|
||||
password: value.password,
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects an invalid fragment", () => {
|
||||
expect(decodePairingUrl("#not-a-pairing-code")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("pairing scan", () => {
|
||||
const info = {
|
||||
urls: ["http://192.168.1.2:49374", "http://127.0.0.1:49374"],
|
||||
username: "opencode" as const,
|
||||
password: "a+b & café",
|
||||
}
|
||||
const decoded = { urls: info.urls, password: info.password }
|
||||
|
||||
test("decodes raw JSON, desktop query links, and the CLI fragment link", () => {
|
||||
expect(decodePairingScan(JSON.stringify(info))).toEqual(decoded)
|
||||
expect(decodePairingScan(pairingUrl(info, "http://192.168.1.2:49374"))).toEqual(decoded)
|
||||
const encoded = Buffer.from(JSON.stringify(info)).toString("base64url")
|
||||
expect(decodePairingScan(`https://app.opencode.ai/connect#${encoded}`)).toEqual(decoded)
|
||||
})
|
||||
|
||||
test("rejects URLs without pairing data and non-http schemes", () => {
|
||||
expect(decodePairingScan("http://192.168.1.2:49374/connect")).toBeUndefined()
|
||||
expect(decodePairingScan("opencode-ios://connect?password=secret")).toBeUndefined()
|
||||
expect(decodePairingScan("not a code")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import { normalizeServerUrl } from "@/runtime/server/registry"
|
||||
|
||||
const pairing = Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
urls: Schema.optional(Schema.Array(Schema.String)),
|
||||
urls: Schema.Array(Schema.String),
|
||||
username: Schema.Literal("opencode"),
|
||||
password: Schema.String,
|
||||
}),
|
||||
@@ -19,46 +19,10 @@ export function serverAddress(value: string) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function decodePairingCode(value: string, origin?: string) {
|
||||
export function decodePairingCode(value: string) {
|
||||
const result = Schema.decodeUnknownOption(pairing)(value)
|
||||
if (Option.isNone(result)) return
|
||||
const urls = [
|
||||
...new Set(
|
||||
(result.value.urls ?? (origin ? [origin] : [])).map(serverAddress).filter((url) => url !== undefined),
|
||||
),
|
||||
]
|
||||
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
|
||||
if (!urls.length) return
|
||||
return { urls, password: result.value.password }
|
||||
}
|
||||
|
||||
export function pairingUrl(
|
||||
value: { urls?: readonly string[]; username: "opencode"; password: string },
|
||||
host = "https://app.opencode.ai",
|
||||
) {
|
||||
return `${new URL("/connect", host)}?data=${encodeURIComponent(JSON.stringify(value))}`
|
||||
}
|
||||
|
||||
export function decodePairingScan(value: string) {
|
||||
const url = URL.parse(value)
|
||||
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return decodePairingCode(value)
|
||||
return decodePairingUrl(url.search, url.origin) ?? decodePairingUrl(url.hash)
|
||||
}
|
||||
|
||||
export function decodePairingUrl(value: string, origin?: string) {
|
||||
if (value.startsWith("?")) {
|
||||
const data = new URLSearchParams(value).get("data")
|
||||
return data === null ? undefined : decodePairingCode(data, origin)
|
||||
}
|
||||
const encoded = value.startsWith("#") ? value.slice(1) : value
|
||||
if (!encoded) return
|
||||
const legacy = new URLSearchParams(`value=${encoded}`).get("value") ?? ""
|
||||
if (legacy.startsWith("{")) return decodePairingCode(legacy)
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) return
|
||||
const binary = atob(
|
||||
encoded
|
||||
.replaceAll("-", "+")
|
||||
.replaceAll("_", "/")
|
||||
.padEnd(Math.ceil(encoded.length / 4) * 4, "="),
|
||||
)
|
||||
return decodePairingCode(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))))
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ import { onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { decodePairingScan } from "./pairing"
|
||||
import { decodePairingCode } from "./pairing"
|
||||
import "./scanner.css"
|
||||
|
||||
export function PairingScanner(props: {
|
||||
onScan: (value: NonNullable<ReturnType<typeof decodePairingScan>>) => void
|
||||
onScan: (value: NonNullable<ReturnType<typeof decodePairingCode>>) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
@@ -22,7 +22,7 @@ export function PairingScanner(props: {
|
||||
const scanner = new QrScanner(
|
||||
video,
|
||||
(result) => {
|
||||
const pairing = decodePairingScan(result.data)
|
||||
const pairing = decodePairingCode(result.data)
|
||||
if (!pairing) {
|
||||
setState("error", language.t("server.connect.scan.invalid"))
|
||||
return
|
||||
|
||||
@@ -9,28 +9,19 @@ import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useCheckServerHealth } from "@/runtime/server/health"
|
||||
import { useServers } from "@/runtime/server/registry"
|
||||
import { serverAddress } from "./pairing"
|
||||
import type { decodePairingCode } from "./pairing"
|
||||
import { isLoopback, isMixedContent } from "./browser"
|
||||
import { isMixedContent } from "./browser"
|
||||
import { createCameraAvailability } from "./camera"
|
||||
import "./screen.css"
|
||||
|
||||
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
|
||||
|
||||
export function ConnectServerScreen(
|
||||
props: { pairing?: NonNullable<ReturnType<typeof decodePairingCode>>; onConnect?: () => void } = {},
|
||||
) {
|
||||
export function ConnectServerScreen() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const servers = useServers()
|
||||
const check = useCheckServerHealth()
|
||||
const camera = createCameraAvailability()
|
||||
const [state, setState] = createStore({
|
||||
url: props.pairing?.urls[0] ?? "",
|
||||
password: props.pairing?.password ?? "",
|
||||
urls: props.pairing?.urls ?? ([] as string[]),
|
||||
error: "",
|
||||
scanning: false,
|
||||
})
|
||||
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
|
||||
const connectionError = () =>
|
||||
language.t(
|
||||
platform.platform === "web" && isMixedContent(location.href, state.url)
|
||||
@@ -51,7 +42,6 @@ export function ConnectServerScreen(
|
||||
return
|
||||
}
|
||||
servers.add({ type: "http", http })
|
||||
props.onConnect?.()
|
||||
},
|
||||
onError: () => setState("error", connectionError()),
|
||||
}))
|
||||
@@ -174,37 +164,3 @@ export function ConnectServerScreen(
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export function ConnectLocalScreen(props: { urls: readonly string[] }) {
|
||||
const language = useLanguage()
|
||||
const target = props.urls[0]
|
||||
const loopback = isLoopback(new URL(target))
|
||||
return (
|
||||
<main data-component="connect-server" aria-labelledby="server-connect-title">
|
||||
<div class="server-connect-content">
|
||||
<div class="server-connect-brand" role="img" aria-label="OpenCode">
|
||||
<Wordmark />
|
||||
</div>
|
||||
<header>
|
||||
<h1 id="server-connect-title">
|
||||
{language.t(loopback ? "server.connect.local.loopback.title" : "server.connect.local.title")}
|
||||
</h1>
|
||||
<p>{language.t(loopback ? "server.connect.local.loopback.description" : "server.connect.local.description")}</p>
|
||||
</header>
|
||||
<Button
|
||||
variant="contrast"
|
||||
size="large"
|
||||
onClick={() => location.assign(`${new URL("/connect", target)}${location.search}${location.hash}`)}
|
||||
>
|
||||
{language.t(loopback ? "server.connect.local.loopback.open" : "server.connect.local.open")}
|
||||
</Button>
|
||||
<footer>
|
||||
<Show when={loopback}>
|
||||
<p>{language.t("server.connect.local.loopback.fix")}</p>
|
||||
</Show>
|
||||
<code dir="ltr">{loopback ? "opencode service set hostname 0.0.0.0" : target}</code>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { DateTime } from "luxon"
|
||||
|
||||
export function createSessionContextFormatter(locale: string) {
|
||||
// The fields luxon's DATETIME_MED preset passed to Intl; output is identical.
|
||||
const dateTime = new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})
|
||||
return {
|
||||
number(value: number | null | undefined) {
|
||||
if (value === undefined) return "—"
|
||||
@@ -14,7 +20,7 @@ export function createSessionContextFormatter(locale: string) {
|
||||
},
|
||||
time(value: number | undefined) {
|
||||
if (!value) return "—"
|
||||
return DateTime.fromMillis(value).setLocale(locale).toLocaleString(DateTime.DATETIME_MED)
|
||||
return dateTime.format(value)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ export const pageIcons = {
|
||||
appearance: "appearance",
|
||||
notifications: "notifications",
|
||||
shortcuts: "keyboard",
|
||||
pairing: "server",
|
||||
projects: "folder",
|
||||
workspaces: "outline-worktree",
|
||||
providers: "providers",
|
||||
@@ -23,7 +22,6 @@ export const pageLabels = {
|
||||
appearance: "settings.general.section.appearance",
|
||||
notifications: "settings.tab.notifications",
|
||||
shortcuts: "settings.shortcuts.title",
|
||||
pairing: "settings.pairing.title",
|
||||
projects: "settings.tab.projects",
|
||||
workspaces: "settings.tab.workspaces",
|
||||
providers: "settings.providers.title",
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Switch } from "@opencode/ui/switch"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
|
||||
import { renderSVG } from "uqr"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform, type PairingInfo } from "@/runtime/platform/platform"
|
||||
import { pairingUrl } from "@/servers/connect/pairing"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
|
||||
export function SettingsPairing() {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const platform = usePlatform()
|
||||
const queryClient = useQueryClient()
|
||||
const pair = platform.pair
|
||||
if (!pair) return null
|
||||
const local = useQuery(() => ({
|
||||
queryKey: ["pairing", "local"],
|
||||
queryFn: pair.info,
|
||||
}))
|
||||
// Reading pending query data would suspend the entire settings surface.
|
||||
const localInfo = () => (local.isSuccess ? local.data : undefined)
|
||||
const localHost = createMemo(() =>
|
||||
localInfo()?.urls.find((value) => {
|
||||
const host = new URL(value).hostname
|
||||
return (
|
||||
host !== "localhost" &&
|
||||
!host.endsWith(".localhost") &&
|
||||
!host.startsWith("127.") &&
|
||||
host !== "[::1]" &&
|
||||
host !== "0.0.0.0" &&
|
||||
host !== "[::]"
|
||||
)
|
||||
}),
|
||||
)
|
||||
const screenActive = useQuery(() => ({
|
||||
queryKey: ["pairing", "screen-active"],
|
||||
queryFn: () => platform.getKeepScreenActive!(),
|
||||
enabled: !!platform.getKeepScreenActive,
|
||||
}))
|
||||
const screenActivity = useMutation(() => ({
|
||||
mutationFn: async (enabled: boolean) => platform.setKeepScreenActive?.(enabled),
|
||||
onSuccess: (_, enabled) => queryClient.setQueryData(["pairing", "screen-active"], enabled),
|
||||
}))
|
||||
const tailscale = useQuery(() => ({
|
||||
queryKey: ["pairing", "tailscale-available"],
|
||||
queryFn: pair.tailscaleAvailable,
|
||||
}))
|
||||
const tailscaleStatus = useQuery(() => ({
|
||||
queryKey: ["pairing", "tailscale-status"],
|
||||
queryFn: pair.tailscaleStatus,
|
||||
enabled: tailscale.isSuccess && tailscale.data === true,
|
||||
}))
|
||||
const tailscaleServe = useMutation(() => ({
|
||||
mutationFn: pair.openTailscale,
|
||||
onSuccess: (value) => queryClient.setQueryData(["pairing", "tailscale-status"], value),
|
||||
}))
|
||||
const tailscaleDisable = useMutation(() => ({
|
||||
mutationFn: pair.disableTailscale,
|
||||
onSuccess: () => queryClient.setQueryData(["pairing", "tailscale-status"], null),
|
||||
}))
|
||||
const tailscaleInfo = () => (tailscaleStatus.isSuccess ? tailscaleStatus.data : undefined)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-tab-title">{language.t("settings.pairing.title")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">{language.t("pair.description")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body settings-tab-body--sectioned">
|
||||
<section class="settings-section" aria-label={language.t("settings.pairing.connection")}>
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("settings.pairing.connection")}
|
||||
description={language.t("pair.local.description")}
|
||||
>
|
||||
<Button
|
||||
variant="neutral"
|
||||
disabled={!localHost()}
|
||||
onClick={() =>
|
||||
dialog.push(() => (
|
||||
<DialogPairing
|
||||
title={language.t("settings.pairing.connection")}
|
||||
info={localInfo()}
|
||||
host={localHost()}
|
||||
/>
|
||||
))
|
||||
}
|
||||
>
|
||||
{language.t("pair.local.open")}
|
||||
</Button>
|
||||
</SettingsRow>
|
||||
<Show when={platform.getKeepScreenActive && platform.setKeepScreenActive}>
|
||||
<div data-action="settings-keep-screen-active">
|
||||
<SettingsRow
|
||||
title={language.t("pair.screenActive.title")}
|
||||
description={language.t("pair.screenActive.description")}
|
||||
>
|
||||
<Switch
|
||||
hideLabel
|
||||
checked={screenActive.isSuccess && screenActive.data}
|
||||
disabled={screenActive.isPending || !!screenActive.error || screenActivity.isPending}
|
||||
onChange={(enabled) => screenActivity.mutate(enabled)}
|
||||
>
|
||||
{language.t("pair.screenActive.title")}
|
||||
</Switch>
|
||||
</SettingsRow>
|
||||
</div>
|
||||
</Show>
|
||||
</SettingsList>
|
||||
<Show when={screenActive.error || screenActivity.error}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{language.t("pair.screenActive.error")}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={local.error}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{language.t("pair.error")}
|
||||
</p>
|
||||
</Show>
|
||||
</section>
|
||||
|
||||
<Show when={tailscale.isSuccess && tailscale.data === true}>
|
||||
<section class="settings-section" aria-label={language.t("pair.tailscale.title")}>
|
||||
<Show
|
||||
when={tailscaleInfo()}
|
||||
fallback={
|
||||
<>
|
||||
<h3 class="settings-section-title">{language.t("pair.tailscale.title")}</h3>
|
||||
<SettingsList>
|
||||
<div class="flex min-h-24 flex-col items-center justify-center gap-3 px-4 py-6">
|
||||
<Button
|
||||
variant="neutral"
|
||||
disabled={!localInfo() || tailscaleStatus.isPending || tailscaleServe.isPending}
|
||||
aria-busy={tailscaleServe.isPending}
|
||||
onClick={() => tailscaleServe.mutate()}
|
||||
>
|
||||
<svg class="size-4 shrink-0" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<g opacity="0.3">
|
||||
<circle cx="3" cy="3" r="3" />
|
||||
<circle cx="12" cy="3" r="3" />
|
||||
<circle cx="21" cy="3" r="3" />
|
||||
<circle cx="3" cy="21" r="3" />
|
||||
<circle cx="21" cy="21" r="3" />
|
||||
</g>
|
||||
<circle cx="3" cy="12" r="3" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<circle cx="21" cy="12" r="3" />
|
||||
<circle cx="12" cy="21" r="3" />
|
||||
</svg>
|
||||
{language.t(tailscaleServe.isPending ? "pair.tailscale.opening" : "pair.tailscale.enable")}
|
||||
</Button>
|
||||
<span class="text-center text-[13px] leading-text-base text-v2-text-text-muted">
|
||||
{language.t("pair.tailscale.description")}
|
||||
</span>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<h3 class="settings-section-title">{language.t("pair.tailscale.title")}</h3>
|
||||
<SettingsList>
|
||||
<SettingsRow
|
||||
title={language.t("pair.tailscale.serve")}
|
||||
description={language.t("pair.tailscale.description")}
|
||||
>
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<Button
|
||||
variant="neutral"
|
||||
disabled={!tailscaleInfo() || tailscaleDisable.isPending}
|
||||
onClick={() =>
|
||||
dialog.push(() => (
|
||||
<DialogPairing
|
||||
title={language.t("pair.tailscale.title")}
|
||||
info={tailscaleInfo()}
|
||||
host={tailscaleInfo()?.urls[0]}
|
||||
/>
|
||||
))
|
||||
}
|
||||
>
|
||||
{language.t("pair.qr.open")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="neutral"
|
||||
disabled={
|
||||
!localInfo() ||
|
||||
tailscaleStatus.isPending ||
|
||||
tailscaleServe.isPending ||
|
||||
tailscaleDisable.isPending
|
||||
}
|
||||
onClick={() => tailscaleDisable.mutate()}
|
||||
>
|
||||
{language.t("pair.tailscale.disable")}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</Show>
|
||||
<Show when={tailscaleStatus.error || tailscaleServe.error || tailscaleDisable.error}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{language.t("pair.tailscale.error")}
|
||||
</p>
|
||||
</Show>
|
||||
</section>
|
||||
</Show>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogPairing(props: { title: string; info: PairingInfo | null | undefined; host?: string }) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const url = createMemo(() => {
|
||||
if (!props.info) return
|
||||
const host = props.host ?? (platform.platform === "web" ? location.origin : undefined)
|
||||
return pairingUrl(
|
||||
{
|
||||
urls: host ? [host] : props.info.urls.slice(0, 1),
|
||||
username: props.info.username,
|
||||
password: props.info.password,
|
||||
},
|
||||
host,
|
||||
)
|
||||
})
|
||||
const origin = createMemo(() => {
|
||||
const value = url()
|
||||
if (!value) return
|
||||
return new URL(value).origin
|
||||
})
|
||||
const copy = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const value = url()
|
||||
if (!value) return
|
||||
await (platform.writeClipboardText?.(value) ?? navigator.clipboard.writeText(value))
|
||||
},
|
||||
}))
|
||||
createEffect(() => {
|
||||
if (!copy.isSuccess) return
|
||||
const timeout = setTimeout(() => copy.reset(), 2000)
|
||||
onCleanup(() => clearTimeout(timeout))
|
||||
})
|
||||
const qr = createMemo(() => {
|
||||
const value = url()
|
||||
if (!value) return
|
||||
return renderSVG(value, { border: 4, blackColor: "currentColor", whiteColor: "transparent" })
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog fit containerClass="max-w-[min(400px,calc(100vw-32px),calc(100dvh-180px))]">
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup title={props.title} description={language.t("pair.description")} />
|
||||
</DialogHeader>
|
||||
<DialogBody class="flex flex-col gap-4 px-4 pb-4">
|
||||
<Show when={props.info}>
|
||||
<div
|
||||
class="aspect-square w-full shrink-0 rounded-[6px] bg-v2-background-bg-base p-6 text-v2-text-text-base [&>svg]:size-full"
|
||||
role="img"
|
||||
aria-label={language.t("pair.qr")}
|
||||
innerHTML={qr()}
|
||||
/>
|
||||
<div class="flex min-w-0 justify-center pb-2">
|
||||
<Tooltip
|
||||
class="min-w-0 max-w-full"
|
||||
value={language.t(copy.isSuccess ? "common.copied" : "pair.copy")}
|
||||
placement="top"
|
||||
forceOpen={copy.isSuccess ? true : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex min-h-8 max-w-full select-none items-center justify-center gap-2 rounded-[6px] px-2 py-1 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted transition-colors hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base focus-visible:bg-v2-background-bg-layer-02 focus-visible:outline-none disabled:opacity-50"
|
||||
disabled={copy.isPending}
|
||||
aria-label={language.t("pair.copy")}
|
||||
onClick={() => copy.mutate()}
|
||||
>
|
||||
<Icon name={copy.isSuccess ? "check" : "copy"} size="small" class="shrink-0" />
|
||||
<bdi dir="ltr" class="min-w-0 break-all text-start">
|
||||
{origin()}
|
||||
</bdi>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={copy.error}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{language.t("pair.copy.error")}
|
||||
</p>
|
||||
</Show>
|
||||
</DialogBody>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -20,20 +20,6 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
|
||||
{ tab: "appearance", label: "settings.general.section.appearance" },
|
||||
{ tab: "notifications", label: "settings.tab.notifications" },
|
||||
{ tab: "shortcuts", label: "settings.shortcuts.title", keywords: "keybind keyboard hotkey" },
|
||||
{
|
||||
tab: "pairing",
|
||||
label: "settings.pairing.title",
|
||||
keywords: "pair device qr tailscale",
|
||||
available: "desktop",
|
||||
},
|
||||
{
|
||||
tab: "pairing",
|
||||
label: "pair.screenActive.title",
|
||||
description: "pair.screenActive.description",
|
||||
target: "settings-keep-screen-active",
|
||||
keywords: "display sleep awake local",
|
||||
available: "desktop",
|
||||
},
|
||||
{ tab: "experimental", label: "settings.tab.experimental" },
|
||||
{ tab: "about", label: "settings.tab.about", keywords: "version license credits" },
|
||||
{ tab: "general", label: "settings.general.row.language.title", target: "settings-language" },
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { createEffect, createMemo, on, onCleanup, onMount, Show, Switch, Match, type Accessor } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
@@ -19,7 +18,6 @@ import { SettingsAppearance } from "./appearance/appearance"
|
||||
import { SettingsExperimental } from "./experimental/experimental"
|
||||
import { SettingsKeybinds } from "./keybinds/keybinds"
|
||||
import { SettingsNotifications } from "./notifications/notifications"
|
||||
import { SettingsPairing } from "./pairing/pairing"
|
||||
import { SettingsProviders } from "./providers/providers"
|
||||
import { SettingsModels } from "./models/models"
|
||||
import { SettingsServerGeneral } from "./servers/servers"
|
||||
@@ -43,7 +41,6 @@ const rootClientTabs = [
|
||||
{ value: "appearance", icon: pageIcons.appearance, label: "settings.general.section.appearance" },
|
||||
{ value: "notifications", icon: pageIcons.notifications, label: "settings.tab.notifications" },
|
||||
{ value: "shortcuts", icon: pageIcons.shortcuts, label: "settings.tab.shortcuts" },
|
||||
{ value: "pairing", icon: pageIcons.pairing, label: "settings.pairing.title" },
|
||||
] as const
|
||||
|
||||
const serverTabs = [
|
||||
@@ -190,7 +187,6 @@ function RootSettings() {
|
||||
const tabs = useTabs()
|
||||
const servers = useServerCollectionController()
|
||||
const inventory = useSettingsServers()
|
||||
const platform = usePlatform()
|
||||
const [state, setState] = createStore({ worktreeFilterReset: 0 })
|
||||
const list = servers.collection.items
|
||||
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
|
||||
@@ -220,11 +216,7 @@ function RootSettings() {
|
||||
<DialogServer mode="add" onSave={(server) => surface.openServer(ServerConnection.key(server))} />
|
||||
))
|
||||
const groups = createMemo<SettingsNavGroup[]>(() => [
|
||||
{
|
||||
items: rootClientTabs
|
||||
.filter((item) => item.value !== "pairing" || !!platform.pair)
|
||||
.map((item) => ({ ...item, label: language.t(item.label) })),
|
||||
},
|
||||
{ items: rootClientTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
|
||||
...(multiple()
|
||||
? [
|
||||
{
|
||||
@@ -290,9 +282,6 @@ function RootSettings() {
|
||||
<Tabs.Content value="shortcuts" class="settings-panel">
|
||||
<SettingsKeybinds active={surface.view().tab === "shortcuts"} autofocus={!surface.search.state.selected} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="pairing" class="settings-panel">
|
||||
<SettingsPairing />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="experimental" class="settings-panel">
|
||||
<SettingsExperimental />
|
||||
</Tabs.Content>
|
||||
|
||||
@@ -11,7 +11,6 @@ export type SettingsRootTab =
|
||||
| "appearance"
|
||||
| "notifications"
|
||||
| "shortcuts"
|
||||
| "pairing"
|
||||
| "projects"
|
||||
| "workspaces"
|
||||
| "providers"
|
||||
@@ -45,7 +44,6 @@ const rootTabs: Record<SettingsRootTab, true> = {
|
||||
appearance: true,
|
||||
notifications: true,
|
||||
shortcuts: true,
|
||||
pairing: true,
|
||||
projects: true,
|
||||
workspaces: true,
|
||||
providers: true,
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useCommand, type CommandOption } from "./command"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { DialogSsh } from "@/servers/ssh/dialog"
|
||||
import { useUpdaterAction } from "@/shell/updates/action"
|
||||
import { useSettingsSurface } from "@/settings/surface"
|
||||
|
||||
export function DesktopCommands() {
|
||||
const command = useCommand()
|
||||
@@ -41,25 +40,3 @@ export function DesktopCommands() {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function DesktopPairingCommand() {
|
||||
const command = useCommand()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const settings = useSettingsSurface()
|
||||
|
||||
command.register("desktop-pairing", () =>
|
||||
platform.platform === "desktop" && platform.pair
|
||||
? [
|
||||
{
|
||||
id: "server.pair",
|
||||
title: language.t("command.server.pair"),
|
||||
category: language.t("command.category.server"),
|
||||
onSelect: () => settings.open("pairing"),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Route, useNavigate, useParams } from "@solidjs/router"
|
||||
import { createMemo, lazy, onMount, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import { Route, useParams } from "@solidjs/router"
|
||||
import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import { Home } from "@/home/route"
|
||||
import { ServerProvider } from "@/runtime/server/current"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
@@ -10,8 +10,6 @@ import { LayoutProvider } from "@/shell/state/layout"
|
||||
import { SettingsSurfaceProvider } from "@/settings/surface"
|
||||
import Shell from "@/shell/shell"
|
||||
import { requireServerKey } from "./session"
|
||||
import { decodePairingUrl } from "@/servers/connect/pairing"
|
||||
import { DesktopPairingCommand } from "@/shell/commands/desktop"
|
||||
|
||||
export const File = lazy(() => import("@opencode/session-ui/file").then((module) => ({ default: module.File })))
|
||||
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
|
||||
@@ -20,9 +18,6 @@ const SettingsScreen = lazy(() => import("@/settings/shell").then((module) => ({
|
||||
const ConnectServerScreen = lazy(() =>
|
||||
import("@/servers/connect/screen").then((module) => ({ default: module.ConnectServerScreen })),
|
||||
)
|
||||
const ConnectLocalScreen = lazy(() =>
|
||||
import("@/servers/connect/screen").then((module) => ({ default: module.ConnectLocalScreen })),
|
||||
)
|
||||
const TargetSessionRouteContent = lazy(() =>
|
||||
loadSessionRoute().then((module) => ({ default: module.TargetSessionRouteContent })),
|
||||
)
|
||||
@@ -31,7 +26,6 @@ export function preloadRoute(url: string) {
|
||||
const pathname = url.split(/[?#]/, 1)[0]
|
||||
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
|
||||
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
|
||||
if (pathname === "/connect") return ConnectServerScreen.preload().then(() => undefined)
|
||||
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
|
||||
return TargetSessionRouteContent.preload().then(() => undefined)
|
||||
return Promise.resolve()
|
||||
@@ -39,53 +33,32 @@ export function preloadRoute(url: string) {
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<>
|
||||
<Route path="/connect" component={ConnectRoute} />
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/settings" component={SettingsScreen} />
|
||||
<Route
|
||||
path="/server/:serverKey/session/:id"
|
||||
component={() => (
|
||||
<SessionRouteFrame>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
</Suspense>
|
||||
</SessionRouteFrame>
|
||||
)}
|
||||
/>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
</>
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/settings" component={SettingsScreen} />
|
||||
<Route
|
||||
path="/server/:serverKey/session/:id"
|
||||
component={() => (
|
||||
<SessionRouteFrame>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
</Suspense>
|
||||
</SessionRouteFrame>
|
||||
)}
|
||||
/>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectRoute() {
|
||||
const navigate = useNavigate()
|
||||
const servers = useServers()
|
||||
const pairing = decodePairingUrl(location.search, location.origin) ?? decodePairingUrl(location.hash)
|
||||
// From the hosted https page, http is mixed content and loopback is the scanning device itself.
|
||||
const url =
|
||||
pairing?.urls.find((item) => item === location.origin) ??
|
||||
pairing?.urls.find((item) => location.protocol !== "https:" || item.startsWith("https:"))
|
||||
onMount(() => {
|
||||
if (!pairing || !url) return
|
||||
servers.add({ type: "http", http: { url, password: pairing.password } })
|
||||
navigate("/", { replace: true })
|
||||
})
|
||||
if (!pairing) return <ConnectServerScreen onConnect={() => navigate("/", { replace: true })} />
|
||||
if (!url) return <ConnectLocalScreen urls={pairing.urls} />
|
||||
return null
|
||||
}
|
||||
|
||||
function TargetServerRoute(props: ParentProps) {
|
||||
const params = useParams<{ serverKey: string }>()
|
||||
const global = useGlobal()
|
||||
@@ -106,7 +79,6 @@ function AppLayout(props: ParentProps) {
|
||||
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<DesktopPairingCommand />
|
||||
<BrowserAttachmentsProvider>
|
||||
<Shell>{props.children}</Shell>
|
||||
</BrowserAttachmentsProvider>
|
||||
|
||||
@@ -10,10 +10,6 @@ test("settings has its own layout route", () => {
|
||||
expect(currentRoute("/settings", "")).toEqual({ type: "settings" })
|
||||
})
|
||||
|
||||
test("connect has its own layout route", () => {
|
||||
expect(currentRoute("/connect", "")).toEqual({ type: "connect" })
|
||||
})
|
||||
|
||||
describe("layout persistence", () => {
|
||||
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
|
||||
const decode = Schema.decodeUnknownSync(schema)
|
||||
|
||||
@@ -67,7 +67,6 @@ export type TabPanes = {
|
||||
export type LayoutRoute =
|
||||
| { type: "home" }
|
||||
| { type: "settings" }
|
||||
| { type: "connect" }
|
||||
| { type: "draft"; draftID: string }
|
||||
| { type: "session"; sessionId: string; server: ServerConnection.Key }
|
||||
|
||||
@@ -107,7 +106,6 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
||||
const parts = pathname.split("/").filter(Boolean)
|
||||
if (parts.length === 0) return { type: "home" }
|
||||
if (parts[0] === "settings") return { type: "settings" }
|
||||
if (parts[0] === "connect") return { type: "connect" }
|
||||
|
||||
if (parts[0] === "new-session") {
|
||||
const draftID = new URLSearchParams(search).get("draftId")
|
||||
|
||||
@@ -303,7 +303,6 @@ export function Titlebar(props: {
|
||||
return
|
||||
}
|
||||
case "settings":
|
||||
case "connect":
|
||||
case "home": {
|
||||
const selection = layout.home.selection()
|
||||
const conn =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { resolveObjectURL } from "node:buffer"
|
||||
import { createDraftStore } from "@/runtime/persistence/drafts"
|
||||
import { createDraftStore, resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
|
||||
function fixture(id: string, getBlob: () => Promise<Blob | null>) {
|
||||
const documents = new Map([
|
||||
@@ -20,7 +20,19 @@ function fixture(id: string, getBlob: () => Promise<Blob | null>) {
|
||||
return { store, documents }
|
||||
}
|
||||
|
||||
test("deduplicates concurrent history and draft reads without invalidating either live reference", async () => {
|
||||
test("loading history and a draft reads no image bytes", async () => {
|
||||
let reads = 0
|
||||
const { store } = fixture("history-cache-lazy", async () => {
|
||||
reads++
|
||||
return new Blob(["shared screenshot"])
|
||||
})
|
||||
const [history, draft] = await Promise.all([store.getItem("history"), store.getItem("draft")])
|
||||
expect(JSON.parse(history!).entries[0].prompt[0].blob).toEqual({ id: "history-cache-lazy" })
|
||||
expect(JSON.parse(draft!).prompt[0].blob).toEqual({ id: "history-cache-lazy" })
|
||||
expect(reads).toBe(0)
|
||||
})
|
||||
|
||||
test("deduplicates concurrent resolves without invalidating either live reference", async () => {
|
||||
const pending = Promise.withResolvers<Blob | null>()
|
||||
const started = Promise.withResolvers<void>()
|
||||
let reads = 0
|
||||
@@ -29,55 +41,33 @@ test("deduplicates concurrent history and draft reads without invalidating eithe
|
||||
started.resolve()
|
||||
return pending.promise
|
||||
})
|
||||
const history = store.getItem("history")
|
||||
const draft = store.getItem("draft")
|
||||
await store.getItem("history")
|
||||
const first = resolveBlobUrl({ id: "history-cache-concurrent" })
|
||||
const second = resolveBlobUrl({ id: "history-cache-concurrent" })
|
||||
await started.promise
|
||||
pending.resolve(new Blob(["shared screenshot"]))
|
||||
const [saved, active] = await Promise.all([history, draft])
|
||||
const reference = JSON.parse(saved!).entries[0].prompt[0].blob
|
||||
expect(JSON.parse(active!).prompt[0].blob).toEqual(reference)
|
||||
const [a, b] = await Promise.all([first, second])
|
||||
expect(a).toBe(b!)
|
||||
expect(reads).toBe(1)
|
||||
await store.removeItem("history")
|
||||
expect(await resolveObjectURL(reference.url)?.text()).toBe("shared screenshot")
|
||||
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
|
||||
expect(await resolveObjectURL(a!)?.text()).toBe("shared screenshot")
|
||||
expect(await resolveBlobUrl({ id: "history-cache-concurrent" })).toBe(a!)
|
||||
expect(reads).toBe(1)
|
||||
})
|
||||
|
||||
test("hydrates repeated references once within one history document", async () => {
|
||||
let reads = 0
|
||||
const { store, documents } = fixture("history-cache-repeated", async () => {
|
||||
reads++
|
||||
return new Blob(["repeated screenshot"])
|
||||
})
|
||||
documents.set(
|
||||
"history",
|
||||
JSON.stringify({
|
||||
entries: Array.from({ length: 100 }, () => ({
|
||||
prompt: [{ type: "image", blob: { id: "history-cache-repeated" } }],
|
||||
})),
|
||||
}),
|
||||
)
|
||||
const value = JSON.parse((await store.getItem("history"))!)
|
||||
expect(value.entries).toHaveLength(100)
|
||||
expect(
|
||||
new Set(value.entries.map((entry: { prompt: { blob: { url: string } }[] }) => entry.prompt[0].blob.url)).size,
|
||||
).toBe(1)
|
||||
expect(reads).toBe(1)
|
||||
})
|
||||
|
||||
test("reuses a live URL on remount but reads the latest document", async () => {
|
||||
test("a document re-read while its image is live gets the URL back without a read", async () => {
|
||||
let reads = 0
|
||||
const { store, documents } = fixture("history-cache-remount", async () => {
|
||||
reads++
|
||||
return new Blob(["saved screenshot"])
|
||||
})
|
||||
const first = JSON.parse((await store.getItem("history"))!)
|
||||
const url = await resolveBlobUrl({ id: "history-cache-remount" })
|
||||
const changed = JSON.parse(documents.get("history")!)
|
||||
changed.entries[0].prompt.unshift({ type: "text", content: "new admission" })
|
||||
documents.set("history", JSON.stringify(changed))
|
||||
const second = JSON.parse((await store.getItem("history"))!)
|
||||
expect(second.entries[0].prompt[0].content).toBe("new admission")
|
||||
expect(second.entries[0].prompt[1].blob).toEqual(first.entries[0].prompt[0].blob)
|
||||
expect(second.entries[0].prompt[1].blob).toEqual({ id: "history-cache-remount", url })
|
||||
expect(reads).toBe(1)
|
||||
})
|
||||
|
||||
@@ -89,33 +79,34 @@ test("reuses a just-stored attachment without a round trip", async () => {
|
||||
})
|
||||
const reference = await store.putBlob(new Blob(["pending admission"]))
|
||||
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob).toEqual(reference)
|
||||
expect(await resolveBlobUrl({ id: reference.id })).toBe(reference.url)
|
||||
expect(reads).toBe(0)
|
||||
expect(await resolveObjectURL(reference.url)?.text()).toBe("pending admission")
|
||||
})
|
||||
|
||||
test("does not retain a missing blob result", async () => {
|
||||
let reads = 0
|
||||
const { store } = fixture("history-cache-missing", async () => (++reads === 1 ? null : new Blob(["arrived"])))
|
||||
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toBeUndefined()
|
||||
expect(JSON.parse((await store.getItem("draft"))!).prompt[0].blob.url).toStartWith("blob:")
|
||||
fixture("history-cache-missing", async () => (++reads === 1 ? null : new Blob(["arrived"])))
|
||||
expect(await resolveBlobUrl({ id: "history-cache-missing" })).toBeUndefined()
|
||||
expect(await resolveBlobUrl({ id: "history-cache-missing" })).toStartWith("blob:")
|
||||
expect(reads).toBe(2)
|
||||
})
|
||||
|
||||
test("retries after a failed blob read", async () => {
|
||||
let reads = 0
|
||||
const { store } = fixture("history-cache-failure", async () => {
|
||||
fixture("history-cache-failure", async () => {
|
||||
if (++reads === 1) throw new Error("temporary storage failure")
|
||||
return new Blob(["recovered"])
|
||||
})
|
||||
await expect(store.getItem("history")).rejects.toThrow("temporary storage failure")
|
||||
expect(JSON.parse((await store.getItem("history"))!).entries[0].prompt[0].blob.url).toStartWith("blob:")
|
||||
await expect(resolveBlobUrl({ id: "history-cache-failure" })).rejects.toThrow("temporary storage failure")
|
||||
expect(await resolveBlobUrl({ id: "history-cache-failure" })).toStartWith("blob:")
|
||||
expect(reads).toBe(2)
|
||||
})
|
||||
|
||||
test("keeps different blob IDs independent", async () => {
|
||||
const reads: string[] = []
|
||||
const store = createDraftStore({
|
||||
get: async () => JSON.stringify(["history-cache-first", "history-cache-second"].map((id) => ({ blob: { id } }))),
|
||||
createDraftStore({
|
||||
get: async () => null,
|
||||
set: async () => [],
|
||||
remove: async () => {},
|
||||
putBlob: async () => "unused",
|
||||
@@ -124,10 +115,8 @@ test("keeps different blob IDs independent", async () => {
|
||||
return new Blob([id])
|
||||
},
|
||||
})
|
||||
const value = JSON.parse((await store.getItem("history"))!)
|
||||
expect(value[0].blob.url).not.toBe(value[1].blob.url)
|
||||
expect(
|
||||
await Promise.all(value.map((item: { blob: { url: string } }) => resolveObjectURL(item.blob.url)?.text())),
|
||||
).toEqual(reads)
|
||||
const urls = await Promise.all(["history-cache-first", "history-cache-second"].map((id) => resolveBlobUrl({ id })))
|
||||
expect(urls[0]).not.toBe(urls[1])
|
||||
expect(await Promise.all(urls.map((url) => resolveObjectURL(url!)?.text()))).toEqual(reads)
|
||||
expect(reads).toEqual(["history-cache-first", "history-cache-second"])
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Schema } from "effect"
|
||||
import type { Platform } from "@/runtime/platform/platform"
|
||||
import { createComposerReady, createComposerState } from "@/composer/state"
|
||||
import { ServerScope } from "@/runtime/server/scope"
|
||||
import { createDraftStore } from "@/runtime/persistence/drafts"
|
||||
import { createDraftStore, resolveBlobUrl } from "@/runtime/persistence/drafts"
|
||||
import { flushPersisted } from "@/runtime/persistence/persist"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
|
||||
@@ -103,15 +103,11 @@ describe("prompt persistence", () => {
|
||||
}),
|
||||
}))
|
||||
await root.session.ready.promise
|
||||
// Bytes stay in the store until the image is shown or sent.
|
||||
expect(root.session.current()).toEqual([
|
||||
{
|
||||
type: "image",
|
||||
id: "image",
|
||||
filename: "image.png",
|
||||
mime: "image/png",
|
||||
blob: { id: "composer-image", url: expect.stringMatching(/^blob:/) },
|
||||
},
|
||||
{ type: "image", id: "image", filename: "image.png", mime: "image/png", blob: { id: "composer-image", url: "" } },
|
||||
])
|
||||
expect(await resolveBlobUrl(root.session.current()[0]!.blob)).toStartWith("blob:")
|
||||
root.session.set([{ type: "text", content: "hello", start: 0, end: 5 }, ...root.session.current()])
|
||||
flushPersisted()
|
||||
await Bun.sleep(0)
|
||||
@@ -237,7 +233,7 @@ describe("prompt persistence", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("moves image data URLs into blobs and hydrates object URLs", async () => {
|
||||
test("moves image data URLs into blobs and resolves object URLs on demand", async () => {
|
||||
const documents = new Map<string, string>()
|
||||
const blobs = new Map<string, Blob>()
|
||||
const store = createDraftStore({
|
||||
@@ -258,8 +254,8 @@ test("moves image data URLs into blobs and hydrates object URLs", async () => {
|
||||
await store.setItem("prompt", JSON.stringify({ prompt: [{ type: "image", dataUrl: "data:image/png;base64,YQ==" }] }))
|
||||
expect(documents.get("prompt")).not.toContain("dataUrl")
|
||||
const value = JSON.parse((await store.getItem("prompt"))!)
|
||||
expect(value.prompt[0].blob.id).toBe("1")
|
||||
expect(value.prompt[0].blob.url).toStartWith("blob:")
|
||||
expect(value.prompt[0].blob).toEqual({ id: "1" })
|
||||
expect(await resolveBlobUrl(value.prompt[0].blob)).toStartWith("blob:")
|
||||
})
|
||||
|
||||
test("does not let delayed blob migration overwrite a newer draft", async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Keymap } from "../../tui/src/context/keymap"
|
||||
export function ErrorOverlay(props: { component: string; error: unknown; onClose: () => void }) {
|
||||
const renderer = useRenderer()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme("elevated")
|
||||
const theme = useTheme().surface("dialog")
|
||||
const focus = renderer.currentFocusedRenderable
|
||||
onCleanup(Keymap.use().mode.push("modal"))
|
||||
Keymap.createLayer(() => ({
|
||||
@@ -36,17 +36,17 @@ export function ErrorOverlay(props: { component: string; error: unknown; onClose
|
||||
<Dialog centered onClose={props.onClose}>
|
||||
<box maxHeight={Math.max(1, dimensions().height - 3)} paddingX={2} paddingBottom={1} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
||||
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
|
||||
<text fg={theme.text.feedback.error.base} attributes={TextAttributes.BOLD}>
|
||||
Error while hot reloading
|
||||
</text>
|
||||
<text fg={theme.text.subdued} onMouseUp={props.onClose}>
|
||||
<text fg={theme.text.muted} onMouseUp={props.onClose}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.default}>
|
||||
<text maxHeight={Math.max(1, dimensions().height - 9)} fg={theme.text.base}>
|
||||
{props.error instanceof Error ? props.error.message : String(props.error)}
|
||||
</text>
|
||||
<text flexShrink={0} fg={theme.text.subdued}>
|
||||
<text flexShrink={0} fg={theme.text.muted}>
|
||||
{props.component} · Fix the component and save to retry.
|
||||
</text>
|
||||
</box>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { EOL } from "os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { renderUnicodeCompact } from "uqr"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
@@ -19,8 +18,6 @@ export default Runtime.handler(
|
||||
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
|
||||
)).urls
|
||||
const info = { urls, username: "opencode", password }
|
||||
// Fragment, not query: the credential must never reach app.opencode.ai.
|
||||
const link = `https://app.opencode.ai/connect#${base64Encode(JSON.stringify(info))}`
|
||||
process.stdout.write(
|
||||
[
|
||||
"",
|
||||
@@ -31,13 +28,11 @@ export default Runtime.handler(
|
||||
"",
|
||||
" Scan to pair",
|
||||
"",
|
||||
renderUnicodeCompact(link, { border: 2 })
|
||||
renderUnicodeCompact(JSON.stringify(info), { border: 2 })
|
||||
.split(EOL)
|
||||
.map((line) => " " + line)
|
||||
.join(EOL),
|
||||
"",
|
||||
` Link ${link}`,
|
||||
"",
|
||||
].join(EOL) + EOL,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,16 +10,18 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
|
||||
? Effect.succeed(options.assets)
|
||||
: yield* Effect.cached(load().pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)))
|
||||
return <E, R>(api: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
// Serve the web shell before API authentication so /connect can load credentials in JavaScript.
|
||||
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
|
||||
return yield* api.pipe(
|
||||
Effect.catchIf(isRouteNotFound, () => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
|
||||
)
|
||||
return yield* assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
|
||||
})
|
||||
api.pipe(
|
||||
Effect.catchIf(isRouteNotFound, () =>
|
||||
HttpServerRequest.HttpServerRequest.pipe(
|
||||
Effect.flatMap((request) => {
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
if (url.pathname === "/api" || url.pathname.startsWith("/api/"))
|
||||
return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
|
||||
return assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"
|
||||
import { ServerProcess } from "@opencode/server/process"
|
||||
import { afterAll, describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
@@ -8,69 +7,11 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { WebUi } from "../src/services/web-ui"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
|
||||
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
|
||||
afterAll(() => rm(root, { recursive: true, force: true }))
|
||||
|
||||
describe("web UI", () => {
|
||||
it.live("serves the web shell and assets before server authentication", () =>
|
||||
Effect.gen(function* () {
|
||||
const transform = yield* WebUi.handler({
|
||||
assets: {
|
||||
"index.html": "<html><body>connect</body></html>",
|
||||
"_assets/app.js": "console.log('connect')",
|
||||
"_assets/app.css": "body { color: black; }",
|
||||
"icons/icon.svg": "<svg></svg>",
|
||||
"font.woff2": new Uint8Array([0, 1, 2, 255]),
|
||||
"sw.js": "service worker",
|
||||
},
|
||||
})
|
||||
const server = yield* ServerProcess.start<never, never>(
|
||||
{ hostname: "127.0.0.1", port: 0, password: "secret", database: { path: ":memory:" } },
|
||||
undefined,
|
||||
transform,
|
||||
)
|
||||
const origin = HttpServer.formatAddress(server.address)
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
"/",
|
||||
"/connect?data=%7B%7D",
|
||||
"/workspace/example",
|
||||
"/_assets/app.js",
|
||||
"/_assets/app.css",
|
||||
"/icons/icon.svg",
|
||||
"/font.woff2",
|
||||
"/sw.js",
|
||||
],
|
||||
(pathname) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.forEach(["GET", "HEAD"], (method) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(() => fetch(new URL(pathname, origin), { method }))
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("www-authenticate")).toBeNull()
|
||||
yield* Effect.promise(() => response.arrayBuffer())
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* Effect.forEach(["/api", "/api/info", "/api/event", "/api/missing", "/openapi.json"], (pathname) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(() => fetch(new URL(pathname, origin)))
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
|
||||
yield* Effect.promise(() => response.arrayBuffer())
|
||||
}),
|
||||
)
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/info", origin), { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toHaveProperty("pid")
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
|
||||
test("falls back from API routes to assets and the SPA index", async () => {
|
||||
const index = path.join(root, "index.html")
|
||||
const asset = path.join(root, "app.js")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
process.chdir(fileURLToPath(new URL("..", import.meta.url)))
|
||||
|
||||
await $`rm -rf dist`
|
||||
await $`bun tsc -p tsconfig.build.json`
|
||||
const root = path.resolve("src")
|
||||
const result = await Bun.build({
|
||||
entrypoints: await Array.fromAsync(new Bun.Glob("**/*.ts").scan({ cwd: root, absolute: true })).then((files) =>
|
||||
files.filter((file) => !file.endsWith(".d.ts")),
|
||||
),
|
||||
root,
|
||||
outdir: "dist",
|
||||
target: "node",
|
||||
format: "esm",
|
||||
packages: "external",
|
||||
splitting: true,
|
||||
naming: {
|
||||
entry: "[dir]/[name].[ext]",
|
||||
chunk: "chunks/[name]-[hash].[ext]",
|
||||
},
|
||||
})
|
||||
if (!result.success) throw new AggregateError(result.logs, "Failed to build Client")
|
||||
|
||||
@@ -131,7 +131,9 @@ export const ensure = Effect.fn("service.ensure")(function* (options: EnsureOpti
|
||||
}).pipe(
|
||||
Effect.repeat({
|
||||
until: Option.isSome,
|
||||
schedule: Schedule.max([Schedule.spaced(timing.pollInterval), Schedule.recurs(timing.attempts)]),
|
||||
// Probes run sequentially, so a slow probe stretches each iteration; bound the loop by wall clock
|
||||
// like the Promise variant rather than by attempt count.
|
||||
schedule: Schedule.spaced(timing.pollInterval).pipe(Schedule.upTo({ duration: timing.promiseTimeout })),
|
||||
}),
|
||||
Effect.ensuring(Effect.sync(() => contenders.forEach((contender) => contender.release()))),
|
||||
)
|
||||
|
||||
@@ -8,7 +8,9 @@ export type LocationPublicRef = { directory: string }
|
||||
|
||||
export type ModelRef = { id: string; providerID: string; variant?: string }
|
||||
|
||||
export type ProviderSettings = { [x: string]: any }
|
||||
export type ProviderCompaction = { type: "summary" } | { type: "native" }
|
||||
|
||||
export type ProviderTransport = "http" | "websocket"
|
||||
|
||||
export type AgentColor = string
|
||||
|
||||
@@ -218,19 +220,8 @@ export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning
|
||||
|
||||
export type ModelMaxTokensField = "max_completion_tokens" | "max_tokens"
|
||||
|
||||
export type ProviderCompaction = { mode: "local" } | { mode: "provider"; threshold?: number }
|
||||
|
||||
export type ProviderTransport = "http" | "websocket"
|
||||
|
||||
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
|
||||
|
||||
export type ModelVariant = {
|
||||
id: string
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type MoneyUSDPerMillionTokens = number
|
||||
|
||||
export type GenerateTextResponse = { data: { text: string } }
|
||||
@@ -465,11 +456,23 @@ export type V2EventServerConnected = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type ProviderRequest = {
|
||||
settings: ProviderSettings
|
||||
headers: { [x: string]: string }
|
||||
body: { [x: string]: any }
|
||||
}
|
||||
export type ModelSettings = { compaction?: ProviderCompaction } & { [x: string]: any }
|
||||
|
||||
export type ConfigModelSettings = { compaction?: ProviderCompaction } & { [x: string]: JsonValue | null }
|
||||
|
||||
export type ProviderSettings = {
|
||||
timeout?: number | false
|
||||
chunkTimeout?: number
|
||||
compaction?: ProviderCompaction
|
||||
transport?: ProviderTransport
|
||||
} & { [x: string]: any }
|
||||
|
||||
export type ConfigProviderSettings = {
|
||||
timeout?: number | false
|
||||
chunkTimeout?: number
|
||||
compaction?: ProviderCompaction
|
||||
transport?: ProviderTransport
|
||||
} & { [x: string]: JsonValue | null }
|
||||
|
||||
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
||||
|
||||
@@ -1443,20 +1446,6 @@ export type ModelCompatibility = {
|
||||
supportsPromptCacheKey?: boolean
|
||||
}
|
||||
|
||||
export type ProviderInfo = {
|
||||
id: string
|
||||
canonical?: string
|
||||
integrationID?: string
|
||||
name: string
|
||||
activation: "auto" | "enabled" | "disabled"
|
||||
package: string
|
||||
compaction?: ProviderCompaction
|
||||
transport?: ProviderTransport
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ModelCost = {
|
||||
tier?: { type: "context"; size: number }
|
||||
input: MoneyUSDPerMillionTokens
|
||||
@@ -1671,6 +1660,31 @@ export type SessionInboxMove = {
|
||||
payload: SessionInboxMovePayload
|
||||
}
|
||||
|
||||
export type ModelVariant = {
|
||||
id: string
|
||||
settings?: ModelSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ProviderRequest = {
|
||||
settings: ProviderSettings
|
||||
headers: { [x: string]: string }
|
||||
body: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type ProviderInfo = {
|
||||
id: string
|
||||
canonical?: string
|
||||
integrationID?: string
|
||||
name: string
|
||||
activation: "auto" | "enabled" | "disabled"
|
||||
package: string
|
||||
settings?: ProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type PermissionRuleset = Array<PermissionRule>
|
||||
|
||||
export type SessionRevertStaged = {
|
||||
@@ -1854,29 +1868,6 @@ export type FormField =
|
||||
|
||||
export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" }
|
||||
|
||||
export type ModelInfo = {
|
||||
id: string
|
||||
modelID: string
|
||||
providerID: string
|
||||
canonical?: string
|
||||
family?: string
|
||||
name: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
compaction?: ProviderCompaction
|
||||
transport?: ProviderTransport
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
capabilities: ModelCapabilities
|
||||
variants: Array<ModelVariant>
|
||||
time: { released: number }
|
||||
cost: Array<ModelCost>
|
||||
status: "alpha" | "beta" | "deprecated" | "active"
|
||||
enabled: boolean
|
||||
limit: { context: number; input?: number; output: number }
|
||||
}
|
||||
|
||||
export type FormField1 =
|
||||
| FormStringField1
|
||||
| FormNumberField1
|
||||
@@ -1902,6 +1893,27 @@ export type ReferenceInfo = {
|
||||
source: ReferenceSource
|
||||
}
|
||||
|
||||
export type ModelInfo = {
|
||||
id: string
|
||||
modelID: string
|
||||
providerID: string
|
||||
canonical?: string
|
||||
family?: string
|
||||
name: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: ModelSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: any }
|
||||
capabilities: ModelCapabilities
|
||||
variants: Array<ModelVariant>
|
||||
time: { released: number }
|
||||
cost: Array<ModelCost>
|
||||
status: "alpha" | "beta" | "deprecated" | "active"
|
||||
enabled: boolean
|
||||
limit: { context: number; input?: number; output: number }
|
||||
}
|
||||
|
||||
export type AgentInfo = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -2085,31 +2097,27 @@ export type ConfigEntry =
|
||||
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
|
||||
providers?: {
|
||||
[x: string]: {
|
||||
compaction?: ProviderCompaction
|
||||
transport?: ProviderTransport
|
||||
canonical?: string
|
||||
name?: string
|
||||
env?: Array<string>
|
||||
package?: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
settings?: ConfigProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
models?: {
|
||||
[x: string]: {
|
||||
compaction?: ProviderCompaction
|
||||
transport?: ProviderTransport
|
||||
modelID?: string
|
||||
family?: string
|
||||
name?: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
settings?: ConfigModelSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
capabilities?: ModelCapabilities
|
||||
variants?: Array<{
|
||||
id: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
settings?: ConfigModelSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
}>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export type EnsureTiming = {
|
||||
readonly pollInterval: number
|
||||
readonly attempts: number
|
||||
readonly requestTimeout: number
|
||||
readonly spawnDelay: number
|
||||
readonly maxSpawnDelay: number
|
||||
@@ -11,9 +10,11 @@ export type EnsureTiming = {
|
||||
|
||||
const timings = new WeakMap<object, EnsureTiming>()
|
||||
|
||||
// A freshly spawned service registers in ~250 ms, so the poll cadence is a large share of the
|
||||
// time a client waits for it. Probes are sequential, so the wait between them only matters while
|
||||
// the connection is refused; both variants give up after promiseTimeout of wall-clock time.
|
||||
export const defaultEnsureTiming: EnsureTiming = {
|
||||
pollInterval: 100,
|
||||
attempts: 1_200,
|
||||
pollInterval: 25,
|
||||
requestTimeout: 2_000,
|
||||
spawnDelay: 5_000,
|
||||
maxSpawnDelay: 30_000,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { withEnsureTiming } from "../../src/service-timing"
|
||||
|
||||
const timing = {
|
||||
pollInterval: 20,
|
||||
attempts: 120,
|
||||
requestTimeout: 100,
|
||||
spawnDelay: 200,
|
||||
maxSpawnDelay: 1_200,
|
||||
|
||||
@@ -15,8 +15,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
## Source and execution model
|
||||
|
||||
- [x] JavaScript parsed with the latest syntax accepted by Acorn, then restricted by the interpreter allowlist.
|
||||
- [x] Erasable TypeScript syntax, including type annotations, type declarations, assertions, and non-null assertions.
|
||||
TypeScript is transpiled first; the emitted JavaScript must still use the supported subset.
|
||||
TypeScript-only syntax is rejected rather than stripped before execution.
|
||||
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
|
||||
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
|
||||
- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as
|
||||
@@ -41,8 +40,6 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
|
||||
- [ ] Strict-mode early errors: duplicate parameter names, `yield` as an identifier, and a trailing comma after a
|
||||
rest parameter are accepted unless the program itself begins with `"use strict"`.
|
||||
- [ ] Valid JavaScript rejected by TypeScript transpilation before interpretation, such as `in` inside a destructuring
|
||||
default in a `for...of` head and Unicode-escaped keywords.
|
||||
|
||||
## Values and literals
|
||||
|
||||
@@ -494,9 +491,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
|
||||
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
|
||||
`catch`.
|
||||
- [x] Source locations on unsupported-syntax diagnostics for JavaScript-shaped input; TypeScript transpilation may
|
||||
shift them. The diagnostic names the rejected node type and attaches a short orientation to the supported
|
||||
subset; this matrix is the full reference.
|
||||
- [x] Source locations on unsupported-syntax diagnostics. The diagnostic names the rejected node type and attaches a
|
||||
short orientation to the supported subset; this matrix is the full reference.
|
||||
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
|
||||
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
|
||||
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -19,12 +19,6 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#transpile": {
|
||||
"workerd": "./src/interpreter/transpile.workerd.ts",
|
||||
"default": "./src/interpreter/transpile.node.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun run script/build.ts",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
@@ -32,8 +26,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
|
||||
@@ -15,7 +15,7 @@ const pkg = JSON.parse(originalText) as {
|
||||
name: string
|
||||
version: string
|
||||
exports: Record<string, string | { import: string; types: string }>
|
||||
imports: Record<string, Record<string, string>>
|
||||
imports?: Record<string, Record<string, string>>
|
||||
}
|
||||
const tarball = `${pkg.name.replace("@", "").replace("/", "-")}-${pkg.version}.tgz`
|
||||
const output = (value: string, types = false) =>
|
||||
@@ -41,12 +41,14 @@ try {
|
||||
]
|
||||
}),
|
||||
)
|
||||
pkg.imports = Object.fromEntries(
|
||||
Object.entries(pkg.imports).map(([key, conditions]) => [
|
||||
key,
|
||||
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
|
||||
]),
|
||||
)
|
||||
if (pkg.imports) {
|
||||
pkg.imports = Object.fromEntries(
|
||||
Object.entries(pkg.imports).map(([key, conditions]) => [
|
||||
key,
|
||||
Object.fromEntries(Object.entries(conditions).map(([condition, value]) => [condition, output(value)])),
|
||||
]),
|
||||
)
|
||||
}
|
||||
await Bun.write("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
await rm(tarball, { force: true })
|
||||
await $`bun pm pack`
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { parse, type Program } from "acorn"
|
||||
import { Cause, Effect, Scope } from "effect"
|
||||
// #transpile: conditional import — full typescript on node/bun, an identity
|
||||
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
|
||||
import { transpile } from "#transpile"
|
||||
import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../codemode.js"
|
||||
import { toBoundary } from "../data.js"
|
||||
import { ToolRuntime } from "../tool-runtime.js"
|
||||
import { normalizeError } from "./errors.js"
|
||||
import { createBuiltins } from "./intrinsics.js"
|
||||
import { PendingThrow } from "./model.js"
|
||||
import { Pending } from "./promises.js"
|
||||
import { Interpreter } from "./interpreter.js"
|
||||
|
||||
@@ -110,16 +106,7 @@ export const executeProgram = <R>(
|
||||
}
|
||||
|
||||
const parseProgram = (code: string): Program => {
|
||||
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`)
|
||||
|
||||
if (transpiled.error !== undefined) {
|
||||
throw new PendingThrow("SyntaxError", `Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
|
||||
}
|
||||
|
||||
const bodyStart = transpiled.outputText.indexOf("{") + 1
|
||||
const bodyEnd = transpiled.outputText.lastIndexOf("}")
|
||||
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
|
||||
return parse(executableCode, {
|
||||
return parse(code, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "script",
|
||||
allowReturnOutsideFunction: true,
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||
|
||||
export interface TranspileResult {
|
||||
readonly outputText: string
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// Full TypeScript transpilation on node/bun runtimes.
|
||||
export const transpile = (source: string): TranspileResult => {
|
||||
const transpiled = transpileModule(source, {
|
||||
reportDiagnostics: true,
|
||||
compilerOptions: {
|
||||
target: ScriptTarget.ESNext,
|
||||
module: ModuleKind.ESNext,
|
||||
},
|
||||
})
|
||||
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
|
||||
if (diagnostic) {
|
||||
return {
|
||||
outputText: transpiled.outputText,
|
||||
error: flattenDiagnosticMessageText(diagnostic.messageText, "\n"),
|
||||
}
|
||||
}
|
||||
return { outputText: transpiled.outputText }
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export interface TranspileResult {
|
||||
readonly outputText: string
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
// workerd profile: the typescript compiler is ~11 MiB and probes node
|
||||
// internals at module init, so codemode programs are passed through
|
||||
// untranspiled. Plain-JS programs (the overwhelmingly common case) parse
|
||||
// fine downstream via acorn; TypeScript-only syntax surfaces as a parse
|
||||
// error from the interpreter instead of a transpile diagnostic.
|
||||
export const transpile = (source: string): TranspileResult => ({ outputText: source })
|
||||
@@ -15,6 +15,12 @@ const error = async (code: string) => {
|
||||
return result.error
|
||||
}
|
||||
|
||||
describe("source syntax", () => {
|
||||
test("rejects TypeScript-only syntax", async () => {
|
||||
expect((await error(`const value: number = 1; return value`)).kind).toBe("ParseError")
|
||||
})
|
||||
})
|
||||
|
||||
describe("error identity", () => {
|
||||
test("awaiting the same rejected promise twice yields the same error object", async () => {
|
||||
expect(
|
||||
@@ -101,7 +107,7 @@ describe("host errors escaping built-ins", () => {
|
||||
test("report the location of the call that raised them", async () => {
|
||||
const failure = await error(`return [1].map((n) => n.toFixed(200))`)
|
||||
expect(failure.kind).toBe("ExecutionFailure")
|
||||
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 23)")
|
||||
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 19)")
|
||||
})
|
||||
|
||||
test("a built-in that rejects its arguments before doing any work is located at the call", async () => {
|
||||
@@ -109,13 +115,13 @@ describe("host errors escaping built-ins", () => {
|
||||
})
|
||||
|
||||
test("a rejection born inside a promise the built-in created is located at the creating call", async () => {
|
||||
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 14)")
|
||||
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 14)")
|
||||
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 10)")
|
||||
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 10)")
|
||||
expect((await error(`return await Promise.all({ [Symbol.iterator]: () => ({ next: 1 }) })`)).message).toEndWith(
|
||||
"(line 1, col 14)",
|
||||
"(line 1, col 10)",
|
||||
)
|
||||
expect((await error(`let p; p = Promise.resolve().then(() => p); return await p`)).message).toEndWith(
|
||||
"(line 2, col 5)",
|
||||
"(line 1, col 8)",
|
||||
)
|
||||
})
|
||||
|
||||
@@ -128,7 +134,7 @@ describe("host errors escaping built-ins", () => {
|
||||
|
||||
test("a failure inside a built-in called by another built-in is located at the outer call", async () => {
|
||||
const failure = await error(`return Array.from({ [Symbol.iterator]: () => ({ next: 1 }) })`)
|
||||
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 8)")
|
||||
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 4)")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -147,7 +153,7 @@ describe("call depth", () => {
|
||||
test("uncaught overflow reports the call that overflowed", async () => {
|
||||
const failure = await error(`const f = (n) => f(n + 1); return f(0)`)
|
||||
expect(failure.kind).toBe("ExecutionFailure")
|
||||
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 18)")
|
||||
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 14)")
|
||||
})
|
||||
|
||||
test("the limit is 10000 nested calls", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.7",
|
||||
"version": "2.0.8",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -34,7 +34,7 @@ import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode/ai/rou
|
||||
import { ProviderShared } from "@opencode/ai/protocols/shared"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { makeParser } from "effect/unstable/encoding/Sse"
|
||||
import type { ID, Info } from "./model.js"
|
||||
import type { ID, RuntimeInfo } from "./model.js"
|
||||
import { Provider } from "./provider.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
@@ -46,14 +46,14 @@ type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result"
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
|
||||
export interface SDKEvent {
|
||||
readonly model: Info
|
||||
readonly model: RuntimeInfo
|
||||
readonly package: string
|
||||
readonly options: Record<string, any>
|
||||
sdk?: SDK
|
||||
}
|
||||
|
||||
export interface LanguageEvent {
|
||||
readonly model: Info
|
||||
readonly model: RuntimeInfo
|
||||
readonly sdk: SDK
|
||||
readonly options: Record<string, any>
|
||||
language?: LanguageModelV3
|
||||
@@ -116,7 +116,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
})
|
||||
}
|
||||
|
||||
function prepareOptions(model: Info, pkg: string) {
|
||||
function prepareOptions(model: RuntimeInfo, pkg: string) {
|
||||
const projected = mapBodyToProviderOptions(model, pkg)
|
||||
const options: Record<string, any> = {
|
||||
name: model.canonical ?? model.providerID,
|
||||
@@ -128,6 +128,8 @@ function prepareOptions(model: Info, pkg: string) {
|
||||
const customFetch = options.fetch
|
||||
const chunkTimeout = options.chunkTimeout
|
||||
delete options.chunkTimeout
|
||||
delete options.compaction
|
||||
delete options.transport
|
||||
options.fetch = async (input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
const opts = { ...(init ?? {}) }
|
||||
const signals = [
|
||||
@@ -180,8 +182,8 @@ export interface Interface {
|
||||
}
|
||||
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
|
||||
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
|
||||
readonly language: (model: Info) => Effect.Effect<LanguageModelV3, InitError>
|
||||
readonly model: (model: Info) => Effect.Effect<LanguageModel, InitError>
|
||||
readonly language: (model: RuntimeInfo) => Effect.Effect<LanguageModelV3, InitError>
|
||||
readonly model: (model: RuntimeInfo) => Effect.Effect<LanguageModel, InitError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AISDK") {}
|
||||
@@ -300,7 +302,7 @@ export const locationLayer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
function modelFromLanguage(info: RuntimeInfo, language: LanguageModelV3) {
|
||||
const packageName = Provider.packageName(info.package!)
|
||||
const projected = mapBodyToProviderOptions(info, packageName)
|
||||
const providerID = info.canonical ?? info.providerID
|
||||
@@ -388,13 +390,16 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
|
||||
if (settings === undefined) return undefined
|
||||
const result = Object.fromEntries(
|
||||
Object.entries(settings).filter(
|
||||
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
|
||||
([key]) =>
|
||||
!["apiKey", "authToken", "baseURL", "chunkTimeout", "compaction", "fetch", "timeout", "transport"].includes(
|
||||
key,
|
||||
),
|
||||
),
|
||||
)
|
||||
return Object.keys(result).length === 0 ? undefined : result
|
||||
}
|
||||
|
||||
function mapBodyToProviderOptions(model: Info, packageName: string) {
|
||||
function mapBodyToProviderOptions(model: RuntimeInfo, packageName: string) {
|
||||
const settings = requestSettings(model.settings)
|
||||
const pro = Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning)
|
||||
const forceReasoning =
|
||||
|
||||
@@ -68,8 +68,6 @@ export const Plugin = define({
|
||||
if (item.canonical !== undefined) provider.canonical = item.canonical
|
||||
if (item.name !== undefined) provider.name = item.name
|
||||
if (item.package !== undefined) provider.package = item.package
|
||||
if (item.compaction !== undefined) provider.compaction = { ...item.compaction }
|
||||
if (item.transport !== undefined) provider.transport = item.transport
|
||||
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
|
||||
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
|
||||
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
|
||||
@@ -116,8 +114,6 @@ export const Plugin = define({
|
||||
if (config.compatibility !== undefined)
|
||||
model.compatibility = { ...model.compatibility, ...config.compatibility }
|
||||
if (config.package !== undefined) model.package = config.package
|
||||
if (config.compaction !== undefined) model.compaction = { ...config.compaction }
|
||||
if (config.transport !== undefined) model.transport = config.transport
|
||||
if (config.settings !== undefined) model.settings = Provider.mergeOverlay(model.settings, config.settings)
|
||||
if (config.headers !== undefined) model.headers = Provider.mergeHeaders(model.headers, config.headers)
|
||||
if (config.body !== undefined) model.body = Provider.mergeOverlay(model.body, config.body)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Watcher from "./watcher.js"
|
||||
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import { createWrapper } from "@parcel/watcher/wrapper.js"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { FileSystem } from "@opencode/schema/filesystem"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
|
||||
+61
-14
@@ -36,6 +36,7 @@ export type Status = Background["status"]
|
||||
|
||||
const decodeBackground = Schema.decodeUnknownResult(Background)
|
||||
const backgroundPrefix = "job.background/"
|
||||
const COMPLETED_LIMIT = 25
|
||||
|
||||
export type Info = {
|
||||
id: string
|
||||
@@ -57,6 +58,7 @@ type Active = {
|
||||
scope: Scope.Closeable
|
||||
blockingSessions: Map<SessionSchema.ID, number>
|
||||
isBackgrounded: boolean
|
||||
consumed: boolean
|
||||
recovery?: Recovery
|
||||
}
|
||||
|
||||
@@ -69,6 +71,7 @@ type FinishResult = {
|
||||
info?: Info
|
||||
done?: Deferred.Deferred<Info>
|
||||
scope?: Scope.Closeable
|
||||
generation?: Scope.Closeable
|
||||
}
|
||||
|
||||
type BackgroundResult = {
|
||||
@@ -81,11 +84,12 @@ type StartResult = { info: Info } | { info: Info; scope: Scope.Closeable }
|
||||
type BlockWait = {
|
||||
done: Deferred.Deferred<Info>
|
||||
backgrounded: Deferred.Deferred<Info>
|
||||
generation: Scope.Closeable
|
||||
}
|
||||
|
||||
type BlockStart =
|
||||
| { type: "missing" }
|
||||
| { type: "finished"; info: Info }
|
||||
| { type: "finished"; info: Info; generation: Scope.Closeable }
|
||||
| { type: "backgrounded"; info: Info }
|
||||
| { type: "wait"; wait: BlockWait }
|
||||
|
||||
@@ -168,6 +172,9 @@ function decrementSession(input: Map<SessionSchema.ID, number>, sessionID: Sessi
|
||||
/**
|
||||
* Makes one scoped, process-local registry. Explicitly recoverable background
|
||||
* work also owns a durable notification marker until its notification is admitted.
|
||||
* Unconsumed results survive the start-to-wait handoff. Foreground block/cancel
|
||||
* and non-recoverable wait results enter a 25-entry consumed history. Recoverable
|
||||
* wait results stay available for background registration and acknowledgment.
|
||||
*/
|
||||
export const make = Effect.gen(function* () {
|
||||
const kv = yield* KV.Service
|
||||
@@ -176,6 +183,19 @@ export const make = Effect.gen(function* () {
|
||||
scope: yield* Scope.Scope,
|
||||
}
|
||||
|
||||
const consume = (id: string, generation: Scope.Closeable) =>
|
||||
SynchronizedRef.update(state.jobs, (jobs) => {
|
||||
const job = jobs.get(id)
|
||||
if (!job || job.scope !== generation || job.info.status === "running" || job.consumed) return jobs
|
||||
const next = new Map(jobs)
|
||||
// Order history by first consumption, not by start time or subsequent reads.
|
||||
next.delete(id)
|
||||
next.set(id, { ...job, consumed: true })
|
||||
const completed = [...next].filter(([, job]) => job.consumed && !job.info.notificationID)
|
||||
for (const [id] of completed.slice(0, -COMPLETED_LIMIT)) next.delete(id)
|
||||
return next
|
||||
})
|
||||
|
||||
const persistBackground = Effect.fnUntraced(function* (job: Active) {
|
||||
if (!job.recovery || !job.info.notificationID) return
|
||||
yield* kv.set(`${backgroundPrefix}${job.info.notificationID}`, {
|
||||
@@ -260,6 +280,7 @@ export const make = Effect.gen(function* () {
|
||||
scope,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
isBackgrounded: false,
|
||||
consumed: false,
|
||||
recovery: input.recovery,
|
||||
}
|
||||
return [{ info: snapshot(job), scope }, new Map(jobs).set(id, job)]
|
||||
@@ -280,12 +301,19 @@ export const make = Effect.gen(function* () {
|
||||
const wait: Interface["wait"] = Effect.fn("Job.wait")(function* (input) {
|
||||
const job = (yield* SynchronizedRef.get(state.jobs)).get(input.id)
|
||||
if (!job) return { timedOut: false }
|
||||
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
|
||||
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
|
||||
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
|
||||
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
|
||||
if (info._tag === "Some") return { info: info.value, timedOut: false }
|
||||
return { info: snapshot(job), timedOut: true }
|
||||
return yield* Effect.gen(function* () {
|
||||
if (job.info.status !== "running") return { info: snapshot(job), timedOut: false }
|
||||
if (input.timeout === undefined) return { info: yield* Deferred.await(job.done), timedOut: false }
|
||||
if (input.timeout <= 0) return { info: snapshot(job), timedOut: true }
|
||||
const info = yield* Deferred.await(job.done).pipe(Effect.timeoutOption(input.timeout))
|
||||
if (info._tag === "Some") return { info: info.value, timedOut: false }
|
||||
return { info: snapshot(job), timedOut: true }
|
||||
}).pipe(
|
||||
// Recoverable wait -> background is a supported handoff, even after failure.
|
||||
Effect.tap((result) =>
|
||||
result.info.status === "running" || job.recovery ? Effect.void : consume(input.id, job.scope),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const removeBlock = Effect.fnUntraced(function* (input: BlockInput) {
|
||||
@@ -303,10 +331,10 @@ export const make = Effect.gen(function* () {
|
||||
const result = yield* SynchronizedRef.modify(state.jobs, (jobs): readonly [BlockStart, Map<string, Active>] => {
|
||||
const job = jobs.get(input.id)
|
||||
if (!job) return [{ type: "missing" }, jobs]
|
||||
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job) }, jobs]
|
||||
if (job.info.status !== "running") return [{ type: "finished", info: snapshot(job), generation: job.scope }, jobs]
|
||||
if (job.isBackgrounded) return [{ type: "backgrounded", info: snapshot(job) }, jobs]
|
||||
return [
|
||||
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded } },
|
||||
{ type: "wait", wait: { done: job.done, backgrounded: job.backgrounded, generation: job.scope } },
|
||||
new Map(jobs).set(input.id, {
|
||||
...job,
|
||||
blockingSessions: incrementSession(job.blockingSessions, input.sessionID),
|
||||
@@ -314,12 +342,18 @@ export const make = Effect.gen(function* () {
|
||||
]
|
||||
})
|
||||
if (result.type === "missing") return undefined
|
||||
if (result.type === "finished") return { type: "finished", info: result.info }
|
||||
if (result.type === "finished") {
|
||||
yield* consume(input.id, result.generation)
|
||||
return { type: "finished", info: result.info }
|
||||
}
|
||||
if (result.type === "backgrounded") return { type: "backgrounded", info: result.info }
|
||||
return yield* Effect.raceFirst(
|
||||
Deferred.await(result.wait.done).pipe(Effect.map((info) => ({ type: "finished" as const, info }))),
|
||||
Deferred.await(result.wait.backgrounded).pipe(Effect.map((info) => ({ type: "backgrounded" as const, info }))),
|
||||
).pipe(Effect.ensuring(removeBlock(input)))
|
||||
).pipe(
|
||||
Effect.tap((outcome) => (outcome.type === "finished" ? consume(input.id, result.wait.generation) : Effect.void)),
|
||||
Effect.ensuring(removeBlock(input)),
|
||||
)
|
||||
})
|
||||
|
||||
const markBackground = Effect.fnUntraced(function* (job: Active) {
|
||||
@@ -383,7 +417,7 @@ export const make = Effect.gen(function* () {
|
||||
Effect.fnUntraced(function* (jobs): Effect.fn.Return<readonly [FinishResult, Map<string, Active>]> {
|
||||
const job = jobs.get(id)
|
||||
if (!job) return [{}, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs]
|
||||
if (job.info.status !== "running") return [{ info: snapshot(job), generation: job.scope }, jobs]
|
||||
const next = {
|
||||
...job,
|
||||
blockingSessions: new Map<SessionSchema.ID, number>(),
|
||||
@@ -394,11 +428,15 @@ export const make = Effect.gen(function* () {
|
||||
},
|
||||
}
|
||||
yield* persistBackground(next)
|
||||
return [{ info: snapshot(next), done: job.done, scope: job.scope }, new Map(jobs).set(id, next)]
|
||||
return [
|
||||
{ info: snapshot(next), done: job.done, scope: job.scope, generation: job.scope },
|
||||
new Map(jobs).set(id, next),
|
||||
]
|
||||
}),
|
||||
)
|
||||
if (result.info && result.done) yield* Deferred.succeed(result.done, result.info)
|
||||
if (result.scope) yield* Scope.close(result.scope, Exit.void)
|
||||
if (result.generation) yield* consume(id, result.generation)
|
||||
return result.info
|
||||
})
|
||||
|
||||
@@ -414,7 +452,16 @@ export const make = Effect.gen(function* () {
|
||||
}).pipe(Effect.withSpan("Job.pendingBackground"))
|
||||
|
||||
const completeBackground: Interface["completeBackground"] = Effect.fn("Job.completeBackground")((notificationID) =>
|
||||
kv.remove(`${backgroundPrefix}${notificationID}`),
|
||||
SynchronizedRef.updateEffect(state.jobs, (jobs) =>
|
||||
Effect.gen(function* () {
|
||||
yield* kv.remove(`${backgroundPrefix}${notificationID}`)
|
||||
const entry = [...jobs].find(([, job]) => job.info.notificationID === notificationID)
|
||||
if (!entry || entry[1].info.status === "running") return jobs
|
||||
const next = new Map(jobs)
|
||||
next.delete(entry[0])
|
||||
return next
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AISDK } from "./aisdk.js"
|
||||
import { Credential } from "./credential.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Capabilities, ID, Info, Model, Ref, VariantID } from "./model.js"
|
||||
import type { RuntimeInfo } from "./model.js"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { Provider } from "./provider.js"
|
||||
|
||||
@@ -117,10 +118,10 @@ export interface Resolved {
|
||||
readonly cost: Info["cost"]
|
||||
/** Catalog token limits used by Core for context management. */
|
||||
readonly limit: Info["limit"]
|
||||
/** Model policy overrides the provider policy; omitted means local compaction. */
|
||||
readonly compaction?: Info["compaction"]
|
||||
/** Model transport overrides the provider transport; omitted means HTTP. */
|
||||
readonly transport?: Info["transport"]
|
||||
/** Model policy overrides the provider policy; omitted means summary compaction. */
|
||||
readonly compaction?: Provider.Compaction
|
||||
/** Provider transport policy; omitted means HTTP. */
|
||||
readonly transport?: Provider.Transport
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
@@ -149,7 +150,7 @@ export const withVariant = (
|
||||
variant
|
||||
? {
|
||||
...model,
|
||||
settings: Provider.mergeOverlay(model.settings, variant.settings),
|
||||
settings: Provider.mergeOverlay(model.settings, Provider.modelSettings(variant.settings)),
|
||||
headers: Provider.mergeHeaders(model.headers, variant.headers),
|
||||
body: Provider.mergeOverlay(model.body, variant.body),
|
||||
}
|
||||
@@ -159,11 +160,11 @@ export const withVariant = (
|
||||
|
||||
export interface Dependencies {
|
||||
readonly loadPackage?: (specifier: string) => Effect.Effect<Provider.ProviderPackage, Provider.LoadError>
|
||||
readonly loadAISDK?: (model: Info) => Effect.Effect<LanguageModel, AISDK.InitError>
|
||||
readonly loadAISDK?: (model: RuntimeInfo) => Effect.Effect<LanguageModel, AISDK.InitError>
|
||||
}
|
||||
|
||||
export const fromCatalogModel = (
|
||||
model: Info,
|
||||
model: RuntimeInfo,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
): Effect.Effect<
|
||||
@@ -178,7 +179,11 @@ export const fromCatalogModel = (
|
||||
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
|
||||
Effect.flatMap((resolved) => {
|
||||
// Reject provider compaction policies up front so the misconfiguration surfaces before any step runs.
|
||||
if (model.compaction?.mode !== "provider" || resolved.route.compact?.trigger || resolved.route.compact?.endpoint)
|
||||
if (
|
||||
model.settings?.compaction?.type !== "native" ||
|
||||
resolved.route.compact?.trigger ||
|
||||
resolved.route.compact?.endpoint
|
||||
)
|
||||
return Effect.succeed(resolved)
|
||||
return Effect.fail(
|
||||
new UnsupportedCompactionError({ providerID: model.providerID, modelID: model.id, route: resolved.route.id }),
|
||||
@@ -187,7 +192,7 @@ export const fromCatalogModel = (
|
||||
)
|
||||
|
||||
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
|
||||
model: Info,
|
||||
model: RuntimeInfo,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
) {
|
||||
@@ -244,7 +249,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
})
|
||||
})
|
||||
|
||||
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
|
||||
function prepareRuntimeModel(model: RuntimeInfo, credential: Credential.Value | undefined) {
|
||||
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
|
||||
return {
|
||||
...model,
|
||||
@@ -256,7 +261,7 @@ function prepareRuntimeModel(model: Info, credential: Credential.Value | undefin
|
||||
}
|
||||
|
||||
function validateProviderVariables(
|
||||
model: Info,
|
||||
model: RuntimeInfo,
|
||||
resolved: LanguageModel,
|
||||
): Effect.Effect<LanguageModel, UnresolvedProviderVariablesError> {
|
||||
const baseURL = resolved.route.endpoint.baseURL
|
||||
@@ -266,7 +271,7 @@ function validateProviderVariables(
|
||||
}
|
||||
|
||||
function prepareProviderSettings(
|
||||
model: Info,
|
||||
model: RuntimeInfo,
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
): Effect.Effect<Readonly<Record<string, unknown>>, UnresolvedProviderVariablesError> {
|
||||
const baseURL = settings.baseURL
|
||||
@@ -276,14 +281,14 @@ function prepareProviderSettings(
|
||||
)
|
||||
}
|
||||
|
||||
function prepareProviderURL(model: Info, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
|
||||
function prepareProviderURL(model: RuntimeInfo, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
|
||||
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
|
||||
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
|
||||
const failure = unresolvedProviderVariables(model, prepared)
|
||||
return failure ? Effect.fail(failure) : Effect.succeed(prepared)
|
||||
}
|
||||
|
||||
function unresolvedProviderVariables(model: Info, baseURL: string) {
|
||||
function unresolvedProviderVariables(model: RuntimeInfo, baseURL: string) {
|
||||
const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]))
|
||||
if (variables.size === 0) return
|
||||
return new UnresolvedProviderVariablesError({
|
||||
@@ -306,14 +311,14 @@ const nativeCredentialSettings = (specifier: string, credential: Credential.Valu
|
||||
return { apiKey: credential.access }
|
||||
}
|
||||
|
||||
const unsupported = (model: Info) =>
|
||||
const unsupported = (model: RuntimeInfo) =>
|
||||
new UnsupportedPackageError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
package: model.package ?? "unknown",
|
||||
})
|
||||
|
||||
const initialization = (model: Info, phase: InitializationPhase, cause: unknown) =>
|
||||
const initialization = (model: RuntimeInfo, phase: InitializationPhase, cause: unknown) =>
|
||||
new ModelInitializationError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
@@ -355,7 +360,11 @@ export const layer = Layer.effect(
|
||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||
)
|
||||
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
|
||||
const runtimeInfo = yield* withVariant(selected, variant)
|
||||
const selectedVariant = yield* withVariant(selected, variant)
|
||||
const runtimeInfo: RuntimeInfo = {
|
||||
...selectedVariant,
|
||||
settings: Provider.mergeOverlay(provider?.settings, Provider.modelSettings(selectedVariant.settings)),
|
||||
}
|
||||
const model = yield* fromCatalogModel(runtimeInfo, credential, {
|
||||
loadPackage: (specifier) => Provider.loadPackage(specifier, npm),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
@@ -377,8 +386,8 @@ export const layer = Layer.effect(
|
||||
capabilities: selected.capabilities,
|
||||
cost: selected.cost,
|
||||
limit: selected.limit,
|
||||
compaction: selected.compaction,
|
||||
transport: selected.transport,
|
||||
compaction: runtimeInfo.settings?.compaction,
|
||||
transport: provider?.settings?.transport,
|
||||
}
|
||||
})
|
||||
return Service.of({
|
||||
@@ -402,7 +411,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function hasConfiguredAuth(model: Info) {
|
||||
function hasConfiguredAuth(model: RuntimeInfo) {
|
||||
return [model.settings?.apiKey, model.settings?.authToken, model.settings?.accessToken].some(
|
||||
(value) => typeof value === "string" && value !== "",
|
||||
)
|
||||
|
||||
@@ -36,6 +36,9 @@ export type Ref = typeof Ref.Type
|
||||
export const Info = Model.Info
|
||||
export type Info = Model.Info
|
||||
|
||||
/** Effective provider and model settings used only while constructing a runtime model. */
|
||||
export type RuntimeInfo = Omit<Info, "settings"> & { readonly settings?: Provider.Settings }
|
||||
|
||||
export type MutableInfo = DeepMutable<Info>
|
||||
|
||||
export { Event } from "@opencode/schema/model"
|
||||
@@ -188,9 +191,10 @@ const layer = Layer.effect(
|
||||
...model,
|
||||
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
|
||||
package: model.package ?? provider?.package,
|
||||
compaction: model.compaction ?? provider?.compaction,
|
||||
transport: model.transport ?? provider?.transport,
|
||||
settings: Provider.mergeOverlay(provider?.settings, model.settings),
|
||||
settings: Provider.mergeOverlay(
|
||||
Provider.modelSettings(provider?.settings),
|
||||
Provider.modelSettings(model.settings),
|
||||
),
|
||||
headers: Provider.mergeHeaders(provider?.headers, model.headers),
|
||||
body: Provider.mergeOverlay(provider?.body, model.body),
|
||||
} satisfies Info
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export * as NativeCompactionPlugin from "./compaction.js"
|
||||
|
||||
import { LLMClient, Message } from "@opencode/ai"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Effect } from "effect"
|
||||
import { SessionCompaction } from "../session/compaction.js"
|
||||
import type { PluginInternal } from "./internal.js"
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.compaction.native",
|
||||
effect: Effect.fn("NativeCompactionPlugin")(function* () {
|
||||
const llm = yield* LLMClient.Service
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
yield* compaction.transform((editor) => {
|
||||
editor.native((input) => {
|
||||
const request = input.request
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" }))
|
||||
return Effect.gen(function* () {
|
||||
const retained = yield* input.retained
|
||||
const result = yield* llm.compact(request, { ...input.options, mechanism: "trigger" })
|
||||
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
|
||||
})
|
||||
if (LLMClient.canCompact(request))
|
||||
return llm.compact(request, { mechanism: "endpoint", http: input.options.http })
|
||||
return undefined
|
||||
})
|
||||
})
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
@@ -453,6 +453,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
tool: {
|
||||
transform: tools.transform,
|
||||
reload: tools.reload,
|
||||
list: tools.list,
|
||||
hook: (name, callback) => hooks.register("tool", name, callback),
|
||||
},
|
||||
vcs: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as PluginInternal from "./internal.js"
|
||||
|
||||
import { LLMClient } from "@opencode/ai"
|
||||
import type { Plugin } from "@opencode/plugin/effect/plugin"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { httpClient } from "@opencode/util/effect/app-node-platform"
|
||||
@@ -12,6 +13,7 @@ import { Provider } from "../provider.js"
|
||||
import { Command } from "../command.js"
|
||||
import { Config } from "../config.js"
|
||||
import { Credential } from "../credential.js"
|
||||
import { llmClient } from "../effect/app-node-platform.js"
|
||||
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
|
||||
import { ConfigCommandPlugin } from "../config/plugin/command.js"
|
||||
import { ConfigCompactionPlugin } from "../config/plugin/compaction.js"
|
||||
@@ -84,6 +86,7 @@ import { WriteTool } from "../tool/plugin/write.js"
|
||||
import { AgentPlugin } from "./agent.js"
|
||||
import BrowserPlugin from "@opencode/plugin-browser"
|
||||
import { CommandPlugin } from "./command.js"
|
||||
import { NativeCompactionPlugin } from "./compaction.js"
|
||||
import { IdentityPlugin } from "./identity.js"
|
||||
import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
@@ -92,6 +95,7 @@ import { ProviderPlugins } from "./provider.js"
|
||||
import { WebSearchPlugins } from "./websearch/index.js"
|
||||
import { SkillPlugin } from "./skill.js"
|
||||
import { VcsHgPlugin } from "./vcs/hg.js"
|
||||
import { ToolInputRepairPlugin } from "./tool-input-repair.js"
|
||||
import { OptimizePlugin } from "./optimize.js"
|
||||
import { VcsGitPlugin } from "./vcs/git.js"
|
||||
import { WarmingPlugin } from "./warming.js"
|
||||
@@ -120,6 +124,7 @@ const services = [
|
||||
Integration.Service,
|
||||
Job.Service,
|
||||
KV.Service,
|
||||
LLMClient.Service,
|
||||
Location.Service,
|
||||
ModelsDev.Service,
|
||||
Mcp.Service,
|
||||
@@ -171,6 +176,7 @@ export const requirements = LayerNode.group([
|
||||
Integration.node,
|
||||
Job.node,
|
||||
KV.node,
|
||||
llmClient,
|
||||
Location.node,
|
||||
ModelsDev.node,
|
||||
Mcp.node,
|
||||
@@ -200,6 +206,7 @@ export const requirements = LayerNode.group([
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
ToolInputRepairPlugin.Plugin,
|
||||
ConfigWorktreePlugin.Plugin,
|
||||
BrowserPlugin,
|
||||
ConfigMcpPlugin.Plugin,
|
||||
@@ -212,6 +219,7 @@ const pre = [
|
||||
SkillPlugin.Plugin,
|
||||
VcsHgPlugin.Plugin,
|
||||
ModelsDevPlugin,
|
||||
NativeCompactionPlugin.Plugin,
|
||||
...ProviderPlugins,
|
||||
...WebSearchPlugins,
|
||||
PatchTool.Plugin,
|
||||
|
||||
@@ -7,7 +7,6 @@ import { App } from "../../app.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { iife } from "../../util/iife.js"
|
||||
import { which } from "../../util/which.js"
|
||||
@@ -140,16 +139,18 @@ export const AzurePlugin = define({
|
||||
)
|
||||
continue
|
||||
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
|
||||
if (resourceName)
|
||||
evt.update(item.provider.id, (provider) => {
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
resourceName,
|
||||
...(typeof provider.settings?.baseURL === "string"
|
||||
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
const websocket = responsesWebSocketCapable(item.provider)
|
||||
if (!resourceName && !websocket) continue
|
||||
evt.update(item.provider.id, (provider) => {
|
||||
provider.settings = {
|
||||
...provider.settings,
|
||||
...(resourceName === undefined ? {} : { resourceName }),
|
||||
...(websocket ? { transport: provider.settings?.transport ?? "websocket" } : {}),
|
||||
...(resourceName !== undefined && typeof provider.settings?.baseURL === "string"
|
||||
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
|
||||
: {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.model.transform((models) => {
|
||||
@@ -167,8 +168,6 @@ export const AzurePlugin = define({
|
||||
draft.settings.baseURL,
|
||||
resolveResourceName(draft.settings, resourceName) ?? resourceName,
|
||||
)
|
||||
if (responsesWebSocketCapable(item.provider, draft))
|
||||
draft.transport = item.provider.transport ?? "websocket"
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -240,9 +239,9 @@ function expandResourceName(baseURL: string, resourceName: string) {
|
||||
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
|
||||
}
|
||||
|
||||
function responsesWebSocketCapable(provider: Provider.Info, model: Model.Info) {
|
||||
if ((model.package ?? provider.package) !== "@opencode/ai/providers/azure/responses") return false
|
||||
const settings = Provider.mergeOverlay(provider.settings, model.settings)
|
||||
function responsesWebSocketCapable(provider: Provider.Info) {
|
||||
if (provider.package !== "@opencode/ai/providers/azure/responses") return false
|
||||
const settings = provider.settings
|
||||
if (settings?.useDeploymentBasedUrls === true) return false
|
||||
if (settings?.apiVersion !== undefined && settings.apiVersion !== "v1") return false
|
||||
if (typeof settings?.baseURL !== "string") return true
|
||||
|
||||
@@ -254,10 +254,13 @@ export const OpenAIPlugin = define({
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
const item = providers.get(Provider.ID.openai)
|
||||
if (!item) return
|
||||
if (!chatgpt) return
|
||||
const account = chatgpt.metadata?.accountID
|
||||
const account = chatgpt?.metadata?.accountID
|
||||
providers.update(item.provider.id, (provider) => {
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, { baseURL: codexBaseURL })
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, {
|
||||
transport: provider.settings?.transport ?? "websocket",
|
||||
...(chatgpt ? { baseURL: codexBaseURL } : {}),
|
||||
})
|
||||
if (!chatgpt) return
|
||||
provider.headers = Provider.mergeHeaders(provider.headers, {
|
||||
originator: "opencode",
|
||||
"x-codex-beta-features": "remote_compaction_v2",
|
||||
@@ -270,7 +273,6 @@ export const OpenAIPlugin = define({
|
||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||
// subscription covers usage, so hide the rest and zero the cost.
|
||||
models.update(model.providerID, model.id, (draft) => {
|
||||
draft.transport = models.provider.get(model.providerID)?.provider.transport ?? "websocket"
|
||||
if (!chatgpt) return
|
||||
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
|
||||
draft.enabled = false
|
||||
|
||||
@@ -6,6 +6,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { IntegrationConnection } from "../../integration/connection.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { WebSearch } from "../../websearch.js"
|
||||
import { ConfigProvider } from "@opencode/schema/config/provider"
|
||||
@@ -131,7 +132,13 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
const config = credential
|
||||
? yield* fetchConfig(http, credential).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
|
||||
Effect.as(
|
||||
IntegrationConnection.key(snapshot.connection) === IntegrationConnection.key(connection)
|
||||
? snapshot.config
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
|
||||
@@ -95,12 +95,13 @@ export const XAIPlugin = define({
|
||||
editor.method.update(device(ctx.app))
|
||||
editor.method.update({ integrationID: "xai", method: { type: "key", label: "Manually enter API Key" } })
|
||||
})
|
||||
yield* ctx.model.transform((models) => {
|
||||
for (const model of models.list(providerID)) {
|
||||
models.update(providerID, model.id, (draft) => {
|
||||
draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket"
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
if (!providers.get(providerID)) return
|
||||
providers.update(providerID, (provider) => {
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, {
|
||||
transport: provider.settings?.transport ?? "websocket",
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
export * as ToolInputRepairPlugin from "./tool-input-repair.js"
|
||||
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Effect, JsonSchema, Option, Predicate, Schema } from "effect"
|
||||
import { definition } from "../tool/runtime.js"
|
||||
|
||||
// Repairs apply only when the input schema unambiguously supports them:
|
||||
// - Stringified root or nested object: '{"limit":"20"}' -> { limit: 20 }
|
||||
// - Closed object: { limit: "20", extra: true } -> { limit: 20 }
|
||||
// - Optional null or empty-object placeholder: { limit: null } -> {}
|
||||
// - Numeric or boolean string: { limit: "20", enabled: "false" } -> { limit: 20, enabled: false }
|
||||
// - Nullable field: { count: "2" } -> { count: 2 }
|
||||
// - Stringified array or compatible item: { tags: '["a"]', count: "2" } -> { tags: ["a"], count: [2] }
|
||||
// - Positional tuple: { pair: ["2", "false"] } -> { pair: [2, false] }
|
||||
// - Typed dictionary: { counts: { first: "2" } } -> { counts: { first: 2 } }
|
||||
// - Nested fields and local references: { items: [{ count: "2" }] } -> { items: [{ count: 2 }] }
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
const maxDepth = 6
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.tool.input.repair",
|
||||
effect: (ctx) =>
|
||||
ctx.tool.hook("execute.before", (event) =>
|
||||
Effect.gen(function* () {
|
||||
// The outer Code Mode tool is built per snapshot rather than registered, so it cannot be
|
||||
// looked up here. Its `{ code }` input is trivial; the tools it calls are repaired normally.
|
||||
if (event.tool === "execute") return
|
||||
const tool = (yield* ctx.tool.list()).find((tool) => tool.id === event.tool)
|
||||
if (!tool) return
|
||||
const schema = definition(tool).inputSchema
|
||||
if (schema.type !== "object") return
|
||||
event.input = repair(event.input, schema, schema, 0)
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
function repair(value: unknown, schema: JsonSchema.JsonSchema, root: JsonSchema.JsonSchema, depth: number): unknown {
|
||||
if (depth > maxDepth) return value
|
||||
|
||||
if (typeof schema.$ref === "string") {
|
||||
const definitions = /^#\/\$defs\/[^/]+$/.test(schema.$ref)
|
||||
? root.$defs
|
||||
: /^#\/definitions\/[^/]+$/.test(schema.$ref)
|
||||
? root.definitions
|
||||
: undefined
|
||||
if (!Predicate.isObject(definitions)) return value
|
||||
const target = Object.fromEntries(
|
||||
Object.entries(definitions).filter((entry): entry is [string, JsonSchema.JsonSchema] =>
|
||||
Predicate.isObject(entry[1]),
|
||||
),
|
||||
)[
|
||||
schema.$ref
|
||||
.slice(schema.$ref.lastIndexOf("/") + 1)
|
||||
.replaceAll("~1", "/")
|
||||
.replaceAll("~0", "~")
|
||||
]
|
||||
return target ? repair(value, target, root, depth + 1) : value
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.type)) {
|
||||
if (value === null && schema.type.includes("null")) return value
|
||||
if (schema.type.includes(typeof value)) return value
|
||||
const types = schema.type.filter((type) => type !== "null")
|
||||
return types.length === 1 ? repair(value, { ...schema, type: types[0] }, root, depth + 1) : value
|
||||
}
|
||||
|
||||
if (schema.type === undefined) {
|
||||
if (Array.isArray(schema.anyOf) && Array.isArray(schema.oneOf)) return value
|
||||
const branches = Array.isArray(schema.anyOf) ? schema.anyOf : schema.oneOf
|
||||
if (!Array.isArray(branches) || value === null) return value
|
||||
if (branches.some((branch) => !Predicate.isObject(branch) || branch.type === typeof value)) return value
|
||||
const candidates = branches.filter((branch) => Predicate.isObject(branch) && branch.type !== "null")
|
||||
return candidates.length === 1 ? repair(value, candidates[0], root, depth + 1) : value
|
||||
}
|
||||
|
||||
if (schema.type === "number" || schema.type === "integer") {
|
||||
if (typeof value !== "string" || value.trim() === "") return value
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) && (schema.type !== "integer" || Number.isSafeInteger(parsed)) ? parsed : value
|
||||
}
|
||||
if (schema.type === "boolean") return value === "true" ? true : value === "false" ? false : value
|
||||
if (schema.type === "object") return repairObject(value, schema, root, depth)
|
||||
if (schema.type === "array") return repairArray(value, schema, root, depth)
|
||||
return value
|
||||
}
|
||||
|
||||
function repairObject(
|
||||
value: unknown,
|
||||
schema: JsonSchema.JsonSchema,
|
||||
root: JsonSchema.JsonSchema,
|
||||
depth: number,
|
||||
): unknown {
|
||||
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
|
||||
if (!Predicate.isObject(parsed)) return value
|
||||
|
||||
const properties = Predicate.isObject(schema.properties) ? schema.properties : {}
|
||||
const required = Array.isArray(schema.required) ? schema.required : []
|
||||
const patterned = Predicate.isObject(schema.patternProperties)
|
||||
const composed = Array.isArray(schema.allOf) || Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf)
|
||||
|
||||
return Object.keys(parsed).reduce<Record<string, unknown>>((result, key) => {
|
||||
const current = result[key]
|
||||
const declared = Object.hasOwn(properties, key)
|
||||
const property = declared ? properties[key] : !patterned ? schema.additionalProperties : undefined
|
||||
|
||||
if (!declared && schema.additionalProperties === false && !patterned && !composed) {
|
||||
const next = { ...result }
|
||||
delete next[key]
|
||||
return next
|
||||
}
|
||||
if (!Predicate.isObject(property)) return result
|
||||
|
||||
// Only a bare single-type property provably rejects null and `{}`. Compositions, enums,
|
||||
// constants, references and nullable flags may accept them, so those are left alone.
|
||||
const plain =
|
||||
typeof property.type === "string" &&
|
||||
property.type !== "null" &&
|
||||
!composed &&
|
||||
["anyOf", "oneOf", "allOf", "enum", "const", "$ref", "nullable"].every((keyword) => !(keyword in property))
|
||||
const placeholder = Predicate.isObject(current) && Object.keys(current).length === 0 && property.type !== "object"
|
||||
if (declared && !required.includes(key) && plain && (current === null || placeholder)) {
|
||||
const next = { ...result }
|
||||
delete next[key]
|
||||
return next
|
||||
}
|
||||
|
||||
const repaired = repair(current, property, root, depth + 1)
|
||||
return repaired === current ? result : { ...result, [key]: repaired }
|
||||
}, parsed)
|
||||
}
|
||||
|
||||
function repairArray(
|
||||
value: unknown,
|
||||
schema: JsonSchema.JsonSchema,
|
||||
root: JsonSchema.JsonSchema,
|
||||
depth: number,
|
||||
): unknown {
|
||||
const parsed = typeof value === "string" ? Option.getOrUndefined(decodeJson(value)) : value
|
||||
const tuple = Array.isArray(schema.prefixItems)
|
||||
? schema.prefixItems
|
||||
: Array.isArray(schema.items)
|
||||
? schema.items
|
||||
: undefined
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
const repaired = parsed.map((item, index) => {
|
||||
const member = tuple
|
||||
? (tuple[index] ?? (Array.isArray(schema.prefixItems) ? schema.items : schema.additionalItems))
|
||||
: schema.items
|
||||
return Predicate.isObject(member) ? repair(item, member, root, depth + 1) : item
|
||||
})
|
||||
return repaired.every((item, index) => item === parsed[index]) ? parsed : repaired
|
||||
}
|
||||
|
||||
if (tuple || !Predicate.isObject(schema.items)) return value
|
||||
const repaired = repair(value, schema.items, root, depth + 1)
|
||||
const type = schema.items.type
|
||||
const compatible =
|
||||
type === "object"
|
||||
? Predicate.isObject(repaired)
|
||||
: type === "array"
|
||||
? Array.isArray(repaired)
|
||||
: type === "integer"
|
||||
? typeof repaired === "number" && Number.isSafeInteger(repaired)
|
||||
: type === "number"
|
||||
? typeof repaired === "number" && Number.isFinite(repaired)
|
||||
: (type === "string" || type === "boolean") && typeof repaired === type
|
||||
return compatible ? [repaired] : value
|
||||
}
|
||||
@@ -132,11 +132,16 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
|
||||
return yield* importPackage(specifier, entrypoint)
|
||||
})
|
||||
|
||||
/** opencode transport settings consumed in aisdk.ts; native packages never receive them. */
|
||||
const TRANSPORT_KEYS = ["chunkTimeout", "fetch", "timeout"] as const
|
||||
/** opencode settings consumed in Core; native packages never receive them. */
|
||||
const CORE_KEYS = ["chunkTimeout", "compaction", "fetch", "timeout", "transport"] as const
|
||||
const PROVIDER_ONLY_KEYS = ["chunkTimeout", "timeout", "transport"] as const
|
||||
|
||||
export function nativeSettings(settings: Settings): Settings {
|
||||
return Struct.omit(settings, TRANSPORT_KEYS)
|
||||
return Struct.omit(settings, CORE_KEYS)
|
||||
}
|
||||
|
||||
export function modelSettings(settings: Settings | undefined) {
|
||||
return settings && Struct.omit(settings, PROVIDER_ONLY_KEYS)
|
||||
}
|
||||
|
||||
export function mergeOverlay(
|
||||
@@ -177,6 +182,12 @@ export function mergeHeaders(
|
||||
export const Request = Provider.Request
|
||||
export type Request = Provider.Request
|
||||
|
||||
export const Compaction = Provider.Compaction
|
||||
export type Compaction = Provider.Compaction
|
||||
|
||||
export const Transport = Provider.Transport
|
||||
export type Transport = Provider.Transport
|
||||
|
||||
export const Settings = Provider.Settings
|
||||
export type Settings = Provider.Settings
|
||||
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
LLMRequest,
|
||||
Message,
|
||||
type ContentPart,
|
||||
type Usage,
|
||||
} from "@opencode/ai"
|
||||
import type { StreamOptions } from "@opencode/ai/route"
|
||||
import type { SessionCompactionResult } from "@opencode/plugin/effect/session"
|
||||
import { SessionError } from "@opencode/schema/session-error"
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
@@ -91,8 +93,25 @@ export type Settings = {
|
||||
tokens: number
|
||||
}
|
||||
|
||||
export type NativeInput = {
|
||||
readonly request: LLMRequest
|
||||
readonly options: StreamOptions
|
||||
/** Whole, real user messages within the retained-token allowance, for checkpoint-only mechanisms. */
|
||||
readonly retained: Effect.Effect<ReadonlyArray<Message>>
|
||||
}
|
||||
|
||||
export type NativeResult = {
|
||||
readonly replacement: ReadonlyArray<Message>
|
||||
readonly usage?: Usage
|
||||
}
|
||||
|
||||
/** Returns the provider's replacement window, or `undefined` when this strategy has no mechanism for the route. */
|
||||
export type NativeStrategy = (input: NativeInput) => Effect.Effect<NativeResult, AIError> | undefined
|
||||
|
||||
export type Editor = {
|
||||
configure: (settings: Partial<Settings>) => void
|
||||
/** Later registrations take precedence. */
|
||||
native: (strategy: NativeStrategy) => void
|
||||
}
|
||||
|
||||
export type AutoInput = {
|
||||
@@ -380,15 +399,18 @@ export const layer = Layer.effect(
|
||||
const llm = yield* LLMClient.Service
|
||||
const db = (yield* Database.Service).db
|
||||
|
||||
const state = State.create<Settings, Editor>({
|
||||
const state = State.create<Settings & { readonly native: NativeStrategy[] }, Editor>({
|
||||
name: "session-compaction",
|
||||
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
|
||||
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS, native: [] }),
|
||||
editor: (editor) => ({
|
||||
configure: (settings) => {
|
||||
if (settings.auto !== undefined) editor.auto = settings.auto
|
||||
if (settings.buffer !== undefined) editor.buffer = settings.buffer
|
||||
if (settings.tokens !== undefined) editor.tokens = settings.tokens
|
||||
},
|
||||
native: (strategy) => {
|
||||
editor.native.push(strategy)
|
||||
},
|
||||
}),
|
||||
})
|
||||
const failed = Effect.fnUntraced(function* (input: SessionEvent.Compaction.Failed["data"]) {
|
||||
@@ -504,6 +526,23 @@ export const layer = Layer.effect(
|
||||
return yield* reject(
|
||||
"Provider compaction requires the endpoint in provider/model settings, not a model.request rewrite",
|
||||
)
|
||||
const native = state
|
||||
.get()
|
||||
.native.toReversed()
|
||||
.map((strategy) =>
|
||||
strategy({
|
||||
request,
|
||||
options: prepared.options,
|
||||
retained: original(context.session.id).pipe(
|
||||
Effect.map((messages) => retainUsers(messages, context.model, state.get().tokens)),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.find((effect) => effect !== undefined)
|
||||
if (!native)
|
||||
return yield* reject(
|
||||
`No plugin provides native compaction for ${request.model.provider}/${request.model.route.id}`,
|
||||
)
|
||||
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
|
||||
agent: context.agent.id,
|
||||
model: context.model.ref,
|
||||
@@ -514,25 +553,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
// Transient provider failures retry like any other request; only a known automatic overflow permits
|
||||
// local recovery, and nothing is installed until the provider returns a checkpoint.
|
||||
const result = yield* restore(
|
||||
Effect.gen(function* () {
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
|
||||
const retained = retainUsers(yield* original(context.session.id), context.model, state.get().tokens)
|
||||
const result = yield* llm
|
||||
.compact(request, { ...prepared.options, mechanism: "trigger" })
|
||||
.pipe(transient)
|
||||
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
|
||||
}
|
||||
if (LLMClient.canCompact(request))
|
||||
return yield* llm
|
||||
.compact(request, { mechanism: "endpoint", http: prepared.options.http })
|
||||
.pipe(transient)
|
||||
// Model resolution admits provider policies only for routes with a compaction operation.
|
||||
return yield* Effect.die(
|
||||
new Error(`${request.model.provider}/${request.model.route.id} has no compaction operation`),
|
||||
)
|
||||
}),
|
||||
)
|
||||
const result = yield* restore(native.pipe(transient))
|
||||
const usage = result.usage ? SessionUsage.record(result.usage, context.model.cost) : undefined
|
||||
if (usage)
|
||||
yield* bus.publish(SessionEvent.UsageRecorded, {
|
||||
@@ -715,7 +736,7 @@ export const layer = Layer.effect(
|
||||
const compact = Effect.fn("SessionCompaction.compact")(function* (input: AutoInput): Effect.fn.Return<Outcome> {
|
||||
const request = { ...input, reason: "auto" as const }
|
||||
if (input.overflow) return yield* recoverLocally(request)
|
||||
if (input.context.model.compaction?.mode !== "provider") return yield* execute(request)
|
||||
if (input.context.model.compaction?.type !== "native") return yield* execute(request)
|
||||
return yield* executeProvider(request)
|
||||
})
|
||||
const required = (input: RequiredInput) => {
|
||||
@@ -738,12 +759,7 @@ export const layer = Layer.effect(
|
||||
limit.input === undefined ? Number.POSITIVE_INFINITY : limit.input - config.buffer,
|
||||
context - Math.max(output, config.buffer),
|
||||
)
|
||||
const policy = input.resolved.compaction
|
||||
const threshold =
|
||||
policy?.mode === "provider" && policy.threshold !== undefined
|
||||
? Math.min(policy.threshold, promptCeiling)
|
||||
: promptCeiling
|
||||
return estimateTokens(input) >= threshold
|
||||
return estimateTokens(input) >= promptCeiling
|
||||
}
|
||||
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
|
||||
if (findTailStart(input.messages, state.get().tokens) === undefined)
|
||||
@@ -771,7 +787,7 @@ export const layer = Layer.effect(
|
||||
inputID: input.inputID,
|
||||
started: input.started,
|
||||
}
|
||||
return context.model.compaction?.mode === "provider" ? executeProvider(request) : execute(request)
|
||||
return context.model.compaction?.type === "native" ? executeProvider(request) : execute(request)
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ const render = (skills: ReadonlyArray<Summary>) =>
|
||||
[
|
||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||
"Use the skill tool to load a skill when a task matches its description.",
|
||||
"The user may also invoke a skill directly. When that happens, its instructions appear in the conversation as a <skill_content> block, the same shape the skill tool returns. A skill that is already present this way does not need to be invoked again.",
|
||||
...(skills.length === 0
|
||||
? ["No skills are currently available."]
|
||||
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user