diff --git a/.opencode/command/changelog.md b/.opencode/command/changelog.md index f0ff1e422d..4cd30a704a 100644 --- a/.opencode/command/changelog.md +++ b/.opencode/command/changelog.md @@ -1,22 +1,19 @@ --- -model: opencode/kimi-k2.5 +model: opencode/gpt-5.4 --- Create `UPCOMING_CHANGELOG.md` from the structured changelog input below. If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely. 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 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 input may also include a `## Community Contributors Input` section. Before writing any entry you keep, inspect the real diff with -`git show --stat --format='' ` or `git show --format='' ` so the -summary reflects the actual user-facing change and not just the commit message. +`git show --stat --format='' ` or `git show --format='' ` so you can +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. Rules: @@ -38,7 +35,12 @@ Rules: - 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 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` + + +!`bun script/raw-changelog.ts $ARGUMENTS` + + diff --git a/bun.lock b/bun.lock index 1410d70059..83be56c584 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.0.14", + "version": "7.2.4", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -82,7 +82,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.0.14", + "version": "7.2.4", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -115,7 +115,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.0.14", + "version": "7.2.4", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -331,7 +331,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "1.0.14", + "version": "7.2.4", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -466,7 +466,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "1.0.14", + "version": "7.2.4", "dependencies": { "@kilocode/sdk": "workspace:*", "zod": "catalog:", @@ -490,7 +490,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "1.0.14", + "version": "7.2.4", "dependencies": { "semver": "^7.6.3", }, @@ -501,7 +501,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "1.0.14", + "version": "7.2.4", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -512,7 +512,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "1.0.14", + "version": "7.2.4", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -535,7 +535,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.0.14", + "version": "7.2.4", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -583,7 +583,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.0.14", + "version": "7.2.4", "dependencies": { "zod": "catalog:", }, diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index afc5d08765..6b59d4ed94 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -137,7 +137,8 @@ export const dict = { "provider.connect.oauth.code.invalid": "رمز التفويض غير صالح", "provider.connect.oauth.auto.visit.prefix": "قم بزيارة ", "provider.connect.oauth.auto.visit.link": "هذا الرابط", - "provider.connect.oauth.auto.visit.suffix": " وأدخل الرمز أدناه لتوصيل حسابك واستخدام نماذج {{provider}} في Kilo.", + "provider.connect.oauth.auto.visit.suffix": + " وأدخل الرمز أدناه لتوصيل حسابك واستخدام نماذج {{provider}} في Kilo.", "provider.connect.oauth.auto.confirmationCode": "رمز التأكيد", "provider.connect.toast.connected.title": "تم توصيل {{provider}}", "provider.connect.toast.connected.description": "نماذج {{provider}} متاحة الآن للاستخدام.", diff --git a/packages/desktop/src/i18n/de.ts b/packages/desktop/src/i18n/de.ts index 224e0b62d2..6ef1167a36 100644 --- a/packages/desktop/src/i18n/de.ts +++ b/packages/desktop/src/i18n/de.ts @@ -45,7 +45,8 @@ export const dict = { "desktop.menu.help.shareFeedback": "Feedback teilen", "desktop.menu.help.reportBug": "Einen Fehler melden", "desktop.cli.error.unsupportedPlatform": "Die CLI-Installation wird nur unter macOS und Linux unterstützt.", - "desktop.cli.error.sidecarMissing": "Das Kilo CLI-Binary fehlt. Versuchen Sie, die Desktop-App neu zu installieren.", + "desktop.cli.error.sidecarMissing": + "Das Kilo CLI-Binary fehlt. Versuchen Sie, die Desktop-App neu zu installieren.", "desktop.cli.error.scriptWriteFailed": "Das CLI-Installationsskript konnte nicht vorbereitet werden.", "desktop.cli.error.scriptPermissionFailed": "Das CLI-Installationsskript konnte nicht ausführbar gemacht werden.", "desktop.cli.error.scriptRunFailed": "Das CLI-Installationsskript konnte nicht ausgeführt werden.", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index e3a8e1c13b..fea3e33a07 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "1.3.9" +version = "1.3.10" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.9/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.10/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.9/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.10/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.9/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.10/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.9/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.10/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.9/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.3.10/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/opencode/specs/effect-migration.md b/packages/opencode/specs/effect-migration.md index 38871356fd..8e45491ccd 100644 --- a/packages/opencode/specs/effect-migration.md +++ b/packages/opencode/specs/effect-migration.md @@ -210,15 +210,13 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade): - [x] `Vcs` — `project/vcs.ts` - [x] `Worktree` — `worktree/index.ts` -Still open and likely worth migrating: - - [x] `Session` — `session/index.ts` -- [ ] `SessionProcessor` — blocked by AI SDK v6 PR (#18433) -- [ ] `SessionPrompt` — blocked by AI SDK v6 PR (#18433) -- [ ] `SessionCompaction` — blocked by AI SDK v6 PR (#18433) -- [ ] `Provider` — blocked by AI SDK v6 PR (#18433) +- [x] `SessionProcessor` — `session/processor.ts` +- [x] `SessionPrompt` — `session/prompt.ts` +- [x] `SessionCompaction` — `session/compaction.ts` +- [x] `Provider` — `provider/provider.ts` -Other services not yet migrated: +Still open: - [ ] `SessionSummary` — `session/summary.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 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: diff --git a/packages/opencode/src/cli/cmd/tui/context/theme/catppuccin-frappe.json b/packages/opencode/src/cli/cmd/tui/context/theme/catppuccin-frappe.json index 79e56ee9af..61f86a87a7 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme/catppuccin-frappe.json +++ b/packages/opencode/src/cli/cmd/tui/context/theme/catppuccin-frappe.json @@ -62,8 +62,8 @@ "light": "frappeText" }, "textMuted": { - "dark": "frappeSubtext1", - "light": "frappeSubtext1" + "dark": "frappeOverlay2", + "light": "frappeOverlay2" }, "background": { "dark": "frappeBase", diff --git a/packages/opencode/src/cli/cmd/tui/context/theme/catppuccin-macchiato.json b/packages/opencode/src/cli/cmd/tui/context/theme/catppuccin-macchiato.json index 6d9827dfe8..1cbca3c3ff 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme/catppuccin-macchiato.json +++ b/packages/opencode/src/cli/cmd/tui/context/theme/catppuccin-macchiato.json @@ -62,8 +62,8 @@ "light": "macText" }, "textMuted": { - "dark": "macSubtext1", - "light": "macSubtext1" + "dark": "macOverlay2", + "light": "macOverlay2" }, "background": { "dark": "macBase", diff --git a/packages/opencode/src/cli/cmd/tui/context/theme/catppuccin.json b/packages/opencode/src/cli/cmd/tui/context/theme/catppuccin.json index d0fa6a11d9..48e825212e 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme/catppuccin.json +++ b/packages/opencode/src/cli/cmd/tui/context/theme/catppuccin.json @@ -63,7 +63,7 @@ "success": { "dark": "darkGreen", "light": "lightGreen" }, "info": { "dark": "darkTeal", "light": "lightTeal" }, "text": { "dark": "darkText", "light": "lightText" }, - "textMuted": { "dark": "darkSubtext1", "light": "lightSubtext1" }, + "textMuted": { "dark": "darkOverlay2", "light": "lightOverlay2" }, "background": { "dark": "darkBase", "light": "lightBase" }, "backgroundPanel": { "dark": "darkMantle", "light": "lightMantle" }, "backgroundElement": { "dark": "darkCrust", "light": "lightCrust" }, diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 59c06c1769..437715d0e0 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -169,7 +169,9 @@ export function DialogProvider(props: ParentProps) { evt.preventDefault() evt.stopPropagation() }} - onMouseUp={!Flag.KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? () => Selection.copy(renderer, toast) : undefined} + onMouseUp={ + !Flag.KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? () => Selection.copy(renderer, toast) : undefined + } > value.clear()} size={value.size}> diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 4fa6113ada..472b33c897 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -31,9 +31,12 @@ export namespace Flag { export const KILO_DISABLE_AUTOCOMPACT = truthy("KILO_DISABLE_AUTOCOMPACT") export const KILO_DISABLE_MODELS_FETCH = truthy("KILO_DISABLE_MODELS_FETCH") export const KILO_DISABLE_CLAUDE_CODE = truthy("KILO_DISABLE_CLAUDE_CODE") - export const KILO_DISABLE_CLAUDE_CODE_PROMPT = KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_PROMPT") - export const KILO_DISABLE_CLAUDE_CODE_SKILLS = KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_SKILLS") - export const KILO_DISABLE_EXTERNAL_SKILLS = KILO_DISABLE_CLAUDE_CODE_SKILLS || truthy("KILO_DISABLE_EXTERNAL_SKILLS") + export const KILO_DISABLE_CLAUDE_CODE_PROMPT = + KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_PROMPT") + export const KILO_DISABLE_CLAUDE_CODE_SKILLS = + KILO_DISABLE_CLAUDE_CODE || truthy("KILO_DISABLE_CLAUDE_CODE_SKILLS") + export const KILO_DISABLE_EXTERNAL_SKILLS = + KILO_DISABLE_CLAUDE_CODE_SKILLS || truthy("KILO_DISABLE_EXTERNAL_SKILLS") export declare const KILO_DISABLE_PROJECT_CONFIG: boolean export const KILO_FAKE_VCS = process.env["KILO_FAKE_VCS"] export declare const KILO_CLIENT: string @@ -46,15 +49,17 @@ export namespace Flag { export const KILO_EXPERIMENTAL_FILEWATCHER = Config.boolean("KILO_EXPERIMENTAL_FILEWATCHER").pipe( Config.withDefault(false), ) - export const KILO_EXPERIMENTAL_DISABLE_FILEWATCHER = Config.boolean("KILO_EXPERIMENTAL_DISABLE_FILEWATCHER").pipe( - Config.withDefault(false), - ) - export const KILO_EXPERIMENTAL_ICON_DISCOVERY = KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_ICON_DISCOVERY") + export const KILO_EXPERIMENTAL_DISABLE_FILEWATCHER = Config.boolean( + "KILO_EXPERIMENTAL_DISABLE_FILEWATCHER", + ).pipe(Config.withDefault(false)) + export const KILO_EXPERIMENTAL_ICON_DISCOVERY = + KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_ICON_DISCOVERY") const copy = process.env["KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"] export const KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = copy === undefined ? process.platform === "win32" : truthy("KILO_EXPERIMENTAL_DISABLE_COPY_ON_SELECT") - export const KILO_ENABLE_EXA = truthy("KILO_ENABLE_EXA") || KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_EXA") + export const KILO_ENABLE_EXA = + truthy("KILO_ENABLE_EXA") || KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_EXA") export const KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS") export const KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX = number("KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX") export const KILO_EXPERIMENTAL_OXFMT = KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_OXFMT") diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 44348609b4..62aa3d85b4 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -19,6 +19,9 @@ import { iife } from "@/util/iife" import { Global } from "../global" import path from "path" 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 import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock" @@ -893,6 +896,29 @@ export namespace Provider { }) export type Info = z.infer + export interface Interface { + readonly list: () => Effect.Effect> + readonly getProvider: (providerID: ProviderID) => Effect.Effect + readonly getModel: (providerID: ProviderID, modelID: ModelID) => Effect.Effect + readonly getLanguage: (model: Model) => Effect.Effect + readonly closest: ( + providerID: ProviderID, + query: string[], + ) => Effect.Effect<{ providerID: ProviderID; modelID: string } | undefined> + readonly getSmallModel: (providerID: ProviderID) => Effect.Effect + readonly defaultModel: () => Effect.Effect<{ providerID: ProviderID; modelID: ModelID }> + } + + interface State { + models: Map + providers: Record + sdk: Map + modelLoaders: Record + varsLoaders: Record + } + + export class Service extends ServiceMap.Service()("@opencode/Provider") {} + function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model { const m: Model = { id: ModelID.make(model.id), @@ -978,581 +1004,650 @@ export namespace Provider { } } - const state = Instance.state(async () => { - using _ = log.time("state") - const config = await Config.get() - const modelsDev = await ModelsDev.get() - const database = mapValues(modelsDev, fromModelsDevProvider) + const layer: Layer.Layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + const auth = yield* Auth.Service - const disabled = new Set(config.disabled_providers ?? []) - const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null + const cache = yield* InstanceState.make(() => + Effect.gen(function* () { + using _ = log.time("state") + const cfg = yield* config.get() + const modelsDev = yield* Effect.promise(() => ModelsDev.get()) + const database = mapValues(modelsDev, fromModelsDevProvider) - function isProviderAllowed(providerID: ProviderID): boolean { - if (enabled && !enabled.has(providerID)) return false - if (disabled.has(providerID)) return false - return true - } + const disabled = new Set(cfg.disabled_providers ?? []) + const enabled = cfg.enabled_providers ? new Set(cfg.enabled_providers) : null - const providers: Record = {} as Record - const languages = new Map() - const modelLoaders: { - [providerID: string]: CustomModelLoader - } = {} - const varsLoaders: { - [providerID: string]: CustomVarsLoader - } = {} - const sdk = new Map() - const discoveryLoaders: { - [providerID: string]: CustomDiscoverModels - } = {} - - log.info("init") - - const configProviders = Object.entries(config.provider ?? {}) - - function mergeProvider(providerID: ProviderID, provider: Partial) { - const existing = providers[providerID] - if (existing) { - // @ts-expect-error - providers[providerID] = mergeDeep(existing, provider) - return - } - const match = database[providerID] - if (!match) return - // @ts-expect-error - providers[providerID] = mergeDeep(match, provider) - } - - // extend database from config - for (const [providerID, provider] of configProviders) { - const existing = database[providerID] - const parsed: Info = { - id: ProviderID.make(providerID), - name: provider.name ?? existing?.name ?? providerID, - env: provider.env ?? existing?.env ?? [], - options: mergeDeep(existing?.options ?? {}, provider.options ?? {}), - source: "config", - models: existing?.models ?? {}, - } - - for (const [modelID, model] of Object.entries(provider.models ?? {})) { - const existingModel = parsed.models[model.id ?? modelID] - const name = iife(() => { - if (model.name) return model.name - if (model.id && model.id !== modelID) return modelID - return existingModel?.name ?? modelID - }) - const parsedModel: Model = { - id: ModelID.make(modelID), - api: { - id: model.id ?? existingModel?.api.id ?? modelID, - npm: - model.provider?.npm ?? - provider.npm ?? - existingModel?.api.npm ?? - modelsDev[providerID]?.npm ?? - "@ai-sdk/openai-compatible", - url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api, - }, - status: model.status ?? existingModel?.status ?? "active", - name, - providerID: ProviderID.make(providerID), - capabilities: { - temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false, - reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false, - attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false, - toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true, - input: { - text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true, - audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? 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, - }, - output: { - text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true, - audio: model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? 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, - }, - interleaved: model.interleaved ?? false, - }, - cost: { - input: model?.cost?.input ?? existingModel?.cost?.input ?? 0, - output: model?.cost?.output ?? existingModel?.cost?.output ?? 0, - cache: { - read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0, - write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0, - }, - }, - options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}), - limit: { - context: model.limit?.context ?? existingModel?.limit?.context ?? 0, - output: model.limit?.output ?? existingModel?.limit?.output ?? 0, - }, - headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}), - family: model.family ?? existingModel?.family ?? "", - release_date: model.release_date ?? existingModel?.release_date ?? "", - variants: {}, - - // kilocode_change start - recommendedIndex: model.recommendedIndex ?? existingModel?.recommendedIndex, - prompt: model.prompt ?? existingModel?.prompt, - isFree: model.isFree ?? existingModel?.isFree, - ai_sdk_provider: model.ai_sdk_provider ?? existingModel?.ai_sdk_provider, - // kilocode_change end - } - const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {}) - parsedModel.variants = mapValues( - pickBy(merged, (v) => !v.disabled), - (v) => omit(v, ["disabled"]), - ) - parsed.models[modelID] = parsedModel - } - database[providerID] = parsed - } - - // load env - const env = Env.all() - for (const [id, provider] of Object.entries(database)) { - const providerID = ProviderID.make(id) - if (disabled.has(providerID)) continue - const apiKey = provider.env.map((item) => env[item]).find(Boolean) - if (!apiKey) continue - mergeProvider(providerID, { - source: "env", - key: provider.env.length === 1 ? apiKey : undefined, - }) - } - - // load apikeys - for (const [id, provider] of Object.entries(await Auth.all())) { - const providerID = ProviderID.make(id) - if (disabled.has(providerID)) continue - if (provider.type === "api") { - mergeProvider(providerID, { - source: "api", - key: provider.key, - }) - } - } - - for (const plugin of await Plugin.list()) { - if (!plugin.auth) continue - const providerID = ProviderID.make(plugin.auth.provider) - if (disabled.has(providerID)) continue - - const auth = await Auth.get(providerID) - if (!auth) continue - if (!plugin.auth.loader) continue - - if (auth) { - const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) - const opts = options ?? {} - const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } - mergeProvider(providerID, patch) - } - } - - for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) { - const providerID = ProviderID.make(id) - if (disabled.has(providerID)) continue - const data = database[providerID] - if (!data) { - log.error("Provider does not exist in model list " + providerID) - continue - } - const result = await fn(data) - if (result && (result.autoload || providers[providerID])) { - if (result.getModel) modelLoaders[providerID] = result.getModel - if (result.vars) varsLoaders[providerID] = result.vars - if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels - const opts = result.options ?? {} - const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } - mergeProvider(providerID, patch) - } - } - - // load config - for (const [id, provider] of configProviders) { - const providerID = ProviderID.make(id) - const partial: Partial = { source: "config" } - if (provider.env) partial.env = provider.env - if (provider.name) partial.name = provider.name - if (provider.options) partial.options = provider.options - mergeProvider(providerID, partial) - } - - for (const [id, provider] of Object.entries(providers)) { - const providerID = ProviderID.make(id) - if (!isProviderAllowed(providerID)) { - delete providers[providerID] - continue - } - - const configProvider = config.provider?.[providerID] - - for (const [modelID, model] of Object.entries(provider.models)) { - model.api.id = model.api.id ?? model.id ?? modelID - if ( - modelID === "gpt-5-chat-latest" || - (providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat") - ) - delete provider.models[modelID] - if (model.status === "alpha" && !Flag.KILO_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID] - if (model.status === "deprecated") delete provider.models[modelID] - if ( - (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) || - (configProvider?.whitelist && !configProvider.whitelist.includes(modelID)) - ) - delete provider.models[modelID] - - model.variants = mapValues(ProviderTransform.variants(model), (v) => v) - - // Filter out disabled variants from config - const configVariants = configProvider?.models?.[modelID]?.variants - if (configVariants && model.variants) { - const merged = mergeDeep(model.variants, configVariants) - model.variants = mapValues( - pickBy(merged, (v) => !v.disabled), - (v) => omit(v, ["disabled"]), - ) - } - } - - if (Object.keys(provider.models).length === 0) { - delete providers[providerID] - continue - } - - log.info("found", { providerID }) - } - - const gitlab = ProviderID.make("gitlab") - if (discoveryLoaders[gitlab] && providers[gitlab]) { - await (async () => { - const discovered = await discoveryLoaders[gitlab]() - for (const [modelID, model] of Object.entries(discovered)) { - if (!providers[gitlab].models[modelID]) { - providers[gitlab].models[modelID] = model + function isProviderAllowed(providerID: ProviderID): boolean { + if (enabled && !enabled.has(providerID)) return false + if (disabled.has(providerID)) return false + return true } - } - })().catch((e) => log.warn("state discovery error", { id: "gitlab", error: e })) - } - return { - models: languages, - providers, - sdk, - modelLoaders, - varsLoaders, - } - }) + const providers: Record = {} as Record + const languages = new Map() + const modelLoaders: { + [providerID: string]: CustomModelLoader + } = {} + const varsLoaders: { + [providerID: string]: CustomVarsLoader + } = {} + const sdk = new Map() + const discoveryLoaders: { + [providerID: string]: CustomDiscoverModels + } = {} - export async function list() { - return state().then((state) => state.providers) - } + log.info("init") - async function getSDK(model: Model) { - try { - using _ = log.time("getSDK", { - providerID: model.providerID, - }) - const s = await state() - const provider = s.providers[model.providerID] - const options = { ...provider.options } + const configProviders = Object.entries(cfg.provider ?? {}) - if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) { - delete options.fetch - } - - if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) { - options["includeUsage"] = true - } - - const baseURL = iife(() => { - let url = - typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url - 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] - if (loader) { - const vars = loader(options) - for (const [key, value] of Object.entries(vars)) { - const field = "${" + key + "}" - url = url.replaceAll(field, value) + function mergeProvider(providerID: ProviderID, provider: Partial) { + const existing = providers[providerID] + if (existing) { + // @ts-expect-error + providers[providerID] = mergeDeep(existing, provider) + return + } + const match = database[providerID] + if (!match) return + // @ts-expect-error + providers[providerID] = mergeDeep(match, provider) } - } - url = url.replace(/\$\{([^}]+)\}/g, (item, key) => { - const val = Env.get(String(key)) - return val ?? item - }) - return url - }) + // extend database from config + for (const [providerID, provider] of configProviders) { + const existing = database[providerID] + const parsed: Info = { + id: ProviderID.make(providerID), + name: provider.name ?? existing?.name ?? providerID, + env: provider.env ?? existing?.env ?? [], + options: mergeDeep(existing?.options ?? {}, provider.options ?? {}), + source: "config", + models: existing?.models ?? {}, + } - if (baseURL !== undefined) options["baseURL"] = baseURL - if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key - if (model.headers) - options["headers"] = { - ...options["headers"], - ...model.headers, - } + for (const [modelID, model] of Object.entries(provider.models ?? {})) { + const existingModel = parsed.models[model.id ?? modelID] + const name = iife(() => { + if (model.name) return model.name + if (model.id && model.id !== modelID) return modelID + return existingModel?.name ?? modelID + }) + const parsedModel: Model = { + id: ModelID.make(modelID), + api: { + id: model.id ?? existingModel?.api.id ?? modelID, + npm: + model.provider?.npm ?? + provider.npm ?? + existingModel?.api.npm ?? + modelsDev[providerID]?.npm ?? + "@ai-sdk/openai-compatible", + url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api, + }, + status: model.status ?? existingModel?.status ?? "active", + name, + providerID: ProviderID.make(providerID), + capabilities: { + temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false, + reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false, + attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false, + toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true, + input: { + text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true, + audio: + model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? 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, + }, + output: { + text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true, + audio: + model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? 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, + }, + interleaved: model.interleaved ?? false, + }, + cost: { + input: model?.cost?.input ?? existingModel?.cost?.input ?? 0, + output: model?.cost?.output ?? existingModel?.cost?.output ?? 0, + cache: { + read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0, + write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0, + }, + }, + options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}), + limit: { + context: model.limit?.context ?? existingModel?.limit?.context ?? 0, + output: model.limit?.output ?? existingModel?.limit?.output ?? 0, + }, + headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}), + family: model.family ?? existingModel?.family ?? "", + release_date: model.release_date ?? existingModel?.release_date ?? "", + recommendedIndex: model.recommendedIndex ?? existingModel?.recommendedIndex, + prompt: model.prompt ?? existingModel?.prompt, + isFree: model.isFree ?? existingModel?.isFree, + ai_sdk_provider: model.ai_sdk_provider ?? existingModel?.ai_sdk_provider, + variants: {}, + } + const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {}) + parsedModel.variants = mapValues( + pickBy(merged, (v) => !v.disabled), + (v) => omit(v, ["disabled"]), + ) + parsed.models[modelID] = parsedModel + } + database[providerID] = parsed + } - const key = Hash.fast( - JSON.stringify({ - providerID: model.providerID, - npm: model.api.npm, - options, - }), - ) - const existing = s.sdk.get(key) - if (existing) return existing + // load env + const env = Env.all() + for (const [id, provider] of Object.entries(database)) { + const providerID = ProviderID.make(id) + if (disabled.has(providerID)) continue + const apiKey = provider.env.map((item) => env[item]).find(Boolean) + if (!apiKey) continue + mergeProvider(providerID, { + source: "env", + key: provider.env.length === 1 ? apiKey : undefined, + }) + } - const customFetch = options["fetch"] - const chunkTimeout = options["chunkTimeout"] - delete options["chunkTimeout"] + // load apikeys + const auths = yield* auth.all().pipe(Effect.orDie) + for (const [id, provider] of Object.entries(auths)) { + const providerID = ProviderID.make(id) + if (disabled.has(providerID)) continue + if (provider.type === "api") { + mergeProvider(providerID, { + source: "api", + key: provider.key, + }) + } + } - options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { - // Preserve custom fetch if it exists, wrap it with timeout logic - const fetchFn = customFetch ?? fetch - const opts = init ?? {} - const chunkAbortCtl = typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined - const signals: AbortSignal[] = [] + const plugins = yield* Effect.promise(() => Plugin.list()) + for (const plugin of plugins) { + if (!plugin.auth) continue + const providerID = ProviderID.make(plugin.auth.provider) + if (disabled.has(providerID)) continue - // kilocode_change start - apply connection-phase timeout only - // Use an AbortController so we can cancel the timer once headers arrive, - // preventing healthy streaming responses from being aborted mid-stream. - const ms = options["timeout"] ?? REQUEST_TIMEOUT_MS - const controller = ms !== false ? new AbortController() : undefined - if (controller) { - const signals: AbortSignal[] = [controller.signal] - if (opts.signal) signals.push(opts.signal) - opts.signal = signals.length > 1 ? AbortSignal.any(signals) : signals[0] - } - const timer = - controller && typeof ms === "number" - ? setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), ms) - : undefined - // kilocode_change end + const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie) + if (!pluginAuth) continue + if (!plugin.auth.loader) continue - // 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") { - const body = JSON.parse(opts.body as string) - const isAzure = model.providerID.includes("azure") - const keepIds = isAzure && body.store === true - if (!keepIds && Array.isArray(body.input)) { - for (const item of body.input) { - if ("id" in item) { - delete item.id + const options = yield* Effect.promise(() => + plugin.auth!.loader!(() => Auth.get(providerID) as any, database[plugin.auth!.provider]), + ) + const opts = options ?? {} + const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } + mergeProvider(providerID, patch) + } + + for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) { + const providerID = ProviderID.make(id) + if (disabled.has(providerID)) continue + const data = database[providerID] + if (!data) { + log.error("Provider does not exist in model list " + providerID) + continue + } + const result = yield* Effect.promise(() => fn(data)) + if (result && (result.autoload || providers[providerID])) { + if (result.getModel) modelLoaders[providerID] = result.getModel + if (result.vars) varsLoaders[providerID] = result.vars + if (result.discoverModels) discoveryLoaders[providerID] = result.discoverModels + const opts = result.options ?? {} + const patch: Partial = providers[providerID] + ? { options: opts } + : { source: "custom", options: opts } + mergeProvider(providerID, patch) + } + } + + // load config + for (const [id, provider] of configProviders) { + const providerID = ProviderID.make(id) + const partial: Partial = { source: "config" } + if (provider.env) partial.env = provider.env + if (provider.name) partial.name = provider.name + if (provider.options) partial.options = provider.options + mergeProvider(providerID, partial) + } + + for (const [id, provider] of Object.entries(providers)) { + const providerID = ProviderID.make(id) + if (!isProviderAllowed(providerID)) { + delete providers[providerID] + continue + } + + const configProvider = cfg.provider?.[providerID] + + for (const [modelID, model] of Object.entries(provider.models)) { + model.api.id = model.api.id ?? model.id ?? modelID + if ( + modelID === "gpt-5-chat-latest" || + (providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat") + ) + delete provider.models[modelID] + if (model.status === "alpha" && !Flag.KILO_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID] + if (model.status === "deprecated") delete provider.models[modelID] + if ( + (configProvider?.blacklist && configProvider.blacklist.includes(modelID)) || + (configProvider?.whitelist && !configProvider.whitelist.includes(modelID)) + ) + delete provider.models[modelID] + + model.variants = mapValues(ProviderTransform.variants(model), (v) => v) + + const configVariants = configProvider?.models?.[modelID]?.variants + if (configVariants && model.variants) { + const merged = mergeDeep(model.variants, configVariants) + model.variants = mapValues( + pickBy(merged, (v) => !v.disabled), + (v) => omit(v, ["disabled"]), + ) } } - opts.body = JSON.stringify(body) + + if (Object.keys(provider.models).length === 0) { + delete providers[providerID] + continue + } + + log.info("found", { providerID }) + } + + const gitlab = ProviderID.make("gitlab") + if (discoveryLoaders[gitlab] && providers[gitlab]) { + yield* Effect.promise(async () => { + try { + const discovered = await discoveryLoaders[gitlab]() + for (const [modelID, model] of Object.entries(discovered)) { + if (!providers[gitlab].models[modelID]) { + providers[gitlab].models[modelID] = model + } + } + } catch (e) { + log.warn("state discovery error", { id: "gitlab", error: e }) + } + }) + } + + return { + models: languages, + providers, + sdk, + modelLoaders, + varsLoaders, + } + }), + ) + + const list = Effect.fn("Provider.list")(() => InstanceState.use(cache, (s) => s.providers)) + + async function resolveSDK(model: Model, s: State) { + try { + using _ = log.time("getSDK", { + providerID: model.providerID, + }) + const provider = s.providers[model.providerID] + const options = { ...provider.options } + + if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) { + delete options.fetch + } + + if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) { + options["includeUsage"] = true + } + + const baseURL = iife(() => { + let url = + typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url + if (!url) return + + const loader = s.varsLoaders[model.providerID] + if (loader) { + const vars = loader(options) + for (const [key, value] of Object.entries(vars)) { + const field = "${" + key + "}" + url = url.replaceAll(field, value) + } + } + + url = url.replace(/\$\{([^}]+)\}/g, (item, key) => { + const val = Env.get(String(key)) + return val ?? item + }) + return url + }) + + if (baseURL !== undefined) options["baseURL"] = baseURL + if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key + if (model.headers) + options["headers"] = { + ...options["headers"], + ...model.headers, + } + + const key = Hash.fast( + JSON.stringify({ + providerID: model.providerID, + npm: model.api.npm, + options, + }), + ) + const existing = s.sdk.get(key) + if (existing) return existing + + const customFetch = options["fetch"] + const chunkTimeout = options["chunkTimeout"] + delete options["chunkTimeout"] + + options["fetch"] = async (input: any, init?: BunFetchRequestInit) => { + const fetchFn = customFetch ?? fetch + const opts = init ?? {} + const chunkAbortCtl = + typeof chunkTimeout === "number" && chunkTimeout > 0 ? new AbortController() : undefined + // kilocode_change start - apply connection-phase timeout via AbortController so the timer + // can be cancelled once headers arrive, preventing healthy streaming responses from being + // aborted mid-stream. + const ms = options["timeout"] ?? REQUEST_TIMEOUT_MS + const controller = ms !== false ? new AbortController() : undefined + const signals: AbortSignal[] = [] + + if (opts.signal) signals.push(opts.signal) + if (chunkAbortCtl) signals.push(chunkAbortCtl.signal) + if (controller) signals.push(controller.signal) + + const combined = signals.length === 0 ? null : signals.length === 1 ? signals[0] : AbortSignal.any(signals) + if (combined) opts.signal = combined + + const timer = + controller && typeof ms === "number" + ? setTimeout(() => controller.abort(new DOMException("The operation timed out.", "TimeoutError")), ms) + : undefined + // kilocode_change end + + // Strip openai itemId metadata following what codex does + if (model.api.npm === "@ai-sdk/openai" && opts.body && opts.method === "POST") { + const body = JSON.parse(opts.body as string) + const isAzure = model.providerID.includes("azure") + const keepIds = isAzure && body.store === true + if (!keepIds && Array.isArray(body.input)) { + for (const item of body.input) { + if ("id" in item) { + delete item.id + } + } + opts.body = JSON.stringify(body) + } + } + + // kilocode_change start - clear connection-phase timeout once headers arrive + try { + const res = await fetchFn(input, { + ...opts, + // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 + timeout: false, + }) + if (timer !== undefined) clearTimeout(timer) + if (!chunkAbortCtl) return res + return wrapSSE(res, chunkTimeout, chunkAbortCtl) + } catch (err) { + if (timer !== undefined) clearTimeout(timer) + throw err + } + // kilocode_change end + } + + const bundledFn = BUNDLED_PROVIDERS[model.api.npm] + if (bundledFn) { + log.info("using bundled provider", { + providerID: model.providerID, + pkg: model.api.npm, + }) + const loaded = bundledFn({ + name: model.providerID, + ...options, + }) + s.sdk.set(key, loaded) + return loaded as SDK + } + + let installedPath: string + if (!model.api.npm.startsWith("file://")) { + installedPath = await BunProc.install(model.api.npm, "latest") + } else { + log.info("loading local provider", { pkg: model.api.npm }) + installedPath = model.api.npm + } + + const mod = await import(installedPath) + + const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!] + const loaded = fn({ + name: model.providerID, + ...options, + }) + s.sdk.set(key, loaded) + return loaded as SDK + } catch (e) { + throw new InitError({ providerID: model.providerID }, { cause: e }) + } + } + + const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) => + InstanceState.use(cache, (s) => s.providers[providerID]), + ) + + const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) { + const s = yield* InstanceState.get(cache) + const provider = s.providers[providerID] + if (!provider) { + const available = Object.keys(s.providers) + const matches = fuzzysort.go(providerID, available, { limit: 3, threshold: -10000 }) + throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) }) + } + + const info = provider.models[modelID] + if (!info) { + const available = Object.keys(provider.models) + const matches = fuzzysort.go(modelID, available, { limit: 3, threshold: -10000 }) + throw new ModelNotFoundError({ providerID, modelID, suggestions: matches.map((m) => m.target) }) + } + return info + }) + + const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) { + const s = yield* InstanceState.get(cache) + const key = `${model.providerID}/${model.id}` + if (s.models.has(key)) return s.models.get(key)! + + return yield* Effect.promise(async () => { + const provider = s.providers[model.providerID] + const sdk = await resolveSDK(model, s) + + try { + const language = s.modelLoaders[model.providerID] + ? await s.modelLoaders[model.providerID](sdk, model.api.id, { + ...provider.options, + ...model.options, + }) + : sdk.languageModel(model.api.id) + s.models.set(key, language) + return language + } catch (e) { + if (e instanceof NoSuchModelError) + throw new ModelNotFoundError( + { + modelID: model.id, + providerID: model.providerID, + }, + { cause: e }, + ) + throw e + } + }) + }) + + const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) { + const s = yield* InstanceState.get(cache) + const provider = s.providers[providerID] + if (!provider) return undefined + for (const item of query) { + for (const modelID of Object.keys(provider.models)) { + if (modelID.includes(item)) return { providerID, modelID } + } + } + return undefined + }) + + const getSmallModel = Effect.fn("Provider.getSmallModel")(function* (providerID: ProviderID) { + const cfg = yield* config.get() + + if (cfg.small_model) { + const parsed = parseModel(cfg.small_model) + return yield* getModel(parsed.providerID, parsed.modelID) + } + + const s = yield* InstanceState.get(cache) + const provider = s.providers[providerID] + if (!provider) return undefined + + let priority = [ + "claude-haiku-4-5", + "claude-haiku-4.5", + "3-5-haiku", + "3.5-haiku", + "gemini-3-flash", + "gemini-2.5-flash", + "gpt-5-nano", + ] + if (providerID.startsWith("opencode")) { + priority = ["gpt-5-nano"] + } + if (providerID.startsWith("github-copilot")) { + priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority] + } + // kilocode_change start + if (providerID.startsWith("kilo")) { + priority = ["kilo-auto/small"] + } + // kilocode_change end + for (const item of priority) { + if (providerID === ProviderID.amazonBedrock) { + const crossRegionPrefixes = ["global.", "us.", "eu."] + const candidates = Object.keys(provider.models).filter((m) => m.includes(item)) + + const globalMatch = candidates.find((m) => m.startsWith("global.")) + if (globalMatch) return yield* getModel(providerID, ModelID.make(globalMatch)) + + const region = provider.options?.region + if (region) { + const regionPrefix = region.split("-")[0] + if (regionPrefix === "us" || regionPrefix === "eu") { + const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`)) + if (regionalMatch) return yield* getModel(providerID, ModelID.make(regionalMatch)) + } + } + + const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p))) + if (unprefixed) return yield* getModel(providerID, ModelID.make(unprefixed)) + } else { + for (const model of Object.keys(provider.models)) { + if (model.includes(item)) return yield* getModel(providerID, ModelID.make(model)) + } } } - // kilocode_change start - clear timeout once headers arrive - try { - const response = await fetchFn(input, { - ...opts, - // @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682 - timeout: false, - }) - if (timer !== undefined) clearTimeout(timer) - return response - } catch (err) { - if (timer !== undefined) clearTimeout(timer) - throw err + // kilocode_change start - fall back to kilo's auto small model if the requested provider has no match + const kiloProvider = s.providers[ProviderID.kilo] + if (kiloProvider && kiloProvider.models["kilo-auto/small"]) { + return yield* getModel(ProviderID.make("kilo"), ModelID.make("kilo-auto/small")) } // kilocode_change end - } - const bundledFn = BUNDLED_PROVIDERS[model.api.npm] - if (bundledFn) { - log.info("using bundled provider", { - providerID: model.providerID, - pkg: model.api.npm, - }) - const loaded = bundledFn({ - name: model.providerID, - ...options, - }) - s.sdk.set(key, loaded) - return loaded as SDK - } - - let installedPath: string - if (!model.api.npm.startsWith("file://")) { - installedPath = await BunProc.install(model.api.npm, "latest") - } else { - log.info("loading local provider", { pkg: model.api.npm }) - installedPath = model.api.npm - } - - const mod = await import(installedPath) - - const fn = mod[Object.keys(mod).find((key) => key.startsWith("create"))!] - const loaded = fn({ - name: model.providerID, - ...options, + return undefined }) - s.sdk.set(key, loaded) - return loaded as SDK - } catch (e) { - throw new InitError({ providerID: model.providerID }, { cause: e }) - } + + 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 state().then((s) => s.providers[providerID]) + return runPromise((svc) => svc.getProvider(providerID)) } export async function getModel(providerID: ProviderID, modelID: ModelID) { - const s = await state() - const provider = s.providers[providerID] - if (!provider) { - const availableProviders = Object.keys(s.providers) - const matches = fuzzysort.go(providerID, availableProviders, { - limit: 3, - threshold: -10000, - }) - const suggestions = matches.map((m) => m.target) - throw new ModelNotFoundError({ providerID, modelID, suggestions }) - } - - const info = provider.models[modelID] - if (!info) { - const availableModels = Object.keys(provider.models) - const matches = fuzzysort.go(modelID, availableModels, { - limit: 3, - threshold: -10000, - }) - const suggestions = matches.map((m) => m.target) - throw new ModelNotFoundError({ providerID, modelID, suggestions }) - } - return info + return runPromise((svc) => svc.getModel(providerID, modelID)) } - export async function getLanguage(model: Model): Promise { - const s = await state() - const key = `${model.providerID}/${model.id}` - if (s.models.has(key)) return s.models.get(key)! - - const provider = s.providers[model.providerID] - const sdk = await getSDK(model) - - try { - const language = s.modelLoaders[model.providerID] - ? await s.modelLoaders[model.providerID](sdk, model.api.id, { - ...provider.options, - ...model.options, - }) - : sdk.languageModel(model.api.id) - s.models.set(key, language) - return language - } catch (e) { - if (e instanceof NoSuchModelError) - throw new ModelNotFoundError( - { - modelID: model.id, - providerID: model.providerID, - }, - { cause: e }, - ) - throw e - } + export async function getLanguage(model: Model) { + return runPromise((svc) => svc.getLanguage(model)) } export async function closest(providerID: ProviderID, query: string[]) { - const s = await state() - const provider = s.providers[providerID] - if (!provider) return undefined - for (const item of query) { - for (const modelID of Object.keys(provider.models)) { - if (modelID.includes(item)) - return { - providerID, - modelID, - } - } - } + return runPromise((svc) => svc.closest(providerID, query)) } export async function getSmallModel(providerID: ProviderID) { - const cfg = await Config.get() + return runPromise((svc) => svc.getSmallModel(providerID)) + } - if (cfg.small_model) { - const parsed = parseModel(cfg.small_model) - return getModel(parsed.providerID, parsed.modelID) - } - - const provider = await state().then((state) => state.providers[providerID]) - if (provider) { - let priority = [ - "claude-haiku-4-5", - "claude-haiku-4.5", - "3-5-haiku", - "3.5-haiku", - "gemini-3-flash", - "gemini-2.5-flash", - "gpt-5-nano", - ] - // kilocode_change start - if (providerID.startsWith("kilo")) { - priority = ["kilo-auto/small"] - } - // kilocode_change end - if (providerID.startsWith("github-copilot")) { - // prioritize free models for github copilot - priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority] - } - for (const item of priority) { - if (providerID === ProviderID.amazonBedrock) { - const crossRegionPrefixes = ["global.", "us.", "eu."] - 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.")) - if (globalMatch) return getModel(providerID, ModelID.make(globalMatch)) - - const region = provider.options?.region - if (region) { - const regionPrefix = region.split("-")[0] - if (regionPrefix === "us" || regionPrefix === "eu") { - const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`)) - if (regionalMatch) return getModel(providerID, ModelID.make(regionalMatch)) - } - } - - const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p))) - if (unprefixed) return getModel(providerID, ModelID.make(unprefixed)) - } else { - for (const model of Object.keys(provider.models)) { - if (model.includes(item)) return getModel(providerID, ModelID.make(model)) - } - } - } - } - - // kilocode_change start - // Check if kilo provider is available before using it - const kiloProvider = await state().then((state) => state.providers[ProviderID.kilo]) - if (kiloProvider && kiloProvider.models["kilo-auto/small"]) { - return getModel(ProviderID.make("kilo"), ModelID.make("kilo-auto/small")) - } - // kilocode_change end - - return undefined + export async function defaultModel() { + return runPromise((svc) => svc.defaultModel()) } const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"] @@ -1565,33 +1660,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) { const [providerID, ...rest] = model.split("/") return { diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts index 38513e0c1d..c2113ff86e 100644 --- a/packages/opencode/src/session/instruction.ts +++ b/packages/opencode/src/session/instruction.ts @@ -34,7 +34,9 @@ async function resolveRelative(instruction: string): Promise { return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => []) } if (!Flag.KILO_CONFIG_DIR) { - log.warn(`Skipping relative instruction "${instruction}" - no KILO_CONFIG_DIR set while project config is disabled`) + log.warn( + `Skipping relative instruction "${instruction}" - no KILO_CONFIG_DIR set while project config is disabled`, + ) return [] } return Filesystem.globUp(instruction, Flag.KILO_CONFIG_DIR, Flag.KILO_CONFIG_DIR).catch(() => []) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e5d4244a5b..47bdbf0240 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -404,7 +404,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the Effect.runPromise( Effect.gen(function* () { 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({ ...match, state: { diff --git a/packages/opencode/src/storage/storage.ts b/packages/opencode/src/storage/storage.ts index a78607cdfd..268b18f687 100644 --- a/packages/opencode/src/storage/storage.ts +++ b/packages/opencode/src/storage/storage.ts @@ -1,19 +1,17 @@ import { Log } from "../util/log" import path from "path" -import fs from "fs/promises" 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 z from "zod" -import { Glob } from "../util/glob" 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 { const log = Log.create({ service: "storage" }) - type Migration = (dir: string) => Promise + type Migration = (dir: string, fs: AppFileSystem.Interface) => Effect.Effect export const NotFoundError = NamedError.create( "NotFoundError", @@ -22,36 +20,101 @@ export namespace Storage { }), ) + export type Error = AppFileSystem.Error | InstanceType + + 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 + readonly read: (key: string[]) => Effect.Effect + readonly update: (key: string[], fn: (draft: T) => void) => Effect.Effect + readonly write: (key: string[], content: T) => Effect.Effect + readonly list: (prefix: string[]) => Effect.Effect + } + + export class Service extends ServiceMap.Service()("@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[] = [ - async (dir) => { + Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface) { const project = path.resolve(dir, "../project") - if (!(await Filesystem.isDir(project))) return - const projectDirs = await Glob.scan("*", { + if (!(yield* fs.isDir(project))) return + const projectDirs = yield* fs.glob("*", { cwd: project, include: "all", }) for (const projectDir of projectDirs) { - const fullPath = path.join(project, projectDir) - if (!(await Filesystem.isDir(fullPath))) continue + const full = path.join(project, projectDir) + if (!(yield* fs.isDir(full))) continue log.info(`migrating project ${projectDir}`) let projectID = projectDir - const fullProjectDir = path.join(project, projectDir) let worktree = "/" if (projectID !== "global") { - for (const msgFile of await Glob.scan("storage/session/message/*/*.json", { - cwd: path.join(project, projectDir), + for (const msgFile of yield* fs.glob("storage/session/message/*/*.json", { + cwd: full, absolute: true, })) { - const json = await Filesystem.readJson(msgFile) - worktree = json.path?.root - if (worktree) break + const json = decodeRoot(yield* fs.readJson(msgFile), { onExcessProperty: "preserve" }) + const root = Option.isSome(json) ? json.value.path?.root : undefined + if (!root) continue + worktree = root + break } if (!worktree) continue - if (!(await Filesystem.isDir(worktree))) continue - const result = await git(["rev-list", "--max-parents=0", "--all"], { - cwd: worktree, - }) + if (!(yield* fs.isDir(worktree))) continue + const result = yield* Effect.promise(() => + git(["rev-list", "--max-parents=0", "--all"], { + cwd: worktree, + }), + ) const [id] = result .text() .split("\n") @@ -61,157 +124,230 @@ export namespace Storage { if (!id) continue projectID = id - await Filesystem.writeJson(path.join(dir, "project", projectID + ".json"), { - id, - vcs: "git", - worktree, - time: { - created: Date.now(), - initialized: Date.now(), - }, - }) + yield* fs.writeWithDirs( + path.join(dir, "project", projectID + ".json"), + JSON.stringify( + { + id, + vcs: "git", + worktree, + time: { + created: Date.now(), + initialized: Date.now(), + }, + }, + null, + 2, + ), + ) log.info(`migrating sessions for project ${projectID}`) - for (const sessionFile of await Glob.scan("storage/session/info/*.json", { - cwd: fullProjectDir, + for (const sessionFile of yield* fs.glob("storage/session/info/*.json", { + cwd: full, absolute: true, })) { const dest = path.join(dir, "session", projectID, path.basename(sessionFile)) - log.info("copying", { - sessionFile, - dest, - }) - const session = await Filesystem.readJson(sessionFile) - await Filesystem.writeJson(dest, session) - log.info(`migrating messages for session ${session.id}`) - for (const msgFile of await Glob.scan(`storage/session/message/${session.id}/*.json`, { - cwd: fullProjectDir, + log.info("copying", { sessionFile, dest }) + const session = yield* fs.readJson(sessionFile) + const info = decodeSession(session, { onExcessProperty: "preserve" }) + yield* fs.writeWithDirs(dest, JSON.stringify(session, null, 2)) + if (Option.isNone(info)) continue + log.info(`migrating messages for session ${info.value.id}`) + for (const msgFile of yield* fs.glob(`storage/session/message/${info.value.id}/*.json`, { + cwd: full, 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", { msgFile, - dest, + dest: next, }) - const message = await Filesystem.readJson(msgFile) - await Filesystem.writeJson(dest, message) + const message = yield* fs.readJson(msgFile) + 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}`) - for (const partFile of await Glob.scan(`storage/session/part/${session.id}/${message.id}/*.json`, { - cwd: fullProjectDir, + log.info(`migrating parts for message ${item.value.id}`) + for (const partFile of yield* fs.glob(`storage/session/part/${info.value.id}/${item.value.id}/*.json`, { + cwd: full, absolute: true, })) { - const dest = path.join(dir, "part", message.id, path.basename(partFile)) - const part = await Filesystem.readJson(partFile) + const out = path.join(dir, "part", item.value.id, path.basename(partFile)) + const part = yield* fs.readJson(partFile) log.info("copying", { partFile, - dest, + dest: out, }) - await Filesystem.writeJson(dest, part) + yield* fs.writeWithDirs(out, JSON.stringify(part, null, 2)) } } } } } - }, - async (dir) => { - for (const item of await Glob.scan("session/*/*.json", { + }), + Effect.fn("Storage.migration.2")(function* (dir: string, fs: AppFileSystem.Interface) { + for (const item of yield* fs.glob("session/*/*.json", { cwd: dir, absolute: true, })) { - const session = await Filesystem.readJson(item) - if (!session.projectID) continue - if (!session.summary?.diffs) continue - const { diffs } = session.summary - await Filesystem.write(path.join(dir, "session_diff", session.id + ".json"), JSON.stringify(diffs)) - await Filesystem.writeJson(path.join(dir, "session", session.projectID, session.id + ".json"), { - ...session, - summary: { - additions: diffs.reduce((sum: any, x: any) => sum + x.additions, 0), - deletions: diffs.reduce((sum: any, x: any) => sum + x.deletions, 0), - }, - }) + const raw = yield* fs.readJson(item) + const session = decodeSummary(raw, { onExcessProperty: "preserve" }) + if (Option.isNone(session)) continue + const diffs = session.value.summary.diffs + yield* fs.writeWithDirs( + path.join(dir, "session_diff", session.value.id + ".json"), + JSON.stringify(diffs, null, 2), + ) + yield* fs.writeWithDirs( + path.join(dir, "session", session.value.projectID, session.value.id + ".json"), + JSON.stringify( + { + ...(raw as Record), + summary: { + additions: diffs.reduce((sum, x) => sum + x.additions, 0), + deletions: diffs.reduce((sum, x) => sum + x.deletions, 0), + }, + }, + null, + 2, + ), + ) } - }, + }), ] - const state = lazy(async () => { - const dir = path.join(Global.Path.data, "storage") - const migration = await Filesystem.readJson(path.join(dir, "migration")) - .then((x) => parseInt(x)) - .catch(() => 0) - for (let index = migration; index < MIGRATIONS.length; index++) { - log.info("running migration", { index }) - const migration = MIGRATIONS[index] - await migration(dir).catch(() => log.error("failed to run migration", { index })) - await Filesystem.write(path.join(dir, "migration"), (index + 1).toString()) - } - return { - dir, - } - }) + 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 marker = path.join(dir, "migration") + const migration = yield* fs.readFileString(marker).pipe( + Effect.map(parseMigration), + Effect.catchIf(missing, () => Effect.succeed(0)), + Effect.orElseSucceed(() => 0), + ) + for (let i = migration; i < MIGRATIONS.length; i++) { + 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 + } + yield* fs.writeWithDirs(marker, String(i + 1)) + } + return { dir } + }), + ) + + const fail = (target: string): Effect.Effect> => + Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` })) + + const wrap = (target: string, body: Effect.Effect) => + 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)) + }) + + const withResolved = ( + key: string[], + fn: (target: string, rw: TxReentrantLock.TxReentrantLock) => Effect.Effect, + ): Effect.Effect => + 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"] = (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"] = (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([]))) + 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[]) { - const dir = await state().then((x) => x.dir) - const target = path.join(dir, ...key) + ".json" - return withErrorHandling(async () => { - await fs.unlink(target).catch(() => {}) - }) + return runPromise((svc) => svc.remove(key)) } export async function read(key: string[]) { - const dir = await state().then((x) => x.dir) - const target = path.join(dir, ...key) + ".json" - return withErrorHandling(async () => { - using _ = await Lock.read(target) - const result = await Filesystem.readJson(target) - return result as T - }) + return runPromise((svc) => svc.read(key)) } export async function update(key: string[], fn: (draft: T) => void) { - const dir = await state().then((x) => x.dir) - const target = path.join(dir, ...key) + ".json" - return withErrorHandling(async () => { - using _ = await Lock.write(target) - const content = await Filesystem.readJson(target) - fn(content as T) - await Filesystem.writeJson(target, content) - return content - }) + return runPromise((svc) => svc.update(key, fn)) } export async function write(key: string[], content: T) { - const dir = await state().then((x) => x.dir) - const target = path.join(dir, ...key) + ".json" - return withErrorHandling(async () => { - using _ = await Lock.write(target) - await Filesystem.writeJson(target, content) - }) - } - - async function withErrorHandling(body: () => Promise) { - 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 - }) + return runPromise((svc) => svc.write(key, content)) } export async function list(prefix: string[]) { - const dir = await state().then((x) => x.dir) - 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 [] - } + return runPromise((svc) => svc.list(prefix)) } } diff --git a/packages/opencode/test/server/session-messages.test.ts b/packages/opencode/test/server/session-messages.test.ts index d7e44cbecc..ab5f99c89d 100644 --- a/packages/opencode/test/server/session-messages.test.ts +++ b/packages/opencode/test/server/session-messages.test.ts @@ -13,6 +13,18 @@ afterEach(async () => { await Instance.disposeAll() }) +async function withoutWatcher(fn: () => Promise) { + if (process.platform !== "win32") return fn() + const prev = process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER + process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER = "true" + try { + return await fn() + } finally { + if (prev === undefined) delete process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER + else process.env.KILO_EXPERIMENTAL_DISABLE_FILEWATCHER = prev + } +} + async function fill(sessionID: SessionID, count: number, time = (i: number) => Date.now() + i) { const ids = [] as MessageID[] for (let i = 0; i < count; i++) { @@ -42,86 +54,94 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D describe("session messages endpoint", () => { test("returns cursor headers for older pages", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const session = await Session.create({}) - const ids = await fill(session.id, 5) - const app = Server.Default() + await withoutWatcher(() => + Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const ids = await fill(session.id, 5) + const app = Server.Default() - const a = await app.request(`/session/${session.id}/message?limit=2`) - expect(a.status).toBe(200) - const aBody = (await a.json()) as MessageV2.WithParts[] - expect(aBody.map((item) => item.info.id)).toEqual(ids.slice(-2)) - const cursor = a.headers.get("x-next-cursor") - expect(cursor).toBeTruthy() - expect(a.headers.get("link")).toContain('rel="next"') + const a = await app.request(`/session/${session.id}/message?limit=2`) + expect(a.status).toBe(200) + const aBody = (await a.json()) as MessageV2.WithParts[] + expect(aBody.map((item) => item.info.id)).toEqual(ids.slice(-2)) + const cursor = a.headers.get("x-next-cursor") + expect(cursor).toBeTruthy() + expect(a.headers.get("link")).toContain('rel="next"') - const b = await app.request(`/session/${session.id}/message?limit=2&before=${encodeURIComponent(cursor!)}`) - expect(b.status).toBe(200) - const bBody = (await b.json()) as MessageV2.WithParts[] - expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) + const b = await app.request(`/session/${session.id}/message?limit=2&before=${encodeURIComponent(cursor!)}`) + expect(b.status).toBe(200) + const bBody = (await b.json()) as MessageV2.WithParts[] + expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2)) - await Session.remove(session.id) - }, - }) + await Session.remove(session.id) + }, + }), + ) }) test("keeps full-history responses when limit is omitted", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const session = await Session.create({}) - const ids = await fill(session.id, 3) - const app = Server.Default() + await withoutWatcher(() => + Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const ids = await fill(session.id, 3) + const app = Server.Default() - const res = await app.request(`/session/${session.id}/message`) - expect(res.status).toBe(200) - const body = (await res.json()) as MessageV2.WithParts[] - expect(body.map((item) => item.info.id)).toEqual(ids) + const res = await app.request(`/session/${session.id}/message`) + expect(res.status).toBe(200) + const body = (await res.json()) as MessageV2.WithParts[] + expect(body.map((item) => item.info.id)).toEqual(ids) - await Session.remove(session.id) - }, - }) + await Session.remove(session.id) + }, + }), + ) }) test("rejects invalid cursors and missing sessions", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const session = await Session.create({}) - const app = Server.Default() + await withoutWatcher(() => + Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const app = Server.Default() - const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`) - expect(bad.status).toBe(400) + const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`) + expect(bad.status).toBe(400) - const miss = await app.request(`/session/ses_missing/message?limit=2`) - expect(miss.status).toBe(404) + const miss = await app.request(`/session/ses_missing/message?limit=2`) + expect(miss.status).toBe(404) - await Session.remove(session.id) - }, - }) + await Session.remove(session.id) + }, + }), + ) }) test("does not truncate large legacy limit requests", async () => { await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const session = await Session.create({}) - await fill(session.id, 520) - const app = Server.Default() + await withoutWatcher(() => + Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + await fill(session.id, 520) + const app = Server.Default() - const res = await app.request(`/session/${session.id}/message?limit=510`) - expect(res.status).toBe(200) - const body = (await res.json()) as MessageV2.WithParts[] - expect(body).toHaveLength(510) + const res = await app.request(`/session/${session.id}/message?limit=510`) + expect(res.status).toBe(200) + const body = (await res.json()) as MessageV2.WithParts[] + expect(body).toHaveLength(510) - await Session.remove(session.id) - }, - }) + await Session.remove(session.id) + }, + }), + ) }) }) diff --git a/packages/opencode/test/storage/storage.test.ts b/packages/opencode/test/storage/storage.test.ts new file mode 100644 index 0000000000..e5a04c082d --- /dev/null +++ b/packages/opencode/test/storage/storage.test.ts @@ -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(fn: (root: string[]) => Promise) { + 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( + root: string, + fn: (run: (body: Effect.Effect) => Promise) => Promise, +) { + 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(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(["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) + }) +}) diff --git a/script/changelog.ts b/script/changelog.ts old mode 100755 new mode 100644 index 822391206a..61549ee6cd --- a/script/changelog.ts +++ b/script/changelog.ts @@ -1,408 +1,40 @@ #!/usr/bin/env bun -import { $ } from "bun" -import { createKilo } from "@kilocode/sdk/v2" +import { rm } from "fs/promises" +import path from "path" import { parseArgs } from "util" -type Release = { - tag_name: string - draft: boolean - prerelease: boolean -} +const root = path.resolve(import.meta.dir, "..") +const file = path.join(root, "UPCOMING_CHANGELOG.md") +const { values, positionals } = parseArgs({ + args: Bun.argv.slice(2), + options: { + from: { type: "string", short: "f" }, + 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 }, + }, + allowPositionals: true, +}) +const args = [...positionals] -export async function getLatestRelease(skip?: string) { - const data = await fetch("https://api.github.com/repos/Kilo-Org/kilocode/releases?per_page=100").then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) +if (values.from) args.push("--from", values.from) +if (values.to) args.push("--to", values.to) - const releases = data as Release[] - const target = skip?.replace(/^v/, "") - - for (const release of releases) { - if (release.draft) continue - const tag = release.tag_name.replace(/^v/, "") - if (target && tag === target) continue - return tag - } - - throw new Error("No releases found") -} - -type Commit = { - hash: string - author: string | null - message: string - areas: Set -} - -export async function getCommits(from: string, to: string): Promise { - const fromRef = from.startsWith("v") ? from : `v${from}` - const toRef = to === "HEAD" ? to : to.startsWith("v") ? to : `v${to}` - - // Get commit data with GitHub usernames from the API - const compare = - await $`gh api "/repos/Kilo-Org/kilocode/compare/${fromRef}...${toRef}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text() - - const commitData = new Map() - for (const line of compare.split("\n").filter(Boolean)) { - const data = JSON.parse(line) as { sha: string; login: string | null; message: string } - commitData.set(data.sha, { login: data.login, message: data.message.split("\n")[0] ?? "" }) - } - - // Get commits that touch the relevant packages - const log = - await $`git log ${fromRef}..${toRef} --oneline --format="%H" -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text() - const hashes = log.split("\n").filter(Boolean) - - const commits: Commit[] = [] - for (const hash of hashes) { - const data = commitData.get(hash) - if (!data) continue - - const message = data.message - if (message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue - - const files = await $`git diff-tree --no-commit-id --name-only -r ${hash}`.text() - const areas = new Set() - - for (const file of files.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/")) areas.add("app") - else if (file.startsWith("packages/app/")) areas.add("app") - else if (file.startsWith("packages/sdk/")) areas.add("sdk") - else if (file.startsWith("packages/plugin/")) areas.add("plugin") - else if (file.startsWith("packages/extensions/")) areas.add("extensions/zed") - else if (file.startsWith("sdks/vscode/")) areas.add("extensions/vscode") - else if (file.startsWith("github/")) areas.add("github") - } - - if (areas.size === 0) continue - - commits.push({ - hash: hash.slice(0, 7), - author: data.login, - message, - areas, - }) - } - - return filterRevertedCommits(commits) -} - -function filterRevertedCommits(commits: Commit[]): Commit[] { - const revertPattern = /^Revert "(.+)"$/ - const seen = new Map() - - for (const commit of commits) { - const match = commit.message.match(revertPattern) - if (match) { - // It's a revert - remove the original if we've seen it - const original = match[1]! - if (seen.has(original)) seen.delete(original) - else seen.set(commit.message, commit) // Keep revert if original not in range - } else { - // Regular commit - remove if its revert exists, otherwise add - const revertMsg = `Revert "${commit.message}"` - if (seen.has(revertMsg)) seen.delete(revertMsg) - else seen.set(commit.message, commit) - } - } - - return [...seen.values()] -} - -const repo = process.env.GH_REPO ?? "Kilo-Org/kilocode" -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) { - 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" -} - -async function summarizeCommit(opencode: Awaited>, message: string): Promise { - console.log("summarizing commit:", message) - const session = await opencode.client.session.create() - const result = await opencode.client.session - .prompt( - { - sessionID: session.data!.id, - model: { providerID: "kilo", modelID: "anthropic/claude-sonnet-4.5" }, // kilocode_change - tools: { - "*": false, - }, - parts: [ - { - type: "text", - text: `Summarize this commit message for a changelog entry. Return ONLY a single line summary starting with a capital letter. Be concise but specific. If the commit message is already well-written, just clean it up (capitalize, fix typos, proper grammar). Do not include any prefixes like "fix:" or "feat:". - - 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()] -} - -export async function generateChangelog(commits: Commit[], opencode: Awaited>) { - // Summarize commits in parallel with max 10 concurrent requests - const BATCH_SIZE = 10 - const summaries: string[] = [] - for (let i = 0; i < commits.length; i += BATCH_SIZE) { - const batch = commits.slice(i, i + BATCH_SIZE) - const results = await Promise.all(batch.map((c) => summarizeCommit(opencode, c.message))) - summaries.push(...results) - } - - 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() - - 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 -} - -export async function getContributors(from: string, to: string) { - const fromRef = from.startsWith("v") ? from : `v${from}` - const toRef = to === "HEAD" ? to : to.startsWith("v") ? to : `v${to}` - const compare = - await $`gh api "/repos/Kilo-Org/kilocode/compare/${fromRef}...${toRef}" --jq '.commits[] | {login: .author.login, message: .commit.message}'`.text() - const contributors = new Map>() - - 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") -} - -export async function buildNotes(from: string, to: string) { - const commits = await getCommits(from, to) - - if (commits.length === 0) { - return [] - } - - console.log("generating changelog since " + from) - - const opencode = await createKilo({ port: 0 }) - const notes: string[] = [] - - try { - const lines = await generateChangelog(commits, opencode) - notes.push(...lines) - console.log("---- Generated Changelog ----") - console.log(notes.join("\n")) - console.log("-----------------------------") - } catch (error) { - if (error instanceof Error && error.name === "TimeoutError") { - console.log("Changelog generation timed out, using raw commits") - for (const commit of commits) { - const attribution = commit.author && !Script.team.includes(commit.author) ? ` (@${commit.author})` : "" - notes.push(`- ${commit.message}${attribution}`) - } - } else { - throw error - } - } finally { - await opencode.server.close() - } - console.log("changelog generation complete") - - const contributors = await getContributors(from, to) - - if (contributors.size > 0) { - notes.push("") - notes.push(`**Thank you to ${contributors.size} community contributor${contributors.size > 1 ? "s" : ""}:**`) - for (const [username, userCommits] of contributors) { - notes.push(`- @${username}:`) - for (const c of userCommits) { - notes.push(` - ${c}`) - } - } - } - - return notes -} - -// CLI entrypoint -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(` +if (values.help) { + console.log(` Usage: bun script/changelog.ts [options] +Generates UPCOMING_CHANGELOG.md by running the kilo changelog command. + Options: -f, --from Starting version (default: latest non-draft GitHub release) -t, --to Ending ref (default: HEAD) + --variant Thinking variant for kilo run (default: low) + --quiet Suppress kilo command output unless it fails + --print Print the generated UPCOMING_CHANGELOG.md after success -h, --help Show this help message Examples: @@ -410,11 +42,35 @@ Examples: bun script/changelog.ts --from 1.0.200 bun script/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))) + process.exit(0) } + +await rm(file, { force: true }) + +const quiet = values.quiet +const cmd = ["kilo", "run"] // kilocode_change - opencode -> kilo +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) diff --git a/script/raw-changelog.ts b/script/raw-changelog.ts new file mode 100644 index 0000000000..ce781fdcfc --- /dev/null +++ b/script/raw-changelog.ts @@ -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 +} + +type User = Map> +type Diff = { + sha: string + login: string | null + message: string +} + +const repo = process.env.GH_REPO ?? "Kilo-Org/kilocode" +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) { + 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() + + 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() + 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() + + 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() + 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 Starting version (default: latest non-draft GitHub release) + -t, --to 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))) +} diff --git a/script/version.ts b/script/version.ts index 9675c5d4b3..2e81a44e72 100755 --- a/script/version.ts +++ b/script/version.ts @@ -7,7 +7,7 @@ const output = [`version=${Script.version}`] if (!Script.preview) { 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 body = await Bun.file(file) .text()