Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1e89c344b | ||
|
|
befbedacdc | ||
|
|
2cc738fb17 | ||
|
|
71b20698bb | ||
|
|
3df18dcde1 | ||
|
|
a898c2ea3a | ||
|
|
bf777298c8 | ||
|
|
93fad99f7f | ||
|
|
057848deb8 | ||
|
|
1de06452d3 | ||
|
|
58f60629a1 | ||
|
|
39a47c9b8c | ||
|
|
ea88044f2e | ||
|
|
e6f6f7aff1 |
@@ -1,22 +1,19 @@
|
|||||||
---
|
---
|
||||||
model: opencode/kimi-k2.5
|
model: opencode/gpt-5.4
|
||||||
---
|
---
|
||||||
|
|
||||||
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
|
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
|
||||||
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
|
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
|
||||||
Do not preserve, merge, or reuse text from the existing file.
|
Do not preserve, merge, or reuse text from the existing file.
|
||||||
|
|
||||||
Any command arguments are passed directly to `bun script/changelog.ts`.
|
|
||||||
Use `--from` / `-f` and `--to` / `-t` to preview a specific release range.
|
|
||||||
|
|
||||||
The input already contains the exact commit range since the last non-draft release.
|
The input already contains the exact commit range since the last non-draft release.
|
||||||
The commits are already filtered to the release-relevant packages and grouped into
|
The commits are already filtered to the release-relevant packages and grouped into
|
||||||
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
|
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
|
||||||
The input may also include a `## Community Contributors Input` section.
|
The input may also include a `## Community Contributors Input` section.
|
||||||
|
|
||||||
Before writing any entry you keep, inspect the real diff with
|
Before writing any entry you keep, inspect the real diff with
|
||||||
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so the
|
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can
|
||||||
summary reflects the actual user-facing change and not just the commit message.
|
understand the actual code changes and not just the commit message (they may be misleading).
|
||||||
Do not use `git log` or author metadata when deciding attribution.
|
Do not use `git log` or author metadata when deciding attribution.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
@@ -38,7 +35,12 @@ Rules:
|
|||||||
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
|
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
|
||||||
- Do not derive the thank-you section from the main summary bullets
|
- Do not derive the thank-you section from the main summary bullets
|
||||||
- Do not include the heading `## Community Contributors Input` in the final file
|
- Do not include the heading `## Community Contributors Input` in the final file
|
||||||
|
- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
|
||||||
|
|
||||||
## Changelog Input
|
**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail**
|
||||||
|
|
||||||
!`bun script/changelog.ts $ARGUMENTS`
|
<changelog_input>
|
||||||
|
|
||||||
|
!`bun script/raw-changelog.ts $ARGUMENTS`
|
||||||
|
|
||||||
|
</changelog_input>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.64",
|
"@ai-sdk/anthropic": "3.0.64",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop-electron": {
|
"packages/desktop-electron": {
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -252,7 +252,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -281,7 +281,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -297,7 +297,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -423,7 +423,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -457,7 +457,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.90.10",
|
"@hey-api/openapi-ts": "0.90.10",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -468,7 +468,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -503,7 +503,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -550,7 +550,7 @@
|
|||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -561,7 +561,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -471,7 +471,7 @@ export async function handler(
|
|||||||
reqModel,
|
reqModel,
|
||||||
providerModel: modelProvider.model,
|
providerModel: modelProvider.model,
|
||||||
adjustCacheUsage: providerProps.adjustCacheUsage,
|
adjustCacheUsage: providerProps.adjustCacheUsage,
|
||||||
safetyIdentifier: ip,
|
safetyIdentifier: modelProvider.safetyIdentifier ? ip : undefined,
|
||||||
workspaceID: authInfo?.workspaceID,
|
workspaceID: authInfo?.workspaceID,
|
||||||
}
|
}
|
||||||
if (format === "anthropic") return anthropicHelper(opts)
|
if (format === "anthropic") return anthropicHelper(opts)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ if (!stage) throw new Error("Stage is required")
|
|||||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||||
|
|
||||||
// read the secret
|
// read the secret
|
||||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||||
const lines = ret.split("\n")
|
const lines = ret.split("\n")
|
||||||
const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
|
const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
|
||||||
if (!value) throw new Error("ZEN_LIMITS not found")
|
if (!value) throw new Error("ZEN_LIMITS not found")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
|
|||||||
const PARTS = 30
|
const PARTS = 30
|
||||||
|
|
||||||
// read the secret
|
// read the secret
|
||||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||||
const lines = ret.split("\n")
|
const lines = ret.split("\n")
|
||||||
const values = Array.from({ length: PARTS }, (_, i) => {
|
const values = Array.from({ length: PARTS }, (_, i) => {
|
||||||
const value = lines
|
const value = lines
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import os from "os"
|
|||||||
import { Subscription } from "../src/subscription"
|
import { Subscription } from "../src/subscription"
|
||||||
|
|
||||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||||
const secrets = await $`bun sst secret list`.cwd(root).text()
|
const secrets = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||||
|
|
||||||
// read value
|
// read value
|
||||||
const lines = secrets.split("\n")
|
const lines = secrets.split("\n")
|
||||||
@@ -25,4 +25,4 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
|
|||||||
Subscription.validate(JSON.parse(newValue))
|
Subscription.validate(JSON.parse(newValue))
|
||||||
|
|
||||||
// update the secret
|
// update the secret
|
||||||
await $`bun sst secret set ZEN_LIMITS ${newValue}`
|
await $`bun sst secret set ZEN_LIMITS ${newValue} --stage frank`.cwd(root)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import os from "os"
|
|||||||
import { ZenData } from "../src/model"
|
import { ZenData } from "../src/model"
|
||||||
|
|
||||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||||
const models = await $`bun sst secret list`.cwd(root).text()
|
const models = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||||
const PARTS = 30
|
const PARTS = 30
|
||||||
|
|
||||||
// read the line starting with "ZEN_MODELS"
|
// read the line starting with "ZEN_MODELS"
|
||||||
@@ -40,4 +40,4 @@ const newValues = Array.from({ length: PARTS }, (_, i) =>
|
|||||||
|
|
||||||
const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
|
const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
|
||||||
await envFile.write(newValues.map((v, i) => `ZEN_MODELS${i + 1}="${v.replace(/"/g, '\\"')}"`).join("\n"))
|
await envFile.write(newValues.map((v, i) => `ZEN_MODELS${i + 1}="${v.replace(/"/g, '\\"')}"`).join("\n"))
|
||||||
await $`bun sst secret load ${envFile.name}`.cwd(root)
|
await $`bun sst secret load ${envFile.name} --stage frank`.cwd(root)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export namespace ZenData {
|
|||||||
disabled: z.boolean().optional(),
|
disabled: z.boolean().optional(),
|
||||||
storeModel: z.string().optional(),
|
storeModel: z.string().optional(),
|
||||||
payloadModifier: z.record(z.string(), z.any()).optional(),
|
payloadModifier: z.record(z.string(), z.any()).optional(),
|
||||||
|
safetyIdentifier: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -49,7 +50,6 @@ export namespace ZenData {
|
|||||||
payloadModifier: z.record(z.string(), z.any()).optional(),
|
payloadModifier: z.record(z.string(), z.any()).optional(),
|
||||||
payloadMappings: z.record(z.string(), z.string()).optional(),
|
payloadMappings: z.record(z.string(), z.string()).optional(),
|
||||||
adjustCacheUsage: z.boolean().optional(),
|
adjustCacheUsage: z.boolean().optional(),
|
||||||
safetyIdentifier: z.boolean().optional(),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const ModelsSchema = z.object({
|
const ModelsSchema = z.object({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.3.8"
|
version = "1.3.10"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.8/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.8/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.8/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.8/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.8/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.10/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -210,15 +210,13 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
|||||||
- [x] `Vcs` — `project/vcs.ts`
|
- [x] `Vcs` — `project/vcs.ts`
|
||||||
- [x] `Worktree` — `worktree/index.ts`
|
- [x] `Worktree` — `worktree/index.ts`
|
||||||
|
|
||||||
Still open and likely worth migrating:
|
|
||||||
|
|
||||||
- [x] `Session` — `session/index.ts`
|
- [x] `Session` — `session/index.ts`
|
||||||
- [ ] `SessionProcessor` — blocked by AI SDK v6 PR (#18433)
|
- [x] `SessionProcessor` — `session/processor.ts`
|
||||||
- [ ] `SessionPrompt` — blocked by AI SDK v6 PR (#18433)
|
- [x] `SessionPrompt` — `session/prompt.ts`
|
||||||
- [ ] `SessionCompaction` — blocked by AI SDK v6 PR (#18433)
|
- [x] `SessionCompaction` — `session/compaction.ts`
|
||||||
- [ ] `Provider` — blocked by AI SDK v6 PR (#18433)
|
- [x] `Provider` — `provider/provider.ts`
|
||||||
|
|
||||||
Other services not yet migrated:
|
Still open:
|
||||||
|
|
||||||
- [ ] `SessionSummary` — `session/summary.ts`
|
- [ ] `SessionSummary` — `session/summary.ts`
|
||||||
- [ ] `SessionTodo` — `session/todo.ts`
|
- [ ] `SessionTodo` — `session/todo.ts`
|
||||||
@@ -235,7 +233,7 @@ Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `i
|
|||||||
|
|
||||||
1. Migrate each tool to return Effects
|
1. Migrate each tool to return Effects
|
||||||
2. Update `Tool.define()` factory to work with Effects
|
2. Update `Tool.define()` factory to work with Effects
|
||||||
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing — blocked by AI SDK v6 PR (#18433)
|
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
|
||||||
|
|
||||||
Individual tools, ordered by value:
|
Individual tools, ordered by value:
|
||||||
|
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ export namespace Agent {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(Auth.layer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
|
|||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { zod } from "@/util/effect-zod"
|
import { zod } from "@/util/effect-zod"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { AppFileSystem } from "../filesystem"
|
||||||
|
|
||||||
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
|
||||||
|
|
||||||
@@ -53,17 +53,13 @@ export namespace Auth {
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const fsys = yield* AppFileSystem.Service
|
||||||
const decode = Schema.decodeUnknownOption(Info)
|
const decode = Schema.decodeUnknownOption(Info)
|
||||||
|
|
||||||
const all = Effect.fn("Auth.all")(() =>
|
const all = Effect.fn("Auth.all")(function* () {
|
||||||
Effect.tryPromise({
|
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
|
||||||
try: async () => {
|
|
||||||
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
|
|
||||||
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
|
||||||
},
|
})
|
||||||
catch: fail("Failed to read auth data"),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
const get = Effect.fn("Auth.get")(function* (providerID: string) {
|
||||||
return (yield* all())[providerID]
|
return (yield* all())[providerID]
|
||||||
@@ -74,10 +70,9 @@ export namespace Auth {
|
|||||||
const data = yield* all()
|
const data = yield* all()
|
||||||
if (norm !== key) delete data[key]
|
if (norm !== key) delete data[key]
|
||||||
delete data[norm + "/"]
|
delete data[norm + "/"]
|
||||||
yield* Effect.tryPromise({
|
yield* fsys
|
||||||
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
|
.writeJson(file, { ...data, [norm]: info }, 0o600)
|
||||||
catch: fail("Failed to write auth data"),
|
.pipe(Effect.mapError(fail("Failed to write auth data")))
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
const remove = Effect.fn("Auth.remove")(function* (key: string) {
|
||||||
@@ -85,17 +80,16 @@ export namespace Auth {
|
|||||||
const data = yield* all()
|
const data = yield* all()
|
||||||
delete data[key]
|
delete data[key]
|
||||||
delete data[norm]
|
delete data[norm]
|
||||||
yield* Effect.tryPromise({
|
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
|
||||||
try: () => Filesystem.writeJson(file, data, 0o600),
|
|
||||||
catch: fail("Failed to write auth data"),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return Service.of({ get, all, set, remove })
|
return Service.of({ get, all, set, remove })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, layer)
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
|
|
||||||
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export async function get(providerID: string) {
|
export async function get(providerID: string) {
|
||||||
return runPromise((service) => service.get(providerID))
|
return runPromise((service) => service.get(providerID))
|
||||||
|
|||||||
@@ -62,8 +62,8 @@
|
|||||||
"light": "frappeText"
|
"light": "frappeText"
|
||||||
},
|
},
|
||||||
"textMuted": {
|
"textMuted": {
|
||||||
"dark": "frappeSubtext1",
|
"dark": "frappeOverlay2",
|
||||||
"light": "frappeSubtext1"
|
"light": "frappeOverlay2"
|
||||||
},
|
},
|
||||||
"background": {
|
"background": {
|
||||||
"dark": "frappeBase",
|
"dark": "frappeBase",
|
||||||
|
|||||||
@@ -62,8 +62,8 @@
|
|||||||
"light": "macText"
|
"light": "macText"
|
||||||
},
|
},
|
||||||
"textMuted": {
|
"textMuted": {
|
||||||
"dark": "macSubtext1",
|
"dark": "macOverlay2",
|
||||||
"light": "macSubtext1"
|
"light": "macOverlay2"
|
||||||
},
|
},
|
||||||
"background": {
|
"background": {
|
||||||
"dark": "macBase",
|
"dark": "macBase",
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
"success": { "dark": "darkGreen", "light": "lightGreen" },
|
"success": { "dark": "darkGreen", "light": "lightGreen" },
|
||||||
"info": { "dark": "darkTeal", "light": "lightTeal" },
|
"info": { "dark": "darkTeal", "light": "lightTeal" },
|
||||||
"text": { "dark": "darkText", "light": "lightText" },
|
"text": { "dark": "darkText", "light": "lightText" },
|
||||||
"textMuted": { "dark": "darkSubtext1", "light": "lightSubtext1" },
|
"textMuted": { "dark": "darkOverlay2", "light": "lightOverlay2" },
|
||||||
"background": { "dark": "darkBase", "light": "lightBase" },
|
"background": { "dark": "darkBase", "light": "lightBase" },
|
||||||
"backgroundPanel": { "dark": "darkMantle", "light": "lightMantle" },
|
"backgroundPanel": { "dark": "darkMantle", "light": "lightMantle" },
|
||||||
"backgroundElement": { "dark": "darkCrust", "light": "lightCrust" },
|
"backgroundElement": { "dark": "darkCrust", "light": "lightCrust" },
|
||||||
|
|||||||
@@ -1540,7 +1540,7 @@ export namespace Config {
|
|||||||
|
|
||||||
export const defaultLayer = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
Layer.provide(Auth.layer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Account.defaultLayer),
|
Layer.provide(Account.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -541,7 +541,7 @@ export namespace File {
|
|||||||
const exists = yield* appFs.existsSafe(full)
|
const exists = yield* appFs.existsSafe(full)
|
||||||
if (!exists) return { type: "text" as const, content: "" }
|
if (!exists) return { type: "text" as const, content: "" }
|
||||||
|
|
||||||
const mimeType = Filesystem.mimeType(full)
|
const mimeType = AppFileSystem.mimeType(full)
|
||||||
const encode = knownText ? false : shouldEncode(mimeType)
|
const encode = knownText ? false : shouldEncode(mimeType)
|
||||||
|
|
||||||
if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }
|
if (encode && !isImage(mimeType)) return { type: "binary" as const, content: "", mimeType }
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { DateTime, Effect, Layer, Semaphore, ServiceMap } from "effect"
|
import { DateTime, Effect, Layer, Option, Semaphore, ServiceMap } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import type { SessionID } from "@/session/schema"
|
import type { SessionID } from "@/session/schema"
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
|
||||||
export namespace FileTime {
|
export namespace FileTime {
|
||||||
@@ -12,21 +12,9 @@ export namespace FileTime {
|
|||||||
export type Stamp = {
|
export type Stamp = {
|
||||||
readonly read: Date
|
readonly read: Date
|
||||||
readonly mtime: number | undefined
|
readonly mtime: number | undefined
|
||||||
readonly ctime: number | undefined
|
|
||||||
readonly size: number | undefined
|
readonly size: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const stamp = Effect.fnUntraced(function* (file: string) {
|
|
||||||
const stat = Filesystem.stat(file)
|
|
||||||
const size = typeof stat?.size === "bigint" ? Number(stat.size) : stat?.size
|
|
||||||
return {
|
|
||||||
read: yield* DateTime.nowAsDate,
|
|
||||||
mtime: stat?.mtime?.getTime(),
|
|
||||||
ctime: stat?.ctime?.getTime(),
|
|
||||||
size,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
const session = (reads: Map<SessionID, Map<string, Stamp>>, sessionID: SessionID) => {
|
||||||
const value = reads.get(sessionID)
|
const value = reads.get(sessionID)
|
||||||
if (value) return value
|
if (value) return value
|
||||||
@@ -53,7 +41,17 @@ export namespace FileTime {
|
|||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const fsys = yield* AppFileSystem.Service
|
||||||
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
const disableCheck = yield* Flag.OPENCODE_DISABLE_FILETIME_CHECK
|
||||||
|
|
||||||
|
const stamp = Effect.fnUntraced(function* (file: string) {
|
||||||
|
const info = yield* fsys.stat(file).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||||
|
return {
|
||||||
|
read: yield* DateTime.nowAsDate,
|
||||||
|
mtime: info ? Option.getOrUndefined(info.mtime)?.getTime() : undefined,
|
||||||
|
size: info ? Number(info.size) : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
const state = yield* InstanceState.make<State>(
|
const state = yield* InstanceState.make<State>(
|
||||||
Effect.fn("FileTime.state")(() =>
|
Effect.fn("FileTime.state")(() =>
|
||||||
Effect.succeed({
|
Effect.succeed({
|
||||||
@@ -92,7 +90,7 @@ export namespace FileTime {
|
|||||||
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
|
||||||
|
|
||||||
const next = yield* stamp(filepath)
|
const next = yield* stamp(filepath)
|
||||||
const changed = next.mtime !== time.mtime || next.ctime !== time.ctime || next.size !== time.size
|
const changed = next.mtime !== time.mtime || next.size !== time.size
|
||||||
if (!changed) return
|
if (!changed) return
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -108,7 +106,9 @@ export namespace FileTime {
|
|||||||
}),
|
}),
|
||||||
).pipe(Layer.orDie)
|
).pipe(Layer.orDie)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, layer)
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
|
|
||||||
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export function read(sessionID: SessionID, file: string) {
|
export function read(sessionID: SessionID, file: string) {
|
||||||
return runPromise((s) => s.read(sessionID, file))
|
return runPromise((s) => s.read(sessionID, file))
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ export function pluginSource(spec: string): PluginSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveExportPath(raw: string, dir: string) {
|
function resolveExportPath(raw: string, dir: string) {
|
||||||
if (raw.startsWith("./") || raw.startsWith("../")) return path.resolve(dir, raw)
|
|
||||||
if (raw.startsWith("file://")) return fileURLToPath(raw)
|
if (raw.startsWith("file://")) return fileURLToPath(raw)
|
||||||
return raw
|
if (path.isAbsolute(raw)) return raw
|
||||||
|
return path.resolve(dir, raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractExportValue(value: unknown): string | undefined {
|
function extractExportValue(value: unknown): string | undefined {
|
||||||
@@ -93,7 +93,7 @@ function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPac
|
|||||||
|
|
||||||
function targetPath(target: string) {
|
function targetPath(target: string) {
|
||||||
if (target.startsWith("file://")) return fileURLToPath(target)
|
if (target.startsWith("file://")) return fileURLToPath(target)
|
||||||
if (path.isAbsolute(target) || /^[A-Za-z]:[\\/]/.test(target)) return target
|
if (path.isAbsolute(target)) return target
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveDirectoryIndex(dir: string) {
|
async function resolveDirectoryIndex(dir: string) {
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export namespace ProviderAuth {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(Auth.defaultLayer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import { iife } from "@/util/iife"
|
|||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Filesystem } from "../util/filesystem"
|
import { Filesystem } from "../util/filesystem"
|
||||||
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
|
||||||
// Direct imports for bundled providers
|
// Direct imports for bundled providers
|
||||||
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
|
import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock"
|
||||||
@@ -857,6 +860,29 @@ export namespace Provider {
|
|||||||
})
|
})
|
||||||
export type Info = z.infer<typeof Info>
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly list: () => Effect.Effect<Record<ProviderID, Info>>
|
||||||
|
readonly getProvider: (providerID: ProviderID) => Effect.Effect<Info>
|
||||||
|
readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect<Model>
|
||||||
|
readonly getLanguage: (model: Model) => Effect.Effect<LanguageModelV3>
|
||||||
|
readonly closest: (
|
||||||
|
providerID: ProviderID,
|
||||||
|
query: string[],
|
||||||
|
) => Effect.Effect<{ providerID: ProviderID; modelID: string } | undefined>
|
||||||
|
readonly getSmallModel: (providerID: ProviderID) => Effect.Effect<Model | undefined>
|
||||||
|
readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
models: Map<string, LanguageModelV3>
|
||||||
|
providers: Record<ProviderID, Info>
|
||||||
|
sdk: Map<string, BundledSDK>
|
||||||
|
modelLoaders: Record<string, CustomModelLoader>
|
||||||
|
varsLoaders: Record<string, CustomVarsLoader>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Provider") {}
|
||||||
|
|
||||||
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
|
function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
|
||||||
const m: Model = {
|
const m: Model = {
|
||||||
id: ModelID.make(model.id),
|
id: ModelID.make(model.id),
|
||||||
@@ -935,14 +961,21 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = Instance.state(async () => {
|
const layer: Layer.Layer<Service, never, Config.Service | Auth.Service> = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const config = yield* Config.Service
|
||||||
|
const auth = yield* Auth.Service
|
||||||
|
|
||||||
|
const cache = yield* InstanceState.make<State>(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
using _ = log.time("state")
|
using _ = log.time("state")
|
||||||
const config = await Config.get()
|
const cfg = yield* config.get()
|
||||||
const modelsDev = await ModelsDev.get()
|
const modelsDev = yield* Effect.promise(() => ModelsDev.get())
|
||||||
const database = mapValues(modelsDev, fromModelsDevProvider)
|
const database = mapValues(modelsDev, fromModelsDevProvider)
|
||||||
|
|
||||||
const disabled = new Set(config.disabled_providers ?? [])
|
const disabled = new Set(cfg.disabled_providers ?? [])
|
||||||
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null
|
const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null
|
||||||
|
|
||||||
function isProviderAllowed(providerID: ProviderID): boolean {
|
function isProviderAllowed(providerID: ProviderID): boolean {
|
||||||
if (enabled && !enabled.has(providerID)) return false
|
if (enabled && !enabled.has(providerID)) return false
|
||||||
@@ -965,7 +998,7 @@ export namespace Provider {
|
|||||||
|
|
||||||
log.info("init")
|
log.info("init")
|
||||||
|
|
||||||
const configProviders = Object.entries(config.provider ?? {})
|
const configProviders = Object.entries(cfg.provider ?? {})
|
||||||
|
|
||||||
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
|
function mergeProvider(providerID: ProviderID, provider: Partial<Info>) {
|
||||||
const existing = providers[providerID]
|
const existing = providers[providerID]
|
||||||
@@ -1021,16 +1054,22 @@ export namespace Provider {
|
|||||||
toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
|
toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
|
||||||
input: {
|
input: {
|
||||||
text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
|
text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
|
||||||
audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
|
audio:
|
||||||
image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false,
|
model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
|
||||||
video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false,
|
image:
|
||||||
|
model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false,
|
||||||
|
video:
|
||||||
|
model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false,
|
||||||
pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
|
pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
|
||||||
},
|
},
|
||||||
output: {
|
output: {
|
||||||
text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
|
text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
|
||||||
audio: model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false,
|
audio:
|
||||||
image: model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false,
|
model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false,
|
||||||
video: model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
|
image:
|
||||||
|
model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false,
|
||||||
|
video:
|
||||||
|
model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
|
||||||
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
|
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
|
||||||
},
|
},
|
||||||
interleaved: model.interleaved ?? false,
|
interleaved: model.interleaved ?? false,
|
||||||
@@ -1077,7 +1116,8 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// load apikeys
|
// load apikeys
|
||||||
for (const [id, provider] of Object.entries(await Auth.all())) {
|
const auths = yield* auth.all().pipe(Effect.orDie)
|
||||||
|
for (const [id, provider] of Object.entries(auths)) {
|
||||||
const providerID = ProviderID.make(id)
|
const providerID = ProviderID.make(id)
|
||||||
if (disabled.has(providerID)) continue
|
if (disabled.has(providerID)) continue
|
||||||
if (provider.type === "api") {
|
if (provider.type === "api") {
|
||||||
@@ -1088,22 +1128,23 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const plugin of await Plugin.list()) {
|
const plugins = yield* Effect.promise(() => Plugin.list())
|
||||||
|
for (const plugin of plugins) {
|
||||||
if (!plugin.auth) continue
|
if (!plugin.auth) continue
|
||||||
const providerID = ProviderID.make(plugin.auth.provider)
|
const providerID = ProviderID.make(plugin.auth.provider)
|
||||||
if (disabled.has(providerID)) continue
|
if (disabled.has(providerID)) continue
|
||||||
|
|
||||||
const auth = await Auth.get(providerID)
|
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
|
||||||
if (!auth) continue
|
if (!pluginAuth) continue
|
||||||
if (!plugin.auth.loader) continue
|
if (!plugin.auth.loader) continue
|
||||||
|
|
||||||
if (auth) {
|
const options = yield* Effect.promise(() =>
|
||||||
const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider])
|
plugin.auth!.loader!(() => Auth.get(providerID) as any, database[plugin.auth!.provider]),
|
||||||
|
)
|
||||||
const opts = options ?? {}
|
const opts = options ?? {}
|
||||||
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
|
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
|
||||||
mergeProvider(providerID, patch)
|
mergeProvider(providerID, patch)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) {
|
for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) {
|
||||||
const providerID = ProviderID.make(id)
|
const providerID = ProviderID.make(id)
|
||||||
@@ -1113,13 +1154,15 @@ export namespace Provider {
|
|||||||
log.error("Provider does not exist in model list " + providerID)
|
log.error("Provider does not exist in model list " + providerID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
const result = await fn(data)
|
const result = yield* Effect.promise(() => fn(data))
|
||||||
if (result && (result.autoload || providers[providerID])) {
|
if (result && (result.autoload || providers[providerID])) {
|
||||||
if (result.getModel) modelLoaders[providerID] = result.getModel
|
if (result.getModel) modelLoaders[providerID] = result.getModel
|
||||||
if (result.vars) varsLoaders[providerID] = result.vars
|
if (result.vars) varsLoaders[providerID] = result.vars
|
||||||
if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels
|
if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels
|
||||||
const opts = result.options ?? {}
|
const opts = result.options ?? {}
|
||||||
const patch: Partial<Info> = providers[providerID] ? { options: opts } : { source: "custom", options: opts }
|
const patch: Partial<Info> = providers[providerID]
|
||||||
|
? { options: opts }
|
||||||
|
: { source: "custom", options: opts }
|
||||||
mergeProvider(providerID, patch)
|
mergeProvider(providerID, patch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1141,7 +1184,7 @@ export namespace Provider {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const configProvider = config.provider?.[providerID]
|
const configProvider = cfg.provider?.[providerID]
|
||||||
|
|
||||||
for (const [modelID, model] of Object.entries(provider.models)) {
|
for (const [modelID, model] of Object.entries(provider.models)) {
|
||||||
model.api.id = model.api.id ?? model.id ?? modelID
|
model.api.id = model.api.id ?? model.id ?? modelID
|
||||||
@@ -1160,7 +1203,6 @@ export namespace Provider {
|
|||||||
|
|
||||||
model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
|
model.variants = mapValues(ProviderTransform.variants(model), (v) => v)
|
||||||
|
|
||||||
// Filter out disabled variants from config
|
|
||||||
const configVariants = configProvider?.models?.[modelID]?.variants
|
const configVariants = configProvider?.models?.[modelID]?.variants
|
||||||
if (configVariants && model.variants) {
|
if (configVariants && model.variants) {
|
||||||
const merged = mergeDeep(model.variants, configVariants)
|
const merged = mergeDeep(model.variants, configVariants)
|
||||||
@@ -1181,14 +1223,18 @@ export namespace Provider {
|
|||||||
|
|
||||||
const gitlab = ProviderID.make("gitlab")
|
const gitlab = ProviderID.make("gitlab")
|
||||||
if (discoveryLoaders[gitlab] && providers[gitlab]) {
|
if (discoveryLoaders[gitlab] && providers[gitlab]) {
|
||||||
await (async () => {
|
yield* Effect.promise(async () => {
|
||||||
|
try {
|
||||||
const discovered = await discoveryLoaders[gitlab]()
|
const discovered = await discoveryLoaders[gitlab]()
|
||||||
for (const [modelID, model] of Object.entries(discovered)) {
|
for (const [modelID, model] of Object.entries(discovered)) {
|
||||||
if (!providers[gitlab].models[modelID]) {
|
if (!providers[gitlab].models[modelID]) {
|
||||||
providers[gitlab].models[modelID] = model
|
providers[gitlab].models[modelID] = model
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})().catch((e) => log.warn("state discovery error", { id: "gitlab", error: e }))
|
} catch (e) {
|
||||||
|
log.warn("state discovery error", { id: "gitlab", error: e })
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1198,18 +1244,16 @@ export namespace Provider {
|
|||||||
modelLoaders,
|
modelLoaders,
|
||||||
varsLoaders,
|
varsLoaders,
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export async function list() {
|
const list = Effect.fn("Provider.list")(() => InstanceState.use(cache, (s) => s.providers))
|
||||||
return state().then((state) => state.providers)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getSDK(model: Model) {
|
async function resolveSDK(model: Model, s: State) {
|
||||||
try {
|
try {
|
||||||
using _ = log.time("getSDK", {
|
using _ = log.time("getSDK", {
|
||||||
providerID: model.providerID,
|
providerID: model.providerID,
|
||||||
})
|
})
|
||||||
const s = await state()
|
|
||||||
const provider = s.providers[model.providerID]
|
const provider = s.providers[model.providerID]
|
||||||
const options = { ...provider.options }
|
const options = { ...provider.options }
|
||||||
|
|
||||||
@@ -1226,9 +1270,6 @@ export namespace Provider {
|
|||||||
typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
|
typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
|
||||||
if (!url) return
|
if (!url) return
|
||||||
|
|
||||||
// some models/providers have variable urls, ex: "https://${AZURE_RESOURCE_NAME}.services.ai.azure.com/anthropic/v1"
|
|
||||||
// We track this in models.dev, and then when we are resolving the baseURL
|
|
||||||
// we need to string replace that literal: "${AZURE_RESOURCE_NAME}"
|
|
||||||
const loader = s.varsLoaders[model.providerID]
|
const loader = s.varsLoaders[model.providerID]
|
||||||
if (loader) {
|
if (loader) {
|
||||||
const vars = loader(options)
|
const vars = loader(options)
|
||||||
@@ -1268,10 +1309,10 @@ export namespace Provider {
|
|||||||
delete options["chunkTimeout"]
|
delete options["chunkTimeout"]
|
||||||
|
|
||||||
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
|
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
|
||||||
// Preserve custom fetch if it exists, wrap it with timeout logic
|
|
||||||
const fetchFn = customFetch ?? fetch
|
const fetchFn = customFetch ?? fetch
|
||||||
const opts = init ?? {}
|
const opts = init ?? {}
|
||||||
const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
|
const chunkAbortCtl =
|
||||||
|
typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined
|
||||||
const signals: AbortSignal[] = []
|
const signals: AbortSignal[] = []
|
||||||
|
|
||||||
if (opts.signal) signals.push(opts.signal)
|
if (opts.signal) signals.push(opts.signal)
|
||||||
@@ -1283,9 +1324,6 @@ export namespace Provider {
|
|||||||
if (combined) opts.signal = combined
|
if (combined) opts.signal = combined
|
||||||
|
|
||||||
// Strip openai itemId metadata following what codex does
|
// Strip openai itemId metadata following what codex does
|
||||||
// Codex uses #[serde(skip_serializing)] on id fields for all item types:
|
|
||||||
// Message, Reasoning, FunctionCall, LocalShellCall, CustomToolCall, WebSearchCall
|
|
||||||
// IDs are only re-attached for Azure with store=true
|
|
||||||
if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") {
|
if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") {
|
||||||
const body = JSON.parse(opts.body as string)
|
const body = JSON.parse(opts.body as string)
|
||||||
const isAzure = model.providerID.includes("azure")
|
const isAzure = model.providerID.includes("azure")
|
||||||
@@ -1346,43 +1384,36 @@ export namespace Provider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProvider(providerID: ProviderID) {
|
const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
|
||||||
return state().then((s) => s.providers[providerID])
|
InstanceState.use(cache, (s) => s.providers[providerID]),
|
||||||
}
|
)
|
||||||
|
|
||||||
export async function getModel(providerID: ProviderID, modelID: ModelID) {
|
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {
|
||||||
const s = await state()
|
const s = yield* InstanceState.get(cache)
|
||||||
const provider = s.providers[providerID]
|
const provider = s.providers[providerID]
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
const availableProviders = Object.keys(s.providers)
|
const available = Object.keys(s.providers)
|
||||||
const matches = fuzzysort.go(providerID, availableProviders, {
|
const matches = fuzzysort.go(providerID, available, { limit: 3, threshold: -10000 })
|
||||||
limit: 3,
|
throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
|
||||||
threshold: -10000,
|
|
||||||
})
|
|
||||||
const suggestions = matches.map((m) => m.target)
|
|
||||||
throw new ModelNotFoundError({ providerID, modelID, suggestions })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = provider.models[modelID]
|
const info = provider.models[modelID]
|
||||||
if (!info) {
|
if (!info) {
|
||||||
const availableModels = Object.keys(provider.models)
|
const available = Object.keys(provider.models)
|
||||||
const matches = fuzzysort.go(modelID, availableModels, {
|
const matches = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 })
|
||||||
limit: 3,
|
throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) })
|
||||||
threshold: -10000,
|
|
||||||
})
|
|
||||||
const suggestions = matches.map((m) => m.target)
|
|
||||||
throw new ModelNotFoundError({ providerID, modelID, suggestions })
|
|
||||||
}
|
}
|
||||||
return info
|
return info
|
||||||
}
|
})
|
||||||
|
|
||||||
export async function getLanguage(model: Model): Promise<LanguageModelV3> {
|
const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {
|
||||||
const s = await state()
|
const s = yield* InstanceState.get(cache)
|
||||||
const key = `${model.providerID}/${model.id}`
|
const key = `${model.providerID}/${model.id}`
|
||||||
if (s.models.has(key)) return s.models.get(key)!
|
if (s.models.has(key)) return s.models.get(key)!
|
||||||
|
|
||||||
|
return yield* Effect.promise(async () => {
|
||||||
const provider = s.providers[model.providerID]
|
const provider = s.providers[model.providerID]
|
||||||
const sdk = await getSDK(model)
|
const sdk = await resolveSDK(model, s)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const language = s.modelLoaders[model.providerID]
|
const language = s.modelLoaders[model.providerID]
|
||||||
@@ -1404,33 +1435,33 @@ export namespace Provider {
|
|||||||
)
|
)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
})
|
||||||
|
|
||||||
export async function closest(providerID: ProviderID, query: string[]) {
|
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
|
||||||
const s = await state()
|
const s = yield* InstanceState.get(cache)
|
||||||
const provider = s.providers[providerID]
|
const provider = s.providers[providerID]
|
||||||
if (!provider) return undefined
|
if (!provider) return undefined
|
||||||
for (const item of query) {
|
for (const item of query) {
|
||||||
for (const modelID of Object.keys(provider.models)) {
|
for (const modelID of Object.keys(provider.models)) {
|
||||||
if (modelID.includes(item))
|
if (modelID.includes(item)) return { providerID, modelID }
|
||||||
return {
|
|
||||||
providerID,
|
|
||||||
modelID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
export async function getSmallModel(providerID: ProviderID) {
|
const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) {
|
||||||
const cfg = await Config.get()
|
const cfg = yield* config.get()
|
||||||
|
|
||||||
if (cfg.small_model) {
|
if (cfg.small_model) {
|
||||||
const parsed = parseModel(cfg.small_model)
|
const parsed = parseModel(cfg.small_model)
|
||||||
return getModel(parsed.providerID, parsed.modelID)
|
return yield* getModel(parsed.providerID, parsed.modelID)
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = await state().then((state) => state.providers[providerID])
|
const s = yield* InstanceState.get(cache)
|
||||||
if (provider) {
|
const provider = s.providers[providerID]
|
||||||
|
if (!provider) return undefined
|
||||||
|
|
||||||
let priority = [
|
let priority = [
|
||||||
"claude-haiku-4-5",
|
"claude-haiku-4-5",
|
||||||
"claude-haiku-4.5",
|
"claude-haiku-4.5",
|
||||||
@@ -1444,7 +1475,6 @@ export namespace Provider {
|
|||||||
priority = ["gpt-5-nano"]
|
priority = ["gpt-5-nano"]
|
||||||
}
|
}
|
||||||
if (providerID.startsWith("github-copilot")) {
|
if (providerID.startsWith("github-copilot")) {
|
||||||
// prioritize free models for github copilot
|
|
||||||
priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
|
priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority]
|
||||||
}
|
}
|
||||||
for (const item of priority) {
|
for (const item of priority) {
|
||||||
@@ -1452,33 +1482,96 @@ export namespace Provider {
|
|||||||
const crossRegionPrefixes = ["global.", "us.", "eu."]
|
const crossRegionPrefixes = ["global.", "us.", "eu."]
|
||||||
const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
|
const candidates = Object.keys(provider.models).filter((m) => m.includes(item))
|
||||||
|
|
||||||
// Model selection priority:
|
|
||||||
// 1. global. prefix (works everywhere)
|
|
||||||
// 2. User's region prefix (us., eu.)
|
|
||||||
// 3. Unprefixed model
|
|
||||||
const globalMatch = candidates.find((m) => m.startsWith("global."))
|
const globalMatch = candidates.find((m) => m.startsWith("global."))
|
||||||
if (globalMatch) return getModel(providerID, ModelID.make(globalMatch))
|
if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch))
|
||||||
|
|
||||||
const region = provider.options?.region
|
const region = provider.options?.region
|
||||||
if (region) {
|
if (region) {
|
||||||
const regionPrefix = region.split("-")[0]
|
const regionPrefix = region.split("-")[0]
|
||||||
if (regionPrefix === "us" || regionPrefix === "eu") {
|
if (regionPrefix === "us" || regionPrefix === "eu") {
|
||||||
const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
|
const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`))
|
||||||
if (regionalMatch) return getModel(providerID, ModelID.make(regionalMatch))
|
if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
|
const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p)))
|
||||||
if (unprefixed) return getModel(providerID, ModelID.make(unprefixed))
|
if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed))
|
||||||
} else {
|
} else {
|
||||||
for (const model of Object.keys(provider.models)) {
|
for (const model of Object.keys(provider.models)) {
|
||||||
if (model.includes(item)) return getModel(providerID, ModelID.make(model))
|
if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined
|
return undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultModel = Effect.fn("Provider.defaultModel")(function* () {
|
||||||
|
const cfg = yield* config.get()
|
||||||
|
if (cfg.model) return parseModel(cfg.model)
|
||||||
|
|
||||||
|
const s = yield* InstanceState.get(cache)
|
||||||
|
const recent = yield* Effect.promise(() =>
|
||||||
|
Filesystem.readJson<{
|
||||||
|
recent?: { providerID: ProviderID; modelID: ModelID }[]
|
||||||
|
}>(path.join(Global.Path.state, "model.json"))
|
||||||
|
.then((x): { providerID: ProviderID; modelID: ModelID }[] => (Array.isArray(x.recent) ? x.recent : []))
|
||||||
|
.catch((): { providerID: ProviderID; modelID: ModelID }[] => []),
|
||||||
|
)
|
||||||
|
for (const entry of recent) {
|
||||||
|
const provider = s.providers[entry.providerID]
|
||||||
|
if (!provider) continue
|
||||||
|
if (!provider.models[entry.modelID]) continue
|
||||||
|
return { providerID: entry.providerID, modelID: entry.modelID }
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = Object.values(s.providers).find(
|
||||||
|
(p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id),
|
||||||
|
)
|
||||||
|
if (!provider) throw new Error("no providers found")
|
||||||
|
const [model] = sort(Object.values(provider.models))
|
||||||
|
if (!model) throw new Error("no models found")
|
||||||
|
return {
|
||||||
|
providerID: provider.id,
|
||||||
|
modelID: model.id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ list, getProvider, getModel, getLanguage, closest, getSmallModel, defaultModel })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { runPromise } = makeRuntime(
|
||||||
|
Service,
|
||||||
|
layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Auth.defaultLayer)),
|
||||||
|
)
|
||||||
|
|
||||||
|
export async function list() {
|
||||||
|
return runPromise((svc) => svc.list())
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProvider(providerID: ProviderID) {
|
||||||
|
return runPromise((svc) => svc.getProvider(providerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getModel(providerID: ProviderID, modelID: ModelID) {
|
||||||
|
return runPromise((svc) => svc.getModel(providerID, modelID))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLanguage(model: Model) {
|
||||||
|
return runPromise((svc) => svc.getLanguage(model))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closest(providerID: ProviderID, query: string[]) {
|
||||||
|
return runPromise((svc) => svc.closest(providerID, query))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSmallModel(providerID: ProviderID) {
|
||||||
|
return runPromise((svc) => svc.getSmallModel(providerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function defaultModel() {
|
||||||
|
return runPromise((svc) => svc.defaultModel())
|
||||||
}
|
}
|
||||||
|
|
||||||
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
|
const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
|
||||||
@@ -1491,33 +1584,6 @@ export namespace Provider {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function defaultModel() {
|
|
||||||
const cfg = await Config.get()
|
|
||||||
if (cfg.model) return parseModel(cfg.model)
|
|
||||||
|
|
||||||
const providers = await list()
|
|
||||||
const recent = (await Filesystem.readJson<{
|
|
||||||
recent?: { providerID: ProviderID; modelID: ModelID }[]
|
|
||||||
}>(path.join(Global.Path.state, "model.json"))
|
|
||||||
.then((x) => (Array.isArray(x.recent) ? x.recent : []))
|
|
||||||
.catch(() => [])) as { providerID: ProviderID; modelID: ModelID }[]
|
|
||||||
for (const entry of recent) {
|
|
||||||
const provider = providers[entry.providerID]
|
|
||||||
if (!provider) continue
|
|
||||||
if (!provider.models[entry.modelID]) continue
|
|
||||||
return { providerID: entry.providerID, modelID: entry.modelID }
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = Object.values(providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id))
|
|
||||||
if (!provider) throw new Error("no providers found")
|
|
||||||
const [model] = sort(Object.values(provider.models))
|
|
||||||
if (!model) throw new Error("no models found")
|
|
||||||
return {
|
|
||||||
providerID: provider.id,
|
|
||||||
modelID: model.id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseModel(model: string) {
|
export function parseModel(model: string) {
|
||||||
const [providerID, ...rest] = model.split("/")
|
const [providerID, ...rest] = model.split("/")
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
Effect.runPromise(
|
Effect.runPromise(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const match = input.processor.partFromToolCall(options.toolCallId)
|
const match = input.processor.partFromToolCall(options.toolCallId)
|
||||||
if (!match || match.state.status !== "running") return
|
if (!match || !["running", "pending"].includes(match.state.status)) return
|
||||||
yield* sessions.updatePart({
|
yield* sessions.updatePart({
|
||||||
...match,
|
...match,
|
||||||
state: {
|
state: {
|
||||||
@@ -1704,7 +1704,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
Layer.provide(Permission.layer),
|
Layer.provide(Permission.layer),
|
||||||
Layer.provide(MCP.defaultLayer),
|
Layer.provide(MCP.defaultLayer),
|
||||||
Layer.provide(LSP.defaultLayer),
|
Layer.provide(LSP.defaultLayer),
|
||||||
Layer.provide(FileTime.layer),
|
Layer.provide(FileTime.defaultLayer),
|
||||||
Layer.provide(ToolRegistry.defaultLayer),
|
Layer.provide(ToolRegistry.defaultLayer),
|
||||||
Layer.provide(Truncate.layer),
|
Layer.provide(Truncate.layer),
|
||||||
Layer.provide(AppFileSystem.defaultLayer),
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { makeRuntime } from "@/effect/run-service"
|
|||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { ConfigMarkdown } from "../config/markdown"
|
import { ConfigMarkdown } from "../config/markdown"
|
||||||
import { Glob } from "../util/glob"
|
import { Glob } from "../util/glob"
|
||||||
@@ -139,28 +139,20 @@ export namespace Skill {
|
|||||||
config: Config.Interface,
|
config: Config.Interface,
|
||||||
discovery: Discovery.Interface,
|
discovery: Discovery.Interface,
|
||||||
bus: Bus.Interface,
|
bus: Bus.Interface,
|
||||||
|
fsys: AppFileSystem.Interface,
|
||||||
directory: string,
|
directory: string,
|
||||||
worktree: string,
|
worktree: string,
|
||||||
) {
|
) {
|
||||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||||
for (const dir of EXTERNAL_DIRS) {
|
for (const dir of EXTERNAL_DIRS) {
|
||||||
const root = path.join(Global.Path.home, dir)
|
const root = path.join(Global.Path.home, dir)
|
||||||
const isDir = yield* Effect.promise(() => Filesystem.isDir(root))
|
if (!(yield* fsys.isDir(root))) continue
|
||||||
if (!isDir) continue
|
|
||||||
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "global" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const upDirs = yield* Effect.promise(async () => {
|
const upDirs = yield* fsys
|
||||||
const dirs: string[] = []
|
.up({ targets: EXTERNAL_DIRS, start: directory, stop: worktree })
|
||||||
for await (const root of Filesystem.up({
|
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
|
||||||
targets: EXTERNAL_DIRS,
|
|
||||||
start: directory,
|
|
||||||
stop: worktree,
|
|
||||||
})) {
|
|
||||||
dirs.push(root)
|
|
||||||
}
|
|
||||||
return dirs
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const root of upDirs) {
|
for (const root of upDirs) {
|
||||||
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
yield* scan(state, bus, root, EXTERNAL_SKILL_PATTERN, { dot: true, scope: "project" })
|
||||||
@@ -176,8 +168,7 @@ export namespace Skill {
|
|||||||
for (const item of cfg.skills?.paths ?? []) {
|
for (const item of cfg.skills?.paths ?? []) {
|
||||||
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
const expanded = item.startsWith("~/") ? path.join(os.homedir(), item.slice(2)) : item
|
||||||
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
|
const dir = path.isAbsolute(expanded) ? expanded : path.join(directory, expanded)
|
||||||
const isDir = yield* Effect.promise(() => Filesystem.isDir(dir))
|
if (!(yield* fsys.isDir(dir))) {
|
||||||
if (!isDir) {
|
|
||||||
log.warn("skill path not found", { path: dir })
|
log.warn("skill path not found", { path: dir })
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -198,16 +189,17 @@ export namespace Skill {
|
|||||||
|
|
||||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Skill") {}
|
||||||
|
|
||||||
export const layer: Layer.Layer<Service, never, Discovery.Service | Config.Service | Bus.Service> = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const discovery = yield* Discovery.Service
|
const discovery = yield* Discovery.Service
|
||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
|
const fsys = yield* AppFileSystem.Service
|
||||||
const state = yield* InstanceState.make(
|
const state = yield* InstanceState.make(
|
||||||
Effect.fn("Skill.state")(function* (ctx) {
|
Effect.fn("Skill.state")(function* (ctx) {
|
||||||
const s: State = { skills: {}, dirs: new Set() }
|
const s: State = { skills: {}, dirs: new Set() }
|
||||||
yield* loadSkills(s, config, discovery, bus, ctx.directory, ctx.worktree)
|
yield* loadSkills(s, config, discovery, bus, fsys, ctx.directory, ctx.worktree)
|
||||||
return s
|
return s
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -238,10 +230,11 @@ export namespace Skill {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
|
export const defaultLayer = layer.pipe(
|
||||||
Layer.provide(Discovery.defaultLayer),
|
Layer.provide(Discovery.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Bus.layer),
|
Layer.provide(Bus.layer),
|
||||||
|
Layer.provide(AppFileSystem.defaultLayer),
|
||||||
)
|
)
|
||||||
|
|
||||||
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
export function fmt(list: Info[], opts: { verbose: boolean }) {
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs/promises"
|
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import { lazy } from "../util/lazy"
|
|
||||||
import { Lock } from "../util/lock"
|
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Glob } from "../util/glob"
|
|
||||||
import { git } from "@/util/git"
|
import { git } from "@/util/git"
|
||||||
|
import { AppFileSystem } from "@/filesystem"
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { Effect, Exit, Layer, Option, RcMap, Schema, ServiceMap, TxReentrantLock } from "effect"
|
||||||
|
|
||||||
export namespace Storage {
|
export namespace Storage {
|
||||||
const log = Log.create({ service: "storage" })
|
const log = Log.create({ service: "storage" })
|
||||||
|
|
||||||
type Migration = (dir: string) => Promise<void>
|
type Migration = (dir: string, fs: AppFileSystem.Interface) => Effect.Effect<void, AppFileSystem.Error>
|
||||||
|
|
||||||
export const NotFoundError = NamedError.create(
|
export const NotFoundError = NamedError.create(
|
||||||
"NotFoundError",
|
"NotFoundError",
|
||||||
@@ -22,36 +20,101 @@ export namespace Storage {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export type Error = AppFileSystem.Error | InstanceType<typeof NotFoundError>
|
||||||
|
|
||||||
|
const RootFile = Schema.Struct({
|
||||||
|
path: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
root: Schema.optional(Schema.String),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const SessionFile = Schema.Struct({
|
||||||
|
id: Schema.String,
|
||||||
|
})
|
||||||
|
|
||||||
|
const MessageFile = Schema.Struct({
|
||||||
|
id: Schema.String,
|
||||||
|
})
|
||||||
|
|
||||||
|
const DiffFile = Schema.Struct({
|
||||||
|
additions: Schema.Number,
|
||||||
|
deletions: Schema.Number,
|
||||||
|
})
|
||||||
|
|
||||||
|
const SummaryFile = Schema.Struct({
|
||||||
|
id: Schema.String,
|
||||||
|
projectID: Schema.String,
|
||||||
|
summary: Schema.Struct({ diffs: Schema.Array(DiffFile) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const decodeRoot = Schema.decodeUnknownOption(RootFile)
|
||||||
|
const decodeSession = Schema.decodeUnknownOption(SessionFile)
|
||||||
|
const decodeMessage = Schema.decodeUnknownOption(MessageFile)
|
||||||
|
const decodeSummary = Schema.decodeUnknownOption(SummaryFile)
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly remove: (key: string[]) => Effect.Effect<void, AppFileSystem.Error>
|
||||||
|
readonly read: <T>(key: string[]) => Effect.Effect<T, Error>
|
||||||
|
readonly update: <T>(key: string[], fn: (draft: T) => void) => Effect.Effect<T, Error>
|
||||||
|
readonly write: <T>(key: string[], content: T) => Effect.Effect<void, AppFileSystem.Error>
|
||||||
|
readonly list: (prefix: string[]) => Effect.Effect<string[][], AppFileSystem.Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Storage") {}
|
||||||
|
|
||||||
|
function file(dir: string, key: string[]) {
|
||||||
|
return path.join(dir, ...key) + ".json"
|
||||||
|
}
|
||||||
|
|
||||||
|
function missing(err: unknown) {
|
||||||
|
if (!err || typeof err !== "object") return false
|
||||||
|
if ("code" in err && err.code === "ENOENT") return true
|
||||||
|
if ("reason" in err && err.reason && typeof err.reason === "object" && "_tag" in err.reason) {
|
||||||
|
return err.reason._tag === "NotFound"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMigration(text: string) {
|
||||||
|
const value = Number.parseInt(text, 10)
|
||||||
|
return Number.isNaN(value) ? 0 : value
|
||||||
|
}
|
||||||
|
|
||||||
const MIGRATIONS: Migration[] = [
|
const MIGRATIONS: Migration[] = [
|
||||||
async (dir) => {
|
Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface) {
|
||||||
const project = path.resolve(dir, "../project")
|
const project = path.resolve(dir, "../project")
|
||||||
if (!(await Filesystem.isDir(project))) return
|
if (!(yield* fs.isDir(project))) return
|
||||||
const projectDirs = await Glob.scan("*", {
|
const projectDirs = yield* fs.glob("*", {
|
||||||
cwd: project,
|
cwd: project,
|
||||||
include: "all",
|
include: "all",
|
||||||
})
|
})
|
||||||
for (const projectDir of projectDirs) {
|
for (const projectDir of projectDirs) {
|
||||||
const fullPath = path.join(project, projectDir)
|
const full = path.join(project, projectDir)
|
||||||
if (!(await Filesystem.isDir(fullPath))) continue
|
if (!(yield* fs.isDir(full))) continue
|
||||||
log.info(`migrating project ${projectDir}`)
|
log.info(`migrating project ${projectDir}`)
|
||||||
let projectID = projectDir
|
let projectID = projectDir
|
||||||
const fullProjectDir = path.join(project, projectDir)
|
|
||||||
let worktree = "/"
|
let worktree = "/"
|
||||||
|
|
||||||
if (projectID !== "global") {
|
if (projectID !== "global") {
|
||||||
for (const msgFile of await Glob.scan("storage/session/message/*/*.json", {
|
for (const msgFile of yield* fs.glob("storage/session/message/*/*.json", {
|
||||||
cwd: path.join(project, projectDir),
|
cwd: full,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
})) {
|
})) {
|
||||||
const json = await Filesystem.readJson<any>(msgFile)
|
const json = decodeRoot(yield* fs.readJson(msgFile), { onExcessProperty: "preserve" })
|
||||||
worktree = json.path?.root
|
const root = Option.isSome(json) ? json.value.path?.root : undefined
|
||||||
if (worktree) break
|
if (!root) continue
|
||||||
|
worktree = root
|
||||||
|
break
|
||||||
}
|
}
|
||||||
if (!worktree) continue
|
if (!worktree) continue
|
||||||
if (!(await Filesystem.isDir(worktree))) continue
|
if (!(yield* fs.isDir(worktree))) continue
|
||||||
const result = await git(["rev-list", "--max-parents=0", "--all"], {
|
const result = yield* Effect.promise(() =>
|
||||||
|
git(["rev-list", "--max-parents=0", "--all"], {
|
||||||
cwd: worktree,
|
cwd: worktree,
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
const [id] = result
|
const [id] = result
|
||||||
.text()
|
.text()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -61,7 +124,10 @@ export namespace Storage {
|
|||||||
if (!id) continue
|
if (!id) continue
|
||||||
projectID = id
|
projectID = id
|
||||||
|
|
||||||
await Filesystem.writeJson(path.join(dir, "project", projectID + ".json"), {
|
yield* fs.writeWithDirs(
|
||||||
|
path.join(dir, "project", projectID + ".json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
id,
|
id,
|
||||||
vcs: "git",
|
vcs: "git",
|
||||||
worktree,
|
worktree,
|
||||||
@@ -69,149 +135,219 @@ export namespace Storage {
|
|||||||
created: Date.now(),
|
created: Date.now(),
|
||||||
initialized: Date.now(),
|
initialized: Date.now(),
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
log.info(`migrating sessions for project ${projectID}`)
|
log.info(`migrating sessions for project ${projectID}`)
|
||||||
for (const sessionFile of await Glob.scan("storage/session/info/*.json", {
|
for (const sessionFile of yield* fs.glob("storage/session/info/*.json", {
|
||||||
cwd: fullProjectDir,
|
cwd: full,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
})) {
|
})) {
|
||||||
const dest = path.join(dir, "session", projectID, path.basename(sessionFile))
|
const dest = path.join(dir, "session", projectID, path.basename(sessionFile))
|
||||||
log.info("copying", {
|
log.info("copying", { sessionFile, dest })
|
||||||
sessionFile,
|
const session = yield* fs.readJson(sessionFile)
|
||||||
dest,
|
const info = decodeSession(session, { onExcessProperty: "preserve" })
|
||||||
})
|
yield* fs.writeWithDirs(dest, JSON.stringify(session, null, 2))
|
||||||
const session = await Filesystem.readJson<any>(sessionFile)
|
if (Option.isNone(info)) continue
|
||||||
await Filesystem.writeJson(dest, session)
|
log.info(`migrating messages for session ${info.value.id}`)
|
||||||
log.info(`migrating messages for session ${session.id}`)
|
for (const msgFile of yield* fs.glob(`storage/session/message/${info.value.id}/*.json`, {
|
||||||
for (const msgFile of await Glob.scan(`storage/session/message/${session.id}/*.json`, {
|
cwd: full,
|
||||||
cwd: fullProjectDir,
|
|
||||||
absolute: true,
|
absolute: true,
|
||||||
})) {
|
})) {
|
||||||
const dest = path.join(dir, "message", session.id, path.basename(msgFile))
|
const next = path.join(dir, "message", info.value.id, path.basename(msgFile))
|
||||||
log.info("copying", {
|
log.info("copying", {
|
||||||
msgFile,
|
msgFile,
|
||||||
dest,
|
dest: next,
|
||||||
})
|
})
|
||||||
const message = await Filesystem.readJson<any>(msgFile)
|
const message = yield* fs.readJson(msgFile)
|
||||||
await Filesystem.writeJson(dest, message)
|
const item = decodeMessage(message, { onExcessProperty: "preserve" })
|
||||||
|
yield* fs.writeWithDirs(next, JSON.stringify(message, null, 2))
|
||||||
|
if (Option.isNone(item)) continue
|
||||||
|
|
||||||
log.info(`migrating parts for message ${message.id}`)
|
log.info(`migrating parts for message ${item.value.id}`)
|
||||||
for (const partFile of await Glob.scan(`storage/session/part/${session.id}/${message.id}/*.json`, {
|
for (const partFile of yield* fs.glob(`storage/session/part/${info.value.id}/${item.value.id}/*.json`, {
|
||||||
cwd: fullProjectDir,
|
cwd: full,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
})) {
|
})) {
|
||||||
const dest = path.join(dir, "part", message.id, path.basename(partFile))
|
const out = path.join(dir, "part", item.value.id, path.basename(partFile))
|
||||||
const part = await Filesystem.readJson(partFile)
|
const part = yield* fs.readJson(partFile)
|
||||||
log.info("copying", {
|
log.info("copying", {
|
||||||
partFile,
|
partFile,
|
||||||
dest,
|
dest: out,
|
||||||
})
|
})
|
||||||
await Filesystem.writeJson(dest, part)
|
yield* fs.writeWithDirs(out, JSON.stringify(part, null, 2))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
async (dir) => {
|
Effect.fn("Storage.migration.2")(function* (dir: string, fs: AppFileSystem.Interface) {
|
||||||
for (const item of await Glob.scan("session/*/*.json", {
|
for (const item of yield* fs.glob("session/*/*.json", {
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
})) {
|
})) {
|
||||||
const session = await Filesystem.readJson<any>(item)
|
const raw = yield* fs.readJson(item)
|
||||||
if (!session.projectID) continue
|
const session = decodeSummary(raw, { onExcessProperty: "preserve" })
|
||||||
if (!session.summary?.diffs) continue
|
if (Option.isNone(session)) continue
|
||||||
const { diffs } = session.summary
|
const diffs = session.value.summary.diffs
|
||||||
await Filesystem.write(path.join(dir, "session_diff", session.id + ".json"), JSON.stringify(diffs))
|
yield* fs.writeWithDirs(
|
||||||
await Filesystem.writeJson(path.join(dir, "session", session.projectID, session.id + ".json"), {
|
path.join(dir, "session_diff", session.value.id + ".json"),
|
||||||
...session,
|
JSON.stringify(diffs, null, 2),
|
||||||
|
)
|
||||||
|
yield* fs.writeWithDirs(
|
||||||
|
path.join(dir, "session", session.value.projectID, session.value.id + ".json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
...(raw as Record<string, unknown>),
|
||||||
summary: {
|
summary: {
|
||||||
additions: diffs.reduce((sum: any, x: any) => sum + x.additions, 0),
|
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
||||||
deletions: diffs.reduce((sum: any, x: any) => sum + x.deletions, 0),
|
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
const state = lazy(async () => {
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
const locks = yield* RcMap.make({
|
||||||
|
lookup: () => TxReentrantLock.make(),
|
||||||
|
idleTimeToLive: 0,
|
||||||
|
})
|
||||||
|
const state = yield* Effect.cached(
|
||||||
|
Effect.gen(function* () {
|
||||||
const dir = path.join(Global.Path.data, "storage")
|
const dir = path.join(Global.Path.data, "storage")
|
||||||
const migration = await Filesystem.readJson<string>(path.join(dir, "migration"))
|
const marker = path.join(dir, "migration")
|
||||||
.then((x) => parseInt(x))
|
const migration = yield* fs.readFileString(marker).pipe(
|
||||||
.catch(() => 0)
|
Effect.map(parseMigration),
|
||||||
for (let index = migration; index < MIGRATIONS.length; index++) {
|
Effect.catchIf(missing, () => Effect.succeed(0)),
|
||||||
log.info("running migration", { index })
|
Effect.orElseSucceed(() => 0),
|
||||||
const migration = MIGRATIONS[index]
|
)
|
||||||
await migration(dir).catch(() => log.error("failed to run migration", { index }))
|
for (let i = migration; i < MIGRATIONS.length; i++) {
|
||||||
await Filesystem.write(path.join(dir, "migration"), (index + 1).toString())
|
log.info("running migration", { index: i })
|
||||||
|
const step = MIGRATIONS[i]!
|
||||||
|
const exit = yield* Effect.exit(step(dir, fs))
|
||||||
|
if (Exit.isFailure(exit)) {
|
||||||
|
log.error("failed to run migration", { index: i, cause: exit.cause })
|
||||||
|
break
|
||||||
}
|
}
|
||||||
return {
|
yield* fs.writeWithDirs(marker, String(i + 1))
|
||||||
dir,
|
|
||||||
}
|
}
|
||||||
|
return { dir }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const fail = (target: string): Effect.Effect<never, InstanceType<typeof NotFoundError>> =>
|
||||||
|
Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` }))
|
||||||
|
|
||||||
|
const wrap = <A>(target: string, body: Effect.Effect<A, AppFileSystem.Error>) =>
|
||||||
|
body.pipe(Effect.catchIf(missing, () => fail(target)))
|
||||||
|
|
||||||
|
const writeJson = Effect.fnUntraced(function* (target: string, content: unknown) {
|
||||||
|
yield* fs.writeWithDirs(target, JSON.stringify(content, null, 2))
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function remove(key: string[]) {
|
const withResolved = <A, E>(
|
||||||
const dir = await state().then((x) => x.dir)
|
key: string[],
|
||||||
const target = path.join(dir, ...key) + ".json"
|
fn: (target: string, rw: TxReentrantLock.TxReentrantLock) => Effect.Effect<A, E>,
|
||||||
return withErrorHandling(async () => {
|
): Effect.Effect<A, E | AppFileSystem.Error> =>
|
||||||
await fs.unlink(target).catch(() => {})
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const target = file((yield* state).dir, key)
|
||||||
|
return yield* fn(target, yield* RcMap.get(locks, target))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const remove: Interface["remove"] = Effect.fn("Storage.remove")(function* (key: string[]) {
|
||||||
|
yield* withResolved(key, (target, rw) =>
|
||||||
|
TxReentrantLock.withWriteLock(rw, fs.remove(target).pipe(Effect.catchIf(missing, () => Effect.void))),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const read: Interface["read"] = <T>(key: string[]) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const value = yield* withResolved(key, (target, rw) =>
|
||||||
|
TxReentrantLock.withReadLock(rw, wrap(target, fs.readJson(target))),
|
||||||
|
)
|
||||||
|
return value as T
|
||||||
|
})
|
||||||
|
|
||||||
|
const update: Interface["update"] = <T>(key: string[], fn: (draft: T) => void) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const value = yield* withResolved(key, (target, rw) =>
|
||||||
|
TxReentrantLock.withWriteLock(
|
||||||
|
rw,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const content = yield* wrap(target, fs.readJson(target))
|
||||||
|
fn(content as T)
|
||||||
|
yield* writeJson(target, content)
|
||||||
|
return content
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return value as T
|
||||||
|
})
|
||||||
|
|
||||||
|
const write: Interface["write"] = (key: string[], content: unknown) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* withResolved(key, (target, rw) => TxReentrantLock.withWriteLock(rw, writeJson(target, content)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const list: Interface["list"] = Effect.fn("Storage.list")(function* (prefix: string[]) {
|
||||||
|
const dir = (yield* state).dir
|
||||||
|
const cwd = path.join(dir, ...prefix)
|
||||||
|
const result = yield* fs
|
||||||
|
.glob("**/*", {
|
||||||
|
cwd,
|
||||||
|
include: "file",
|
||||||
|
})
|
||||||
|
.pipe(Effect.catch(() => Effect.succeed<string[]>([])))
|
||||||
|
return result
|
||||||
|
.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)])
|
||||||
|
.toSorted((a, b) => a.join("/").localeCompare(b.join("/")))
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
remove,
|
||||||
|
read,
|
||||||
|
update,
|
||||||
|
write,
|
||||||
|
list,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
|
|
||||||
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
export async function remove(key: string[]) {
|
||||||
|
return runPromise((svc) => svc.remove(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function read<T>(key: string[]) {
|
export async function read<T>(key: string[]) {
|
||||||
const dir = await state().then((x) => x.dir)
|
return runPromise((svc) => svc.read<T>(key))
|
||||||
const target = path.join(dir, ...key) + ".json"
|
|
||||||
return withErrorHandling(async () => {
|
|
||||||
using _ = await Lock.read(target)
|
|
||||||
const result = await Filesystem.readJson<T>(target)
|
|
||||||
return result as T
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function update<T>(key: string[], fn: (draft: T) => void) {
|
export async function update<T>(key: string[], fn: (draft: T) => void) {
|
||||||
const dir = await state().then((x) => x.dir)
|
return runPromise((svc) => svc.update<T>(key, fn))
|
||||||
const target = path.join(dir, ...key) + ".json"
|
|
||||||
return withErrorHandling(async () => {
|
|
||||||
using _ = await Lock.write(target)
|
|
||||||
const content = await Filesystem.readJson<T>(target)
|
|
||||||
fn(content as T)
|
|
||||||
await Filesystem.writeJson(target, content)
|
|
||||||
return content
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function write<T>(key: string[], content: T) {
|
export async function write<T>(key: string[], content: T) {
|
||||||
const dir = await state().then((x) => x.dir)
|
return runPromise((svc) => svc.write(key, content))
|
||||||
const target = path.join(dir, ...key) + ".json"
|
|
||||||
return withErrorHandling(async () => {
|
|
||||||
using _ = await Lock.write(target)
|
|
||||||
await Filesystem.writeJson(target, content)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withErrorHandling<T>(body: () => Promise<T>) {
|
|
||||||
return body().catch((e) => {
|
|
||||||
if (!(e instanceof Error)) throw e
|
|
||||||
const errnoException = e as NodeJS.ErrnoException
|
|
||||||
if (errnoException.code === "ENOENT") {
|
|
||||||
throw new NotFoundError({ message: `Resource not found: ${errnoException.path}` })
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list(prefix: string[]) {
|
export async function list(prefix: string[]) {
|
||||||
const dir = await state().then((x) => x.dir)
|
return runPromise((svc) => svc.list(prefix))
|
||||||
try {
|
|
||||||
const result = await Glob.scan("**/*", {
|
|
||||||
cwd: path.join(dir, ...prefix),
|
|
||||||
include: "file",
|
|
||||||
}).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]))
|
|
||||||
result.sort()
|
|
||||||
return result
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,6 +331,117 @@ describe("plugin.loader.shared", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("loads npm server plugin from package server export without leading dot", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const mod = path.join(dir, "mods", "acme-plugin")
|
||||||
|
const dist = path.join(mod, "dist")
|
||||||
|
const mark = path.join(dir, "server-called.txt")
|
||||||
|
await fs.mkdir(dist, { recursive: true })
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(mod, "package.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: "acme-plugin",
|
||||||
|
type: "module",
|
||||||
|
exports: {
|
||||||
|
".": "./index.js",
|
||||||
|
"./server": "dist/server.js",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await Bun.write(path.join(mod, "index.js"), 'import "./main-throws.js"\nexport default {}\n')
|
||||||
|
await Bun.write(path.join(mod, "main-throws.js"), 'throw new Error("main loaded")\n')
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dist, "server.js"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
|
||||||
|
|
||||||
|
return {
|
||||||
|
mod,
|
||||||
|
mark,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errors = await errs(tmp.path)
|
||||||
|
expect(errors).toHaveLength(0)
|
||||||
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
||||||
|
} finally {
|
||||||
|
install.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("loads npm server plugin from package main without leading dot", async () => {
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
const mod = path.join(dir, "mods", "acme-plugin")
|
||||||
|
const dist = path.join(mod, "dist")
|
||||||
|
const mark = path.join(dir, "main-called.txt")
|
||||||
|
await fs.mkdir(dist, { recursive: true })
|
||||||
|
|
||||||
|
await Bun.write(
|
||||||
|
path.join(mod, "package.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: "acme-plugin",
|
||||||
|
type: "module",
|
||||||
|
main: "dist/index.js",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dist, "index.js"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "called")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["acme-plugin@1.0.0"] }, null, 2))
|
||||||
|
|
||||||
|
return {
|
||||||
|
mod,
|
||||||
|
mark,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errors = await errs(tmp.path)
|
||||||
|
expect(errors).toHaveLength(0)
|
||||||
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
||||||
|
} finally {
|
||||||
|
install.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("does not use npm package exports dot for server entry", async () => {
|
test("does not use npm package exports dot for server entry", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
|
|||||||
@@ -13,6 +13,18 @@ afterEach(async () => {
|
|||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function withoutWatcher<T>(fn: () => Promise<T>) {
|
||||||
|
if (process.platform !== "win32") return fn()
|
||||||
|
const prev = process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
|
||||||
|
process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = "true"
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
|
||||||
|
else process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fill(sessionID: SessionID, count: number, time = (i: number) => Date.now() + i) {
|
async function fill(sessionID: SessionID, count: number, time = (i: number) => Date.now() + i) {
|
||||||
const ids = [] as MessageID[]
|
const ids = [] as MessageID[]
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -42,7 +54,8 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
|
|||||||
describe("session messages endpoint", () => {
|
describe("session messages endpoint", () => {
|
||||||
test("returns cursor headers for older pages", async () => {
|
test("returns cursor headers for older pages", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
await Instance.provide({
|
await withoutWatcher(() =>
|
||||||
|
Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const session = await Session.create({})
|
const session = await Session.create({})
|
||||||
@@ -64,12 +77,14 @@ describe("session messages endpoint", () => {
|
|||||||
|
|
||||||
await Session.remove(session.id)
|
await Session.remove(session.id)
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps full-history responses when limit is omitted", async () => {
|
test("keeps full-history responses when limit is omitted", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
await Instance.provide({
|
await withoutWatcher(() =>
|
||||||
|
Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const session = await Session.create({})
|
const session = await Session.create({})
|
||||||
@@ -83,12 +98,14 @@ describe("session messages endpoint", () => {
|
|||||||
|
|
||||||
await Session.remove(session.id)
|
await Session.remove(session.id)
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("rejects invalid cursors and missing sessions", async () => {
|
test("rejects invalid cursors and missing sessions", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
await Instance.provide({
|
await withoutWatcher(() =>
|
||||||
|
Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const session = await Session.create({})
|
const session = await Session.create({})
|
||||||
@@ -102,12 +119,14 @@ describe("session messages endpoint", () => {
|
|||||||
|
|
||||||
await Session.remove(session.id)
|
await Session.remove(session.id)
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not truncate large legacy limit requests", async () => {
|
test("does not truncate large legacy limit requests", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
await Instance.provide({
|
await withoutWatcher(() =>
|
||||||
|
Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const session = await Session.create({})
|
const session = await Session.create({})
|
||||||
@@ -121,7 +140,8 @@ describe("session messages endpoint", () => {
|
|||||||
|
|
||||||
await Session.remove(session.id)
|
await Session.remove(session.id)
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
|
import { AppFileSystem } from "../../src/filesystem"
|
||||||
|
import { Global } from "../../src/global"
|
||||||
|
import { Storage } from "../../src/storage/storage"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
const dir = path.join(Global.Path.data, "storage")
|
||||||
|
|
||||||
|
async function withScope<T>(fn: (root: string[]) => Promise<T>) {
|
||||||
|
const root = ["storage_test", crypto.randomUUID()]
|
||||||
|
try {
|
||||||
|
return await fn(root)
|
||||||
|
} finally {
|
||||||
|
await fs.rm(path.join(dir, ...root), { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function map(root: string, file: string) {
|
||||||
|
if (file === Global.Path.data) return root
|
||||||
|
if (file.startsWith(Global.Path.data + path.sep)) return path.join(root, path.relative(Global.Path.data, file))
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
function layer(root: string) {
|
||||||
|
return Layer.effect(
|
||||||
|
AppFileSystem.Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
return AppFileSystem.Service.of({
|
||||||
|
...fs,
|
||||||
|
isDir: (file) => fs.isDir(map(root, file)),
|
||||||
|
readJson: (file) => fs.readJson(map(root, file)),
|
||||||
|
writeWithDirs: (file, content, mode) => fs.writeWithDirs(map(root, file), content, mode),
|
||||||
|
readFileString: (file) => fs.readFileString(map(root, file)),
|
||||||
|
remove: (file) => fs.remove(map(root, file)),
|
||||||
|
glob: (pattern, options) =>
|
||||||
|
fs.glob(pattern, options?.cwd ? { ...options, cwd: map(root, options.cwd) } : options),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
).pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withStorage<T>(
|
||||||
|
root: string,
|
||||||
|
fn: (run: <A, E>(body: Effect.Effect<A, E, Storage.Service>) => Promise<A>) => Promise<T>,
|
||||||
|
) {
|
||||||
|
const rt = ManagedRuntime.make(Storage.layer.pipe(Layer.provide(layer(root))))
|
||||||
|
try {
|
||||||
|
return await fn((body) => rt.runPromise(body))
|
||||||
|
} finally {
|
||||||
|
await rt.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function write(file: string, value: unknown) {
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true })
|
||||||
|
await Bun.write(file, JSON.stringify(value, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function text(file: string, value: string) {
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true })
|
||||||
|
await Bun.write(file, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(file: string) {
|
||||||
|
return fs
|
||||||
|
.stat(file)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Storage", () => {
|
||||||
|
test("round-trips JSON content", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
const key = [...root, "session_diff", "roundtrip"]
|
||||||
|
const value = [{ file: "a.ts", additions: 2, deletions: 1 }]
|
||||||
|
|
||||||
|
await Storage.write(key, value)
|
||||||
|
|
||||||
|
expect(await Storage.read<typeof value>(key)).toEqual(value)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("maps missing reads to NotFoundError", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
await expect(Storage.read([...root, "missing", "value"])).rejects.toMatchObject({ name: "NotFoundError" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("update on missing key throws NotFoundError", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
await expect(
|
||||||
|
Storage.update<{ value: number }>([...root, "missing", "key"], (draft) => {
|
||||||
|
draft.value += 1
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ name: "NotFoundError" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("write overwrites existing value", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
const key = [...root, "overwrite", "test"]
|
||||||
|
await Storage.write<{ v: number }>(key, { v: 1 })
|
||||||
|
await Storage.write<{ v: number }>(key, { v: 2 })
|
||||||
|
|
||||||
|
expect(await Storage.read<{ v: number }>(key)).toEqual({ v: 2 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("remove on missing key is a no-op", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
await expect(Storage.remove([...root, "nonexistent", "key"])).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("list on missing prefix returns empty", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
expect(await Storage.list([...root, "nonexistent"])).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("serializes concurrent updates for the same key", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
const key = [...root, "counter", "shared"]
|
||||||
|
await Storage.write(key, { value: 0 })
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 25 }, () =>
|
||||||
|
Storage.update<{ value: number }>(key, (draft) => {
|
||||||
|
draft.value += 1
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await Storage.read<{ value: number }>(key)).toEqual({ value: 25 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("concurrent reads do not block each other", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
const key = [...root, "concurrent", "reads"]
|
||||||
|
await Storage.write(key, { ok: true })
|
||||||
|
|
||||||
|
const results = await Promise.all(Array.from({ length: 10 }, () => Storage.read(key)))
|
||||||
|
|
||||||
|
expect(results).toHaveLength(10)
|
||||||
|
for (const r of results) expect(r).toEqual({ ok: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("nested keys create deep paths", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
const key = [...root, "a", "b", "c", "deep"]
|
||||||
|
await Storage.write<{ nested: boolean }>(key, { nested: true })
|
||||||
|
|
||||||
|
expect(await Storage.read<{ nested: boolean }>(key)).toEqual({ nested: true })
|
||||||
|
expect(await Storage.list([...root, "a"])).toEqual([key])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("lists and removes stored entries", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
const a = [...root, "list", "a"]
|
||||||
|
const b = [...root, "list", "b"]
|
||||||
|
const prefix = [...root, "list"]
|
||||||
|
|
||||||
|
await Storage.write(b, { value: 2 })
|
||||||
|
await Storage.write(a, { value: 1 })
|
||||||
|
|
||||||
|
expect(await Storage.list(prefix)).toEqual([a, b])
|
||||||
|
|
||||||
|
await Storage.remove(a)
|
||||||
|
|
||||||
|
expect(await Storage.list(prefix)).toEqual([b])
|
||||||
|
await expect(Storage.read(a)).rejects.toMatchObject({ name: "NotFoundError" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("migration 2 runs when marker contents are invalid", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const storage = path.join(tmp.path, "storage")
|
||||||
|
const diffs = [
|
||||||
|
{ additions: 2, deletions: 1 },
|
||||||
|
{ additions: 3, deletions: 4 },
|
||||||
|
]
|
||||||
|
|
||||||
|
await text(path.join(storage, "migration"), "wat")
|
||||||
|
await write(path.join(storage, "session", "proj_test", "ses_test.json"), {
|
||||||
|
id: "ses_test",
|
||||||
|
projectID: "proj_test",
|
||||||
|
title: "legacy",
|
||||||
|
summary: { diffs },
|
||||||
|
})
|
||||||
|
|
||||||
|
await withStorage(tmp.path, async (run) => {
|
||||||
|
expect(await run(Storage.Service.use((svc) => svc.list(["session_diff"])))).toEqual([
|
||||||
|
["session_diff", "ses_test"],
|
||||||
|
])
|
||||||
|
expect(await run(Storage.Service.use((svc) => svc.read<typeof diffs>(["session_diff", "ses_test"])))).toEqual(
|
||||||
|
diffs,
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
await run(
|
||||||
|
Storage.Service.use((svc) =>
|
||||||
|
svc.read<{
|
||||||
|
id: string
|
||||||
|
projectID: string
|
||||||
|
title: string
|
||||||
|
summary: {
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
}
|
||||||
|
}>(["session", "proj_test", "ses_test"]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
id: "ses_test",
|
||||||
|
projectID: "proj_test",
|
||||||
|
title: "legacy",
|
||||||
|
summary: {
|
||||||
|
additions: 5,
|
||||||
|
deletions: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await Bun.file(path.join(storage, "migration")).text()).toBe("2")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("migration 1 tolerates malformed legacy records", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
const storage = path.join(tmp.path, "storage")
|
||||||
|
const legacy = path.join(tmp.path, "project", "legacy")
|
||||||
|
|
||||||
|
await write(path.join(legacy, "storage", "session", "message", "probe", "0.json"), [])
|
||||||
|
await write(path.join(legacy, "storage", "session", "message", "probe", "1.json"), {
|
||||||
|
path: { root: tmp.path },
|
||||||
|
})
|
||||||
|
await write(path.join(legacy, "storage", "session", "info", "ses_legacy.json"), {
|
||||||
|
id: "ses_legacy",
|
||||||
|
title: "legacy",
|
||||||
|
})
|
||||||
|
await write(path.join(legacy, "storage", "session", "message", "ses_legacy", "msg_legacy.json"), {
|
||||||
|
role: "user",
|
||||||
|
text: "hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
await withStorage(tmp.path, async (run) => {
|
||||||
|
const projects = await run(Storage.Service.use((svc) => svc.list(["project"])))
|
||||||
|
expect(projects).toHaveLength(1)
|
||||||
|
const project = projects[0]![1]
|
||||||
|
|
||||||
|
expect(await run(Storage.Service.use((svc) => svc.list(["session", project])))).toEqual([
|
||||||
|
["session", project, "ses_legacy"],
|
||||||
|
])
|
||||||
|
expect(
|
||||||
|
await run(
|
||||||
|
Storage.Service.use((svc) => svc.read<{ id: string; title: string }>(["session", project, "ses_legacy"])),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
id: "ses_legacy",
|
||||||
|
title: "legacy",
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
await run(
|
||||||
|
Storage.Service.use((svc) =>
|
||||||
|
svc.read<{ role: string; text: string }>(["message", "ses_legacy", "msg_legacy"]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
role: "user",
|
||||||
|
text: "hello",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await Bun.file(path.join(storage, "migration")).text()).toBe("2")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("failed migrations do not advance the marker", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const storage = path.join(tmp.path, "storage")
|
||||||
|
const legacy = path.join(tmp.path, "project", "legacy")
|
||||||
|
|
||||||
|
await text(path.join(legacy, "storage", "session", "message", "probe", "0.json"), "{")
|
||||||
|
|
||||||
|
await withStorage(tmp.path, async (run) => {
|
||||||
|
expect(await run(Storage.Service.use((svc) => svc.list(["project"])))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await exists(path.join(storage, "migration"))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||||
|
|||||||
+51
-236
@@ -1,249 +1,40 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { $ } from "bun"
|
import { rm } from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
import { parseArgs } from "util"
|
import { parseArgs } from "util"
|
||||||
|
|
||||||
type Release = {
|
const root = path.resolve(import.meta.dir, "..")
|
||||||
tag_name: string
|
const file = path.join(root, "UPCOMING_CHANGELOG.md")
|
||||||
draft: boolean
|
const { values, positionals } = parseArgs({
|
||||||
}
|
|
||||||
|
|
||||||
type Commit = {
|
|
||||||
hash: string
|
|
||||||
author: string | null
|
|
||||||
message: string
|
|
||||||
areas: Set<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
type User = Map<string, Set<string>>
|
|
||||||
type Diff = {
|
|
||||||
sha: string
|
|
||||||
login: string | null
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const repo = process.env.GH_REPO ?? "anomalyco/opencode"
|
|
||||||
const bot = ["actions-user", "opencode", "opencode-agent[bot]"]
|
|
||||||
const team = [
|
|
||||||
...(await Bun.file(new URL("../.github/TEAM_MEMBERS", import.meta.url))
|
|
||||||
.text()
|
|
||||||
.then((x) => x.split(/\r?\n/).map((x) => x.trim()))
|
|
||||||
.then((x) => x.filter((x) => x && !x.startsWith("#")))),
|
|
||||||
...bot,
|
|
||||||
]
|
|
||||||
const order = ["Core", "TUI", "Desktop", "SDK", "Extensions"] as const
|
|
||||||
const sections = {
|
|
||||||
core: "Core",
|
|
||||||
tui: "TUI",
|
|
||||||
app: "Desktop",
|
|
||||||
tauri: "Desktop",
|
|
||||||
sdk: "SDK",
|
|
||||||
plugin: "SDK",
|
|
||||||
"extensions/zed": "Extensions",
|
|
||||||
"extensions/vscode": "Extensions",
|
|
||||||
github: "Extensions",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
function ref(input: string) {
|
|
||||||
if (input === "HEAD") return input
|
|
||||||
if (input.startsWith("v")) return input
|
|
||||||
if (input.match(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/)) return `v${input}`
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
|
|
||||||
async function latest() {
|
|
||||||
const data = await $`gh api "/repos/${repo}/releases?per_page=100"`.json()
|
|
||||||
const release = (data as Release[]).find((item) => !item.draft)
|
|
||||||
if (!release) throw new Error("No releases found")
|
|
||||||
return release.tag_name.replace(/^v/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function diff(base: string, head: string) {
|
|
||||||
const list: Diff[] = []
|
|
||||||
for (let page = 1; ; page++) {
|
|
||||||
const text =
|
|
||||||
await $`gh api "/repos/${repo}/compare/${base}...${head}?per_page=100&page=${page}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text()
|
|
||||||
const batch = text
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((line) => JSON.parse(line) as Diff)
|
|
||||||
if (batch.length === 0) break
|
|
||||||
list.push(...batch)
|
|
||||||
if (batch.length < 100) break
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
||||||
function section(areas: Set<string>) {
|
|
||||||
const priority = ["core", "tui", "app", "tauri", "sdk", "plugin", "extensions/zed", "extensions/vscode", "github"]
|
|
||||||
for (const area of priority) {
|
|
||||||
if (areas.has(area)) return sections[area as keyof typeof sections]
|
|
||||||
}
|
|
||||||
return "Core"
|
|
||||||
}
|
|
||||||
|
|
||||||
function reverted(commits: Commit[]) {
|
|
||||||
const seen = new Map<string, Commit>()
|
|
||||||
|
|
||||||
for (const commit of commits) {
|
|
||||||
const match = commit.message.match(/^Revert "(.+)"$/)
|
|
||||||
if (match) {
|
|
||||||
const msg = match[1]!
|
|
||||||
if (seen.has(msg)) seen.delete(msg)
|
|
||||||
else seen.set(commit.message, commit)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const revert = `Revert "${commit.message}"`
|
|
||||||
if (seen.has(revert)) {
|
|
||||||
seen.delete(revert)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
seen.set(commit.message, commit)
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...seen.values()]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function commits(from: string, to: string) {
|
|
||||||
const base = ref(from)
|
|
||||||
const head = ref(to)
|
|
||||||
|
|
||||||
const data = new Map<string, { login: string | null; message: string }>()
|
|
||||||
for (const item of await diff(base, head)) {
|
|
||||||
data.set(item.sha, { login: item.login, message: item.message.split("\n")[0] ?? "" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const log =
|
|
||||||
await $`git log ${base}..${head} --format=%H -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text()
|
|
||||||
|
|
||||||
const list: Commit[] = []
|
|
||||||
for (const hash of log.split("\n").filter(Boolean)) {
|
|
||||||
const item = data.get(hash)
|
|
||||||
if (!item) continue
|
|
||||||
if (item.message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
|
|
||||||
|
|
||||||
const diff = await $`git diff-tree --no-commit-id --name-only -r ${hash}`.text()
|
|
||||||
const areas = new Set<string>()
|
|
||||||
|
|
||||||
for (const file of diff.split("\n").filter(Boolean)) {
|
|
||||||
if (file.startsWith("packages/opencode/src/cli/cmd/")) areas.add("tui")
|
|
||||||
else if (file.startsWith("packages/opencode/")) areas.add("core")
|
|
||||||
else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri")
|
|
||||||
else if (file.startsWith("packages/desktop/") || file.startsWith("packages/app/")) areas.add("app")
|
|
||||||
else if (file.startsWith("packages/sdk/") || file.startsWith("packages/plugin/")) areas.add("sdk")
|
|
||||||
else if (file.startsWith("packages/extensions/")) areas.add("extensions/zed")
|
|
||||||
else if (file.startsWith("sdks/vscode/") || file.startsWith("github/")) areas.add("extensions/vscode")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (areas.size === 0) continue
|
|
||||||
|
|
||||||
list.push({
|
|
||||||
hash: hash.slice(0, 7),
|
|
||||||
author: item.login,
|
|
||||||
message: item.message,
|
|
||||||
areas,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return reverted(list)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function contributors(from: string, to: string) {
|
|
||||||
const base = ref(from)
|
|
||||||
const head = ref(to)
|
|
||||||
|
|
||||||
const users: User = new Map()
|
|
||||||
for (const item of await diff(base, head)) {
|
|
||||||
const title = item.message.split("\n")[0] ?? ""
|
|
||||||
if (!item.login || team.includes(item.login)) continue
|
|
||||||
if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
|
|
||||||
if (!users.has(item.login)) users.set(item.login, new Set())
|
|
||||||
users.get(item.login)!.add(title)
|
|
||||||
}
|
|
||||||
|
|
||||||
return users
|
|
||||||
}
|
|
||||||
|
|
||||||
async function published(to: string) {
|
|
||||||
if (to === "HEAD") return
|
|
||||||
const body = await $`gh release view ${ref(to)} --repo ${repo} --json body --jq .body`.text().catch(() => "")
|
|
||||||
if (!body) return
|
|
||||||
|
|
||||||
const lines = body.split(/\r?\n/)
|
|
||||||
const start = lines.findIndex((line) => line.startsWith("**Thank you to "))
|
|
||||||
if (start < 0) return
|
|
||||||
return lines.slice(start).join("\n").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function thanks(from: string, to: string, reuse: boolean) {
|
|
||||||
const release = reuse ? await published(to) : undefined
|
|
||||||
if (release) return release.split(/\r?\n/)
|
|
||||||
|
|
||||||
const users = await contributors(from, to)
|
|
||||||
if (users.size === 0) return []
|
|
||||||
|
|
||||||
const lines = [`**Thank you to ${users.size} community contributor${users.size > 1 ? "s" : ""}:**`]
|
|
||||||
for (const [name, commits] of users) {
|
|
||||||
lines.push(`- @${name}:`)
|
|
||||||
for (const commit of commits) lines.push(` - ${commit}`)
|
|
||||||
}
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
function format(from: string, to: string, list: Commit[], thanks: string[]) {
|
|
||||||
const grouped = new Map<string, string[]>()
|
|
||||||
for (const title of order) grouped.set(title, [])
|
|
||||||
|
|
||||||
for (const commit of list) {
|
|
||||||
const title = section(commit.areas)
|
|
||||||
const attr = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : ""
|
|
||||||
grouped.get(title)!.push(`- \`${commit.hash}\` ${commit.message}${attr}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = [`Last release: ${ref(from)}`, `Target ref: ${to}`, ""]
|
|
||||||
|
|
||||||
if (list.length === 0) {
|
|
||||||
lines.push("No notable changes.")
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const title of order) {
|
|
||||||
const entries = grouped.get(title)
|
|
||||||
if (!entries || entries.length === 0) continue
|
|
||||||
lines.push(`## ${title}`)
|
|
||||||
lines.push(...entries)
|
|
||||||
lines.push("")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thanks.length > 0) {
|
|
||||||
if (lines.at(-1) !== "") lines.push("")
|
|
||||||
lines.push("## Community Contributors Input")
|
|
||||||
lines.push("")
|
|
||||||
lines.push(...thanks)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lines.at(-1) === "") lines.pop()
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.main) {
|
|
||||||
const { values } = parseArgs({
|
|
||||||
args: Bun.argv.slice(2),
|
args: Bun.argv.slice(2),
|
||||||
options: {
|
options: {
|
||||||
from: { type: "string", short: "f" },
|
from: { type: "string", short: "f" },
|
||||||
to: { type: "string", short: "t", default: "HEAD" },
|
to: { type: "string", short: "t" },
|
||||||
|
variant: { type: "string", default: "low" },
|
||||||
|
quiet: { type: "boolean", default: false },
|
||||||
|
print: { type: "boolean", default: false },
|
||||||
help: { type: "boolean", short: "h", default: false },
|
help: { type: "boolean", short: "h", default: false },
|
||||||
},
|
},
|
||||||
})
|
allowPositionals: true,
|
||||||
|
})
|
||||||
|
const args = [...positionals]
|
||||||
|
|
||||||
if (values.help) {
|
if (values.from) args.push("--from", values.from)
|
||||||
|
if (values.to) args.push("--to", values.to)
|
||||||
|
|
||||||
|
if (values.help) {
|
||||||
console.log(`
|
console.log(`
|
||||||
Usage: bun script/changelog.ts [options]
|
Usage: bun script/changelog.ts [options]
|
||||||
|
|
||||||
|
Generates UPCOMING_CHANGELOG.md by running the opencode changelog command.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-f, --from <version> Starting version (default: latest non-draft GitHub release)
|
-f, --from <version> Starting version (default: latest non-draft GitHub release)
|
||||||
-t, --to <ref> Ending ref (default: HEAD)
|
-t, --to <ref> Ending ref (default: HEAD)
|
||||||
|
--variant <name> Thinking variant for opencode run (default: low)
|
||||||
|
--quiet Suppress opencode command output unless it fails
|
||||||
|
--print Print the generated UPCOMING_CHANGELOG.md after success
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -252,10 +43,34 @@ Examples:
|
|||||||
bun script/changelog.ts -f 1.0.200 -t 1.0.205
|
bun script/changelog.ts -f 1.0.200 -t 1.0.205
|
||||||
`)
|
`)
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
|
||||||
|
|
||||||
const to = values.to!
|
|
||||||
const from = values.from ?? (await latest())
|
|
||||||
const list = await commits(from, to)
|
|
||||||
console.log(format(from, to, list, await thanks(from, to, !values.from)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await rm(file, { force: true })
|
||||||
|
|
||||||
|
const quiet = values.quiet
|
||||||
|
const cmd = ["opencode", "run"]
|
||||||
|
cmd.push("--variant", values.variant)
|
||||||
|
cmd.push("--command", "changelog", "--", ...args)
|
||||||
|
|
||||||
|
const proc = Bun.spawn(cmd, {
|
||||||
|
cwd: root,
|
||||||
|
stdin: "inherit",
|
||||||
|
stdout: quiet ? "pipe" : "inherit",
|
||||||
|
stderr: quiet ? "pipe" : "inherit",
|
||||||
|
})
|
||||||
|
|
||||||
|
const [out, err] = quiet
|
||||||
|
? await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
|
||||||
|
: ["", ""]
|
||||||
|
const code = await proc.exited
|
||||||
|
if (code === 0) {
|
||||||
|
if (values.print) process.stdout.write(await Bun.file(file).text())
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quiet) {
|
||||||
|
if (out) process.stdout.write(out)
|
||||||
|
if (err) process.stderr.write(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(code)
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { $ } from "bun"
|
||||||
|
import { parseArgs } from "util"
|
||||||
|
|
||||||
|
type Release = {
|
||||||
|
tag_name: string
|
||||||
|
draft: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Commit = {
|
||||||
|
hash: string
|
||||||
|
author: string | null
|
||||||
|
message: string
|
||||||
|
areas: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
type User = Map<string, Set<string>>
|
||||||
|
type Diff = {
|
||||||
|
sha: string
|
||||||
|
login: string | null
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = process.env.GH_REPO ?? "anomalyco/opencode"
|
||||||
|
const bot = ["actions-user", "opencode", "opencode-agent[bot]"]
|
||||||
|
const team = [
|
||||||
|
...(await Bun.file(new URL("../.github/TEAM_MEMBERS", import.meta.url))
|
||||||
|
.text()
|
||||||
|
.then((x) => x.split(/\r?\n/).map((x) => x.trim()))
|
||||||
|
.then((x) => x.filter((x) => x && !x.startsWith("#")))),
|
||||||
|
...bot,
|
||||||
|
]
|
||||||
|
const order = ["Core", "TUI", "Desktop", "SDK", "Extensions"] as const
|
||||||
|
const sections = {
|
||||||
|
core: "Core",
|
||||||
|
tui: "TUI",
|
||||||
|
app: "Desktop",
|
||||||
|
tauri: "Desktop",
|
||||||
|
sdk: "SDK",
|
||||||
|
plugin: "SDK",
|
||||||
|
"extensions/zed": "Extensions",
|
||||||
|
"extensions/vscode": "Extensions",
|
||||||
|
github: "Extensions",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
function ref(input: string) {
|
||||||
|
if (input === "HEAD") return input
|
||||||
|
if (input.startsWith("v")) return input
|
||||||
|
if (input.match(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/)) return `v${input}`
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
async function latest() {
|
||||||
|
const data = await $`gh api "/repos/${repo}/releases?per_page=100"`.json()
|
||||||
|
const release = (data as Release[]).find((item) => !item.draft)
|
||||||
|
if (!release) throw new Error("No releases found")
|
||||||
|
return release.tag_name.replace(/^v/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function diff(base: string, head: string) {
|
||||||
|
const list: Diff[] = []
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const text =
|
||||||
|
await $`gh api "/repos/${repo}/compare/${base}...${head}?per_page=100&page=${page}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text()
|
||||||
|
const batch = text
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line) as Diff)
|
||||||
|
if (batch.length === 0) break
|
||||||
|
list.push(...batch)
|
||||||
|
if (batch.length < 100) break
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(areas: Set<string>) {
|
||||||
|
const priority = ["core", "tui", "app", "tauri", "sdk", "plugin", "extensions/zed", "extensions/vscode", "github"]
|
||||||
|
for (const area of priority) {
|
||||||
|
if (areas.has(area)) return sections[area as keyof typeof sections]
|
||||||
|
}
|
||||||
|
return "Core"
|
||||||
|
}
|
||||||
|
|
||||||
|
function reverted(commits: Commit[]) {
|
||||||
|
const seen = new Map<string, Commit>()
|
||||||
|
|
||||||
|
for (const commit of commits) {
|
||||||
|
const match = commit.message.match(/^Revert "(.+)"$/)
|
||||||
|
if (match) {
|
||||||
|
const msg = match[1]!
|
||||||
|
if (seen.has(msg)) seen.delete(msg)
|
||||||
|
else seen.set(commit.message, commit)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const revert = `Revert "${commit.message}"`
|
||||||
|
if (seen.has(revert)) {
|
||||||
|
seen.delete(revert)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.set(commit.message, commit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...seen.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commits(from: string, to: string) {
|
||||||
|
const base = ref(from)
|
||||||
|
const head = ref(to)
|
||||||
|
|
||||||
|
const data = new Map<string, { login: string | null; message: string }>()
|
||||||
|
for (const item of await diff(base, head)) {
|
||||||
|
data.set(item.sha, { login: item.login, message: item.message.split("\n")[0] ?? "" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const log =
|
||||||
|
await $`git log ${base}..${head} --format=%H -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text()
|
||||||
|
|
||||||
|
const list: Commit[] = []
|
||||||
|
for (const hash of log.split("\n").filter(Boolean)) {
|
||||||
|
const item = data.get(hash)
|
||||||
|
if (!item) continue
|
||||||
|
if (item.message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
|
||||||
|
|
||||||
|
const diff = await $`git diff-tree --no-commit-id --name-only -r ${hash}`.text()
|
||||||
|
const areas = new Set<string>()
|
||||||
|
|
||||||
|
for (const file of diff.split("\n").filter(Boolean)) {
|
||||||
|
if (file.startsWith("packages/opencode/src/cli/cmd/")) areas.add("tui")
|
||||||
|
else if (file.startsWith("packages/opencode/")) areas.add("core")
|
||||||
|
else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri")
|
||||||
|
else if (file.startsWith("packages/desktop/") || file.startsWith("packages/app/")) areas.add("app")
|
||||||
|
else if (file.startsWith("packages/sdk/") || file.startsWith("packages/plugin/")) areas.add("sdk")
|
||||||
|
else if (file.startsWith("packages/extensions/")) areas.add("extensions/zed")
|
||||||
|
else if (file.startsWith("sdks/vscode/") || file.startsWith("github/")) areas.add("extensions/vscode")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (areas.size === 0) continue
|
||||||
|
|
||||||
|
list.push({
|
||||||
|
hash: hash.slice(0, 7),
|
||||||
|
author: item.login,
|
||||||
|
message: item.message,
|
||||||
|
areas,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return reverted(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function contributors(from: string, to: string) {
|
||||||
|
const base = ref(from)
|
||||||
|
const head = ref(to)
|
||||||
|
|
||||||
|
const users: User = new Map()
|
||||||
|
for (const item of await diff(base, head)) {
|
||||||
|
const title = item.message.split("\n")[0] ?? ""
|
||||||
|
if (!item.login || team.includes(item.login)) continue
|
||||||
|
if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
|
||||||
|
if (!users.has(item.login)) users.set(item.login, new Set())
|
||||||
|
users.get(item.login)!.add(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
|
||||||
|
async function published(to: string) {
|
||||||
|
if (to === "HEAD") return
|
||||||
|
const body = await $`gh release view ${ref(to)} --repo ${repo} --json body --jq .body`.text().catch(() => "")
|
||||||
|
if (!body) return
|
||||||
|
|
||||||
|
const lines = body.split(/\r?\n/)
|
||||||
|
const start = lines.findIndex((line) => line.startsWith("**Thank you to "))
|
||||||
|
if (start < 0) return
|
||||||
|
return lines.slice(start).join("\n").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function thanks(from: string, to: string, reuse: boolean) {
|
||||||
|
const release = reuse ? await published(to) : undefined
|
||||||
|
if (release) return release.split(/\r?\n/)
|
||||||
|
|
||||||
|
const users = await contributors(from, to)
|
||||||
|
if (users.size === 0) return []
|
||||||
|
|
||||||
|
const lines = [`**Thank you to ${users.size} community contributor${users.size > 1 ? "s" : ""}:**`]
|
||||||
|
for (const [name, commits] of users) {
|
||||||
|
lines.push(`- @${name}:`)
|
||||||
|
for (const commit of commits) lines.push(` - ${commit}`)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
function format(from: string, to: string, list: Commit[], thanks: string[]) {
|
||||||
|
const grouped = new Map<string, string[]>()
|
||||||
|
for (const title of order) grouped.set(title, [])
|
||||||
|
|
||||||
|
for (const commit of list) {
|
||||||
|
const title = section(commit.areas)
|
||||||
|
const attr = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : ""
|
||||||
|
grouped.get(title)!.push(`- \`${commit.hash}\` ${commit.message}${attr}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [`Last release: ${ref(from)}`, `Target ref: ${to}`, ""]
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
lines.push("No notable changes.")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const title of order) {
|
||||||
|
const entries = grouped.get(title)
|
||||||
|
if (!entries || entries.length === 0) continue
|
||||||
|
lines.push(`## ${title}`)
|
||||||
|
lines.push(...entries)
|
||||||
|
lines.push("")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thanks.length > 0) {
|
||||||
|
if (lines.at(-1) !== "") lines.push("")
|
||||||
|
lines.push("## Community Contributors Input")
|
||||||
|
lines.push("")
|
||||||
|
lines.push(...thanks)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.at(-1) === "") lines.pop()
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const { values } = parseArgs({
|
||||||
|
args: Bun.argv.slice(2),
|
||||||
|
options: {
|
||||||
|
from: { type: "string", short: "f" },
|
||||||
|
to: { type: "string", short: "t", default: "HEAD" },
|
||||||
|
help: { type: "boolean", short: "h", default: false },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (values.help) {
|
||||||
|
console.log(`
|
||||||
|
Usage: bun script/raw-changelog.ts [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-f, --from <version> Starting version (default: latest non-draft GitHub release)
|
||||||
|
-t, --to <ref> Ending ref (default: HEAD)
|
||||||
|
-h, --help Show this help message
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bun script/raw-changelog.ts
|
||||||
|
bun script/raw-changelog.ts --from 1.0.200
|
||||||
|
bun script/raw-changelog.ts -f 1.0.200 -t 1.0.205
|
||||||
|
`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const to = values.to!
|
||||||
|
const from = values.from ?? (await latest())
|
||||||
|
const list = await commits(from, to)
|
||||||
|
console.log(format(from, to, list, await thanks(from, to, !values.from)))
|
||||||
|
}
|
||||||
+1
-1
@@ -7,7 +7,7 @@ const output = [`version=${Script.version}`]
|
|||||||
|
|
||||||
if (!Script.preview) {
|
if (!Script.preview) {
|
||||||
const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim()
|
const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim()
|
||||||
await $`opencode run --command changelog -- --to ${sha}`.cwd(process.cwd())
|
await $`bun script/changelog.ts --to ${sha}`.cwd(process.cwd())
|
||||||
const file = `${process.cwd()}/UPCOMING_CHANGELOG.md`
|
const file = `${process.cwd()}/UPCOMING_CHANGELOG.md`
|
||||||
const body = await Bun.file(file)
|
const body = await Bun.file(file)
|
||||||
.text()
|
.text()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"displayName": "opencode",
|
"displayName": "opencode",
|
||||||
"description": "opencode for VS Code",
|
"description": "opencode for VS Code",
|
||||||
"version": "1.3.8",
|
"version": "1.3.10",
|
||||||
"publisher": "sst-dev",
|
"publisher": "sst-dev",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
Reference in New Issue
Block a user