Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a7f03a384 | ||
|
|
9215af4465 | ||
|
|
cb411248bf | ||
|
|
46d7d2fdc0 | ||
|
|
d68afcaa55 | ||
|
|
bf35a865ba | ||
|
|
6733a5a822 | ||
|
|
7e28098365 | ||
|
|
ae5c9ed3dd | ||
|
|
a9bf1c0505 | ||
|
|
dad248832d | ||
|
|
6e89d3e597 | ||
|
|
3ebba02d04 | ||
|
|
cf425d114e | ||
|
|
39691e5174 | ||
|
|
adaee66364 | ||
|
|
a6978167ae | ||
|
|
80c36c788c | ||
|
|
76cdc668e8 | ||
|
|
2ba1ecabc9 | ||
|
|
e3b6d84b57 | ||
|
|
0638e49b02 | ||
|
|
ab44597018 | ||
|
|
aec95c4d10 | ||
|
|
b2c82cb897 | ||
|
|
52b42258fa | ||
|
|
3026a005b6 | ||
|
|
a6f802d7fe | ||
|
|
9ef803be82 | ||
|
|
ce5c827a6e | ||
|
|
56decd79db |
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -76,7 +76,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -110,7 +110,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -137,7 +137,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -161,7 +161,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -185,7 +185,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -218,7 +218,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -248,7 +248,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -277,7 +277,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -293,7 +293,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -407,7 +407,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -427,7 +427,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -438,7 +438,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -473,7 +473,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -519,7 +519,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -530,7 +530,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
|
||||
+2
-1
@@ -8,6 +8,7 @@ import type { Context as GitHubContext } from "@actions/github/lib/context"
|
||||
import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { spawn } from "node:child_process"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
type GitHubAuthor = {
|
||||
login: string
|
||||
@@ -281,7 +282,7 @@ async function assertOpencodeConnected() {
|
||||
connected = true
|
||||
break
|
||||
} catch (e) {}
|
||||
await Bun.sleep(300)
|
||||
await sleep(300)
|
||||
} while (retry++ < 30)
|
||||
|
||||
if (!connected) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -22,7 +22,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { getFilename } from "@opencode-ai/util/path"
|
||||
@@ -1937,20 +1937,14 @@ export default function Layout(props: ParentProps) {
|
||||
fallback={
|
||||
<>
|
||||
<div class="shrink-0 py-4 px-3">
|
||||
<TooltipKeybind
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
placement="top"
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
|
||||
>
|
||||
<Button
|
||||
size="large"
|
||||
icon="plus-small"
|
||||
class="w-full"
|
||||
onClick={() => navigateWithSidebarReset(`/${base64Encode(p.worktree)}/session`)}
|
||||
>
|
||||
{language.t("command.session.new")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
{language.t("command.session.new")}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<LocalWorkspace
|
||||
@@ -1965,15 +1959,9 @@ export default function Layout(props: ParentProps) {
|
||||
>
|
||||
<>
|
||||
<div class="shrink-0 py-4 px-3">
|
||||
<TooltipKeybind
|
||||
title={language.t("workspace.new")}
|
||||
keybind={command.keybind("workspace.new")}
|
||||
placement="top"
|
||||
>
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
|
||||
{language.t("workspace.new")}
|
||||
</Button>
|
||||
</TooltipKeybind>
|
||||
<Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
|
||||
{language.t("workspace.new")}
|
||||
</Button>
|
||||
</div>
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<DragDropProvider
|
||||
|
||||
@@ -720,6 +720,7 @@ export default function Page() {
|
||||
showAllFiles,
|
||||
tabForPath: file.tab,
|
||||
openTab: tabs().open,
|
||||
setActive: tabs().setActive,
|
||||
loadFile: file.load,
|
||||
})
|
||||
|
||||
|
||||
@@ -11,12 +11,13 @@ describe("createOpenReviewFile", () => {
|
||||
return `file://${path}`
|
||||
},
|
||||
openTab: (tab) => calls.push(`open:${tab}`),
|
||||
setActive: (tab) => calls.push(`active:${tab}`),
|
||||
loadFile: (path) => calls.push(`load:${path}`),
|
||||
})
|
||||
|
||||
openReviewFile("src/a.ts")
|
||||
|
||||
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts"])
|
||||
expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts"])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -24,15 +24,20 @@ export const createOpenReviewFile = (input: {
|
||||
showAllFiles: () => void
|
||||
tabForPath: (path: string) => string
|
||||
openTab: (tab: string) => void
|
||||
setActive: (tab: string) => void
|
||||
loadFile: (path: string) => any | Promise<void>
|
||||
}) => {
|
||||
return (path: string) => {
|
||||
batch(() => {
|
||||
input.showAllFiles()
|
||||
const maybePromise = input.loadFile(path)
|
||||
const openTab = () => input.openTab(input.tabForPath(path))
|
||||
if (maybePromise instanceof Promise) maybePromise.then(openTab)
|
||||
else openTab()
|
||||
const open = () => {
|
||||
const tab = input.tabForPath(path)
|
||||
input.openTab(tab)
|
||||
input.setActive(tab)
|
||||
}
|
||||
if (maybePromise instanceof Promise) maybePromise.then(open)
|
||||
else open()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, createEffect, createMemo, on, onCleanup, Show, startTransition, Index, type JSX } from "solid-js"
|
||||
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -160,7 +160,7 @@ function createTimelineStaging(input: TimelineStageInput) {
|
||||
}
|
||||
const currentTotal = input.messages().length
|
||||
count = Math.min(currentTotal, count + input.config.batch)
|
||||
startTransition(() => setState("count", count))
|
||||
setState("count", count)
|
||||
if (count >= currentTotal) {
|
||||
setState({ completedSession: sessionKey, activeSession: "" })
|
||||
frame = undefined
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -108,6 +108,26 @@ const DOCS_SEGMENT = new Set([
|
||||
"zh-tw",
|
||||
])
|
||||
|
||||
const DOCS_LOCALE = {
|
||||
ar: "ar",
|
||||
da: "da",
|
||||
de: "de",
|
||||
en: "en",
|
||||
es: "es",
|
||||
fr: "fr",
|
||||
it: "it",
|
||||
ja: "ja",
|
||||
ko: "ko",
|
||||
nb: "no",
|
||||
"pt-br": "br",
|
||||
root: "en",
|
||||
ru: "ru",
|
||||
th: "th",
|
||||
tr: "tr",
|
||||
"zh-cn": "zh",
|
||||
"zh-tw": "zht",
|
||||
} as const satisfies Record<string, Locale>
|
||||
|
||||
function suffix(pathname: string) {
|
||||
const index = pathname.search(/[?#]/)
|
||||
if (index === -1) {
|
||||
@@ -130,7 +150,12 @@ export function docs(locale: Locale, pathname: string) {
|
||||
return `${next.path}${next.suffix}`
|
||||
}
|
||||
|
||||
if (value === "root") return `${next.path}${next.suffix}`
|
||||
if (value === "root") {
|
||||
if (next.path === "/docs/en") return `/docs${next.suffix}`
|
||||
if (next.path === "/docs/en/") return `/docs/${next.suffix}`
|
||||
if (next.path.startsWith("/docs/en/")) return `/docs/${next.path.slice("/docs/en/".length)}${next.suffix}`
|
||||
return `${next.path}${next.suffix}`
|
||||
}
|
||||
|
||||
if (next.path === "/docs") return `/docs/${value}${next.suffix}`
|
||||
if (next.path === "/docs/") return `/docs/${value}/${next.suffix}`
|
||||
@@ -154,6 +179,15 @@ export function fromPathname(pathname: string) {
|
||||
return parseLocale(fix(pathname).split("/")[1])
|
||||
}
|
||||
|
||||
export function fromDocsPathname(pathname: string) {
|
||||
const next = fix(pathname)
|
||||
const value = next.split("/")[2]?.toLowerCase()
|
||||
if (!value) return null
|
||||
if (!next.startsWith("/docs/")) return null
|
||||
if (!(value in DOCS_LOCALE)) return null
|
||||
return DOCS_LOCALE[value as keyof typeof DOCS_LOCALE]
|
||||
}
|
||||
|
||||
export function strip(pathname: string) {
|
||||
const locale = fromPathname(pathname)
|
||||
if (!locale) return fix(pathname)
|
||||
@@ -272,6 +306,9 @@ export function localeFromRequest(request: Request) {
|
||||
const fromPath = fromPathname(new URL(request.url).pathname)
|
||||
if (fromPath) return fromPath
|
||||
|
||||
const fromDocsPath = fromDocsPathname(new URL(request.url).pathname)
|
||||
if (fromDocsPath) return fromDocsPath
|
||||
|
||||
return (
|
||||
localeFromCookieHeader(request.headers.get("cookie")) ??
|
||||
detectFromAcceptLanguage(request.headers.get("accept-language"))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { docs, localeFromRequest, tag } from "~/lib/language"
|
||||
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
|
||||
|
||||
async function handler(evt: APIEvent) {
|
||||
const req = evt.request.clone()
|
||||
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
|
||||
headers,
|
||||
body: req.body,
|
||||
})
|
||||
return response
|
||||
const next = new Response(response.body, response)
|
||||
next.headers.append("set-cookie", cookie(locale))
|
||||
return next
|
||||
}
|
||||
|
||||
export const GET = handler
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { docs, localeFromRequest, tag } from "~/lib/language"
|
||||
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
|
||||
|
||||
async function handler(evt: APIEvent) {
|
||||
const req = evt.request.clone()
|
||||
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
|
||||
headers,
|
||||
body: req.body,
|
||||
})
|
||||
return response
|
||||
const next = new Response(response.body, response)
|
||||
next.headers.append("set-cookie", cookie(locale))
|
||||
return next
|
||||
}
|
||||
|
||||
export const GET = handler
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { APIEvent } from "@solidjs/start/server"
|
||||
import { Resource } from "@opencode-ai/console-resource"
|
||||
import { docs, localeFromRequest, tag } from "~/lib/language"
|
||||
import { cookie, docs, localeFromRequest, tag } from "~/lib/language"
|
||||
|
||||
async function handler(evt: APIEvent) {
|
||||
const req = evt.request.clone()
|
||||
@@ -17,7 +17,9 @@ async function handler(evt: APIEvent) {
|
||||
headers,
|
||||
body: req.body,
|
||||
})
|
||||
return response
|
||||
const next = new Response(response.body, response)
|
||||
next.headers.append("set-cookie", cookie(locale))
|
||||
return next
|
||||
}
|
||||
|
||||
export const GET = handler
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.2.18"
|
||||
version = "1.2.19"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.18/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.18/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.18/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.18/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.18/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.19/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { Log } from "../util/log"
|
||||
import { pathToFileURL } from "bun"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Hash } from "../util/hash"
|
||||
import { ACPSessionManager } from "./session"
|
||||
import type { ACPConfig } from "./types"
|
||||
import { Provider } from "../provider/provider"
|
||||
@@ -281,7 +282,7 @@ export namespace ACP {
|
||||
const output = this.bashOutput(part)
|
||||
const content: ToolCallContent[] = []
|
||||
if (output) {
|
||||
const hash = String(Bun.hash(output))
|
||||
const hash = Hash.fast(output)
|
||||
if (part.tool === "bash") {
|
||||
if (this.bashSnapshots.get(part.callID) === hash) {
|
||||
await this.connection
|
||||
@@ -346,21 +347,13 @@ export namespace ACP {
|
||||
]
|
||||
|
||||
if (kind === "edit") {
|
||||
const input = part.state.input
|
||||
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
|
||||
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
|
||||
const newText =
|
||||
typeof input["newString"] === "string"
|
||||
? input["newString"]
|
||||
: typeof input["content"] === "string"
|
||||
? input["content"]
|
||||
: ""
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText,
|
||||
newText,
|
||||
})
|
||||
const diff = editDiff(part.state.input, part.state.metadata)
|
||||
if (diff) {
|
||||
content.push({
|
||||
type: "diff",
|
||||
...diff,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (part.tool === "todowrite") {
|
||||
@@ -861,21 +854,13 @@ export namespace ACP {
|
||||
]
|
||||
|
||||
if (kind === "edit") {
|
||||
const input = part.state.input
|
||||
const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
|
||||
const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
|
||||
const newText =
|
||||
typeof input["newString"] === "string"
|
||||
? input["newString"]
|
||||
: typeof input["content"] === "string"
|
||||
? input["content"]
|
||||
: ""
|
||||
content.push({
|
||||
type: "diff",
|
||||
path: filePath,
|
||||
oldText,
|
||||
newText,
|
||||
})
|
||||
const diff = editDiff(part.state.input, part.state.metadata)
|
||||
if (diff) {
|
||||
content.push({
|
||||
type: "diff",
|
||||
...diff,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (part.tool === "todowrite") {
|
||||
@@ -1629,6 +1614,40 @@ export namespace ACP {
|
||||
}
|
||||
}
|
||||
|
||||
function editDiff(input: Record<string, any>, metadata: unknown) {
|
||||
const meta = typeof metadata === "object" && metadata ? (metadata as Record<string, any>) : undefined
|
||||
const filediff =
|
||||
meta && typeof meta["filediff"] === "object" && meta["filediff"]
|
||||
? (meta["filediff"] as Record<string, any>)
|
||||
: undefined
|
||||
const path =
|
||||
typeof filediff?.["file"] === "string"
|
||||
? filediff["file"]
|
||||
: typeof input["filePath"] === "string"
|
||||
? input["filePath"]
|
||||
: ""
|
||||
const oldText =
|
||||
typeof filediff?.["before"] === "string"
|
||||
? filediff["before"]
|
||||
: typeof input["oldString"] === "string"
|
||||
? input["oldString"]
|
||||
: ""
|
||||
const newText =
|
||||
typeof filediff?.["after"] === "string"
|
||||
? filediff["after"]
|
||||
: typeof input["newString"] === "string"
|
||||
? input["newString"]
|
||||
: typeof input["content"] === "string"
|
||||
? input["content"]
|
||||
: ""
|
||||
if (!path && !oldText && !newText) return
|
||||
return {
|
||||
path,
|
||||
oldText,
|
||||
newText,
|
||||
}
|
||||
}
|
||||
|
||||
function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined {
|
||||
const result = applyPatch(fileOriginal, unifiedDiff)
|
||||
if (result === false) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Instance } from "../../project/instance"
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { Process } from "../../util/process"
|
||||
import { text } from "node:stream/consumers"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
type PluginAuth = NonNullable<Hooks["auth"]>
|
||||
|
||||
@@ -47,7 +48,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string,
|
||||
const method = plugin.auth.methods[index]
|
||||
|
||||
// Handle prompts for all auth types
|
||||
await Bun.sleep(10)
|
||||
await sleep(10)
|
||||
const inputs: Record<string, string> = {}
|
||||
if (method.prompts) {
|
||||
for (const prompt of method.prompts) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { bootstrap } from "../../bootstrap"
|
||||
import { cmd } from "../cmd"
|
||||
import { Log } from "../../../util/log"
|
||||
import { EOL } from "os"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
export const LSPCommand = cmd({
|
||||
command: "lsp",
|
||||
@@ -19,7 +20,7 @@ const DiagnosticsCommand = cmd({
|
||||
async handler(args) {
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
await LSP.touchFile(args.file, true)
|
||||
await Bun.sleep(1000)
|
||||
await sleep(1000)
|
||||
process.stdout.write(JSON.stringify(await LSP.diagnostics(), null, 2) + EOL)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@ import { Bus } from "../../bus"
|
||||
import { MessageV2 } from "../../session/message-v2"
|
||||
import { SessionPrompt } from "@/session/prompt"
|
||||
import { $ } from "bun"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
type GitHubAuthor = {
|
||||
login: string
|
||||
@@ -353,7 +354,7 @@ export const GithubInstallCommand = cmd({
|
||||
}
|
||||
|
||||
retries++
|
||||
await Bun.sleep(1000)
|
||||
await sleep(1000)
|
||||
} while (true)
|
||||
|
||||
s.stop("Installed GitHub app")
|
||||
@@ -1372,7 +1373,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
|
||||
} catch (e) {
|
||||
if (retries > 0) {
|
||||
console.log(`Retrying after ${delayMs}ms...`)
|
||||
await Bun.sleep(delayMs)
|
||||
await sleep(delayMs)
|
||||
return withRetry(fn, retries - 1, delayMs)
|
||||
}
|
||||
throw e
|
||||
|
||||
@@ -6,6 +6,7 @@ import { cmd } from "./cmd"
|
||||
import { Flag } from "../../flag/flag"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { EOL } from "os"
|
||||
import { text as streamText } from "node:stream/consumers"
|
||||
import { Filesystem } from "../../util/filesystem"
|
||||
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { Server } from "../../server/server"
|
||||
@@ -337,7 +338,7 @@ export const RunCommand = cmd({
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
|
||||
if (!process.stdin.isTTY) message += "\n" + (await streamText(process.stdin))
|
||||
|
||||
if (message.trim().length === 0 && !args.command) {
|
||||
UI.error("You must provide a message or a command")
|
||||
|
||||
@@ -2065,7 +2065,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
|
||||
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
|
||||
Edit {normalizePath(props.input.filePath!)} {input(props.input, ["edits", "filePath"])}
|
||||
</InlineTool>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tui } from "./app"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { type rpc } from "./worker"
|
||||
import path from "path"
|
||||
import { text as streamText } from "node:stream/consumers"
|
||||
import { fileURLToPath } from "url"
|
||||
import { UI } from "@/cli/ui"
|
||||
import { Log } from "@/util/log"
|
||||
@@ -53,7 +54,7 @@ async function target() {
|
||||
}
|
||||
|
||||
async function input(value?: string) {
|
||||
const piped = process.stdin.isTTY ? undefined : await Bun.stdin.text()
|
||||
const piped = process.stdin.isTTY ? undefined : await streamText(process.stdin)
|
||||
if (!value) return piped
|
||||
if (!piped) return value
|
||||
return piped + "\n" + value
|
||||
|
||||
@@ -10,6 +10,7 @@ import { GlobalBus } from "@/bus/global"
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
||||
import type { BunWebSocketData } from "hono/bun"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
await Log.init({
|
||||
print: process.argv.includes("--print-logs"),
|
||||
@@ -75,7 +76,7 @@ const startEventStream = (directory: string) => {
|
||||
).catch(() => undefined)
|
||||
|
||||
if (!events) {
|
||||
await Bun.sleep(250)
|
||||
await sleep(250)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -84,7 +85,7 @@ const startEventStream = (directory: string) => {
|
||||
}
|
||||
|
||||
if (!signal.aborted) {
|
||||
await Bun.sleep(250)
|
||||
await sleep(250)
|
||||
}
|
||||
}
|
||||
})().catch((error) => {
|
||||
|
||||
@@ -25,12 +25,12 @@ export namespace UI {
|
||||
|
||||
export function println(...message: string[]) {
|
||||
print(...message)
|
||||
Bun.stderr.write(EOL)
|
||||
process.stderr.write(EOL)
|
||||
}
|
||||
|
||||
export function print(...message: string[]) {
|
||||
blank = false
|
||||
Bun.stderr.write(message.join(" "))
|
||||
process.stderr.write(message.join(" "))
|
||||
}
|
||||
|
||||
let blank = false
|
||||
@@ -44,7 +44,7 @@ export namespace UI {
|
||||
const result: string[] = []
|
||||
const reset = "\x1b[0m"
|
||||
const left = {
|
||||
fg: Bun.color("gray", "ansi") ?? "",
|
||||
fg: "\x1b[90m",
|
||||
shadow: "\x1b[38;5;235m",
|
||||
bg: "\x1b[48;5;235m",
|
||||
}
|
||||
|
||||
@@ -1151,6 +1151,12 @@ export namespace Config {
|
||||
.object({
|
||||
disable_paste_summary: z.boolean().optional(),
|
||||
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
|
||||
hashline_autocorrect: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"Enable hashline autocorrect cleanup for copied prefixes and formatting artifacts (default true)",
|
||||
),
|
||||
openTelemetry: z
|
||||
.boolean()
|
||||
.optional()
|
||||
@@ -1240,7 +1246,7 @@ export namespace Config {
|
||||
if (!parsed.data.$schema && isFile) {
|
||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
|
||||
await Bun.write(options.path, updated).catch(() => {})
|
||||
await Filesystem.write(options.path, updated).catch(() => {})
|
||||
}
|
||||
const data = parsed.data
|
||||
if (data.plugin && isFile) {
|
||||
@@ -1401,3 +1407,5 @@ export namespace Config {
|
||||
return state().then((x) => x.directories)
|
||||
}
|
||||
}
|
||||
Filesystem.write
|
||||
Filesystem.write
|
||||
|
||||
@@ -70,7 +70,7 @@ export async function migrateTuiConfig(input: MigrateInput) {
|
||||
if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds
|
||||
if (tui) Object.assign(payload, tui)
|
||||
|
||||
const wrote = await Bun.write(target, JSON.stringify(payload, null, 2))
|
||||
const wrote = await Filesystem.write(target, JSON.stringify(payload, null, 2))
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
log.warn("failed to write tui migration target", { from: file, to: target, error })
|
||||
@@ -104,7 +104,7 @@ async function backupAndStripLegacy(file: string, source: string) {
|
||||
const hasBackup = await Filesystem.exists(backup)
|
||||
const backed = hasBackup
|
||||
? true
|
||||
: await Bun.write(backup, source)
|
||||
: await Filesystem.write(backup, source)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
log.warn("failed to backup source config during tui migration", { path: file, backup, error })
|
||||
@@ -123,7 +123,7 @@ async function backupAndStripLegacy(file: string, source: string) {
|
||||
return applyEdits(acc, edits)
|
||||
}, source)
|
||||
|
||||
return Bun.write(file, text)
|
||||
return Filesystem.write(file, text)
|
||||
.then(() => {
|
||||
log.info("stripped tui keys from server config", { path: file, backup })
|
||||
return true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createConnection } from "net"
|
||||
import { Log } from "../util/log"
|
||||
import { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "./oauth-provider"
|
||||
|
||||
@@ -160,21 +161,12 @@ export namespace McpOAuthCallback {
|
||||
|
||||
export async function isPortInUse(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
Bun.connect({
|
||||
hostname: "127.0.0.1",
|
||||
port: OAUTH_CALLBACK_PORT,
|
||||
socket: {
|
||||
open(socket) {
|
||||
socket.end()
|
||||
resolve(true)
|
||||
},
|
||||
error() {
|
||||
resolve(false)
|
||||
},
|
||||
data() {},
|
||||
close() {},
|
||||
},
|
||||
}).catch(() => {
|
||||
const socket = createConnection(OAUTH_CALLBACK_PORT, "127.0.0.1")
|
||||
socket.on("connect", () => {
|
||||
socket.destroy()
|
||||
resolve(true)
|
||||
})
|
||||
socket.on("error", () => {
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Installation } from "../installation"
|
||||
import { Auth, OAUTH_DUMMY_KEY } from "../auth"
|
||||
import os from "os"
|
||||
import { ProviderTransform } from "@/provider/transform"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
const log = Log.create({ service: "plugin.codex" })
|
||||
|
||||
@@ -361,6 +362,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
"gpt-5.1-codex-max",
|
||||
"gpt-5.1-codex-mini",
|
||||
"gpt-5.2",
|
||||
"gpt-5.4",
|
||||
"gpt-5.2-codex",
|
||||
"gpt-5.3-codex",
|
||||
"gpt-5.1-codex",
|
||||
@@ -602,7 +604,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
return { type: "failed" as const }
|
||||
}
|
||||
|
||||
await Bun.sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
await sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { Installation } from "@/installation"
|
||||
import { iife } from "@/util/iife"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
|
||||
// Add a small safety buffer when polling to avoid hitting the server
|
||||
@@ -270,7 +271,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
}
|
||||
|
||||
if (data.error === "authorization_pending") {
|
||||
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -286,13 +287,13 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
newInterval = serverInterval * 1000
|
||||
}
|
||||
|
||||
await Bun.sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
await sleep(newInterval + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
continue
|
||||
}
|
||||
|
||||
if (data.error) return { type: "failed" as const }
|
||||
|
||||
await Bun.sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
await sleep(deviceData.interval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS)
|
||||
continue
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,9 +6,10 @@ import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
|
||||
import { NoSuchModelError, type Provider as SDK } from "ai"
|
||||
import { Log } from "../util/log"
|
||||
import { BunProc } from "../bun"
|
||||
import { Hash } from "../util/hash"
|
||||
import { Plugin } from "../plugin"
|
||||
import { ModelsDev } from "./models"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { ModelsDev } from "./models"
|
||||
import { Auth } from "../auth"
|
||||
import { Env } from "../env"
|
||||
import { Instance } from "../project/instance"
|
||||
@@ -795,7 +796,7 @@ export namespace Provider {
|
||||
const modelLoaders: {
|
||||
[providerID: string]: CustomModelLoader
|
||||
} = {}
|
||||
const sdk = new Map<number, SDK>()
|
||||
const sdk = new Map<string, SDK>()
|
||||
|
||||
log.info("init")
|
||||
|
||||
@@ -1085,7 +1086,7 @@ export namespace Provider {
|
||||
...model.headers,
|
||||
}
|
||||
|
||||
const key = Bun.hash.xxHash32(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
|
||||
const key = Hash.fast(JSON.stringify({ providerID: model.providerID, npm: model.api.npm, options }))
|
||||
const existing = s.sdk.get(key)
|
||||
if (existing) return existing
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks
|
||||
## Editing constraints
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
- Only add comments if they are necessary to make a non-obvious block easier to understand.
|
||||
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
||||
- Prefer the edit tool for file edits. Use apply_patch only when it is available and clearly a better fit. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
|
||||
|
||||
## Tool usage
|
||||
- Prefer specialized tools over shell for file operations:
|
||||
|
||||
@@ -3,6 +3,7 @@ import { lazy } from "@/util/lazy"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import path from "path"
|
||||
import { spawn, type ChildProcess } from "child_process"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
const SIGKILL_TIMEOUT_MS = 200
|
||||
|
||||
@@ -22,13 +23,13 @@ export namespace Shell {
|
||||
|
||||
try {
|
||||
process.kill(-pid, "SIGTERM")
|
||||
await Bun.sleep(SIGKILL_TIMEOUT_MS)
|
||||
await sleep(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
process.kill(-pid, "SIGKILL")
|
||||
}
|
||||
} catch (_e) {
|
||||
proc.kill("SIGTERM")
|
||||
await Bun.sleep(SIGKILL_TIMEOUT_MS)
|
||||
await sleep(SIGKILL_TIMEOUT_MS)
|
||||
if (!opts?.exited?.()) {
|
||||
proc.kill("SIGKILL")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { $ } from "bun"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { Log } from "../util/log"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Global } from "../global"
|
||||
@@ -271,13 +272,12 @@ export namespace Snapshot {
|
||||
const target = path.join(git, "info", "exclude")
|
||||
await fs.mkdir(path.join(git, "info"), { recursive: true })
|
||||
if (!file) {
|
||||
await Bun.write(target, "")
|
||||
await Filesystem.write(target, "")
|
||||
return
|
||||
}
|
||||
const text = await Bun.file(file)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
await Bun.write(target, text)
|
||||
const text = await Filesystem.readText(file).catch(() => "")
|
||||
|
||||
await Filesystem.write(target, text)
|
||||
}
|
||||
|
||||
async function excludes() {
|
||||
|
||||
+278
-563
@@ -1,13 +1,9 @@
|
||||
// the approaches in this edit tool are sourced from
|
||||
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-23-25.ts
|
||||
// https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/utils/editCorrector.ts
|
||||
// https://github.com/cline/cline/blob/main/evals/diff-edits/diff-apply/diff-06-26-25.ts
|
||||
|
||||
import z from "zod"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Tool } from "./tool"
|
||||
import { LSP } from "../lsp"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import DESCRIPTION from "./edit.txt"
|
||||
import { File } from "../file"
|
||||
import { FileWatcher } from "../file/watcher"
|
||||
@@ -17,567 +13,325 @@ import { Filesystem } from "../util/filesystem"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import {
|
||||
HashlineEdit,
|
||||
applyHashlineEdits,
|
||||
hashlineOnlyCreates,
|
||||
parseHashlineContent,
|
||||
serializeHashlineContent,
|
||||
} from "./hashline"
|
||||
import { Config } from "../config/config"
|
||||
|
||||
const MAX_DIAGNOSTICS_PER_FILE = 20
|
||||
const HASHLINE_EDIT_MODE = "hashline"
|
||||
const LEGACY_KEYS = ["oldString", "newString", "replaceAll"] as const
|
||||
|
||||
const EditParams = z
|
||||
.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
edits: z.array(HashlineEdit).optional(),
|
||||
delete: z.boolean().optional(),
|
||||
rename: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.edits !== undefined) return
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Hashline payload requires edits (use [] when only delete or rename is intended).",
|
||||
})
|
||||
})
|
||||
|
||||
type EditParams = z.infer<typeof EditParams>
|
||||
|
||||
function formatValidationError(error: z.ZodError) {
|
||||
const legacy = error.issues.some((issue) => {
|
||||
if (issue.code !== "unrecognized_keys") return false
|
||||
if (!("keys" in issue) || !Array.isArray(issue.keys)) return false
|
||||
return issue.keys.some((key) => LEGACY_KEYS.includes(key as (typeof LEGACY_KEYS)[number]))
|
||||
})
|
||||
if (legacy) {
|
||||
return "Legacy edit payload has been removed. Use hashline fields: { filePath, edits, delete?, rename? }."
|
||||
}
|
||||
return `Invalid parameters for tool 'edit':\n${error.issues.map((issue) => `- ${issue.message}`).join("\n")}`
|
||||
}
|
||||
|
||||
function normalizeLineEndings(text: string): string {
|
||||
return text.replaceAll("\r\n", "\n")
|
||||
}
|
||||
|
||||
export const EditTool = Tool.define("edit", {
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
filePath: z.string().describe("The absolute path to the file to modify"),
|
||||
oldString: z.string().describe("The text to replace"),
|
||||
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
|
||||
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
if (!params.filePath) {
|
||||
throw new Error("filePath is required")
|
||||
async function withLocks(paths: string[], fn: () => Promise<void>) {
|
||||
const unique = Array.from(new Set(paths)).sort((a, b) => a.localeCompare(b))
|
||||
const recurse = async (idx: number): Promise<void> => {
|
||||
if (idx >= unique.length) return fn()
|
||||
await FileTime.withLock(unique[idx], () => recurse(idx + 1))
|
||||
}
|
||||
await recurse(0)
|
||||
}
|
||||
|
||||
function createFileDiff(file: string, before: string, after: string): Snapshot.FileDiff {
|
||||
const filediff: Snapshot.FileDiff = {
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
for (const change of diffLines(before, after)) {
|
||||
if (change.added) filediff.additions += change.count || 0
|
||||
if (change.removed) filediff.deletions += change.count || 0
|
||||
}
|
||||
return filediff
|
||||
}
|
||||
|
||||
async function diagnosticsOutput(filePath: string, output: string) {
|
||||
await LSP.touchFile(filePath, true)
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
const normalizedFilePath = Filesystem.normalizePath(filePath)
|
||||
const issues = diagnostics[normalizedFilePath] ?? []
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length === 0) {
|
||||
return {
|
||||
output,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
return {
|
||||
output:
|
||||
output +
|
||||
`\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`,
|
||||
diagnostics,
|
||||
}
|
||||
}
|
||||
|
||||
async function executeHashline(
|
||||
params: EditParams,
|
||||
ctx: Tool.Context,
|
||||
autocorrect: boolean,
|
||||
aggressiveAutocorrect: boolean,
|
||||
) {
|
||||
const sourcePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
||||
const targetPath = params.rename
|
||||
? path.isAbsolute(params.rename)
|
||||
? params.rename
|
||||
: path.join(Instance.directory, params.rename)
|
||||
: sourcePath
|
||||
const edits = params.edits ?? []
|
||||
|
||||
await assertExternalDirectory(ctx, sourcePath)
|
||||
if (params.rename) {
|
||||
await assertExternalDirectory(ctx, targetPath)
|
||||
}
|
||||
|
||||
if (params.delete && edits.length > 0) {
|
||||
throw new Error("delete=true cannot be combined with edits")
|
||||
}
|
||||
if (params.delete && params.rename) {
|
||||
throw new Error("delete=true cannot be combined with rename")
|
||||
}
|
||||
|
||||
let diff = ""
|
||||
let before = ""
|
||||
let after = ""
|
||||
let noop = 0
|
||||
let deleted = false
|
||||
let changed = false
|
||||
let diagnostics: Awaited<ReturnType<typeof LSP.diagnostics>> = {}
|
||||
await withLocks([sourcePath, targetPath], async () => {
|
||||
const sourceStat = Filesystem.stat(sourcePath)
|
||||
if (sourceStat?.isDirectory()) throw new Error(`Path is a directory, not a file: ${sourcePath}`)
|
||||
const exists = Boolean(sourceStat)
|
||||
|
||||
if (params.rename && !exists) {
|
||||
throw new Error("rename requires an existing source file")
|
||||
}
|
||||
|
||||
if (params.oldString === params.newString) {
|
||||
throw new Error("No changes to apply: oldString and newString are identical.")
|
||||
}
|
||||
|
||||
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
|
||||
await assertExternalDirectory(ctx, filePath)
|
||||
|
||||
let diff = ""
|
||||
let contentOld = ""
|
||||
let contentNew = ""
|
||||
await FileTime.withLock(filePath, async () => {
|
||||
if (params.oldString === "") {
|
||||
const existed = await Filesystem.exists(filePath)
|
||||
contentNew = params.newString
|
||||
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filePath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: filePath,
|
||||
diff,
|
||||
},
|
||||
})
|
||||
await Filesystem.write(filePath, params.newString)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filePath,
|
||||
})
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: existed ? "change" : "add",
|
||||
})
|
||||
FileTime.read(ctx.sessionID, filePath)
|
||||
if (params.delete) {
|
||||
if (!exists) {
|
||||
noop = 1
|
||||
return
|
||||
}
|
||||
|
||||
const stats = Filesystem.stat(filePath)
|
||||
if (!stats) throw new Error(`File ${filePath} not found`)
|
||||
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
|
||||
await FileTime.assert(ctx.sessionID, filePath)
|
||||
contentOld = await Filesystem.readText(filePath)
|
||||
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
|
||||
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
||||
)
|
||||
await FileTime.assert(ctx.sessionID, sourcePath)
|
||||
before = await Filesystem.readText(sourcePath)
|
||||
diff = trimDiff(createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), ""))
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: [path.relative(Instance.worktree, filePath)],
|
||||
patterns: [path.relative(Instance.worktree, sourcePath)],
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: filePath,
|
||||
filepath: sourcePath,
|
||||
diff,
|
||||
},
|
||||
})
|
||||
|
||||
await Filesystem.write(filePath, contentNew)
|
||||
await fs.rm(sourcePath, { force: true })
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: filePath,
|
||||
file: sourcePath,
|
||||
})
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: filePath,
|
||||
event: "change",
|
||||
file: sourcePath,
|
||||
event: "unlink",
|
||||
})
|
||||
contentNew = await Filesystem.readText(filePath)
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
|
||||
)
|
||||
FileTime.read(ctx.sessionID, filePath)
|
||||
deleted = true
|
||||
changed = true
|
||||
return
|
||||
}
|
||||
|
||||
if (!exists && !hashlineOnlyCreates(edits)) {
|
||||
throw new Error("Missing file can only be created with append/prepend hashline edits")
|
||||
}
|
||||
if (exists) {
|
||||
await FileTime.assert(ctx.sessionID, sourcePath)
|
||||
}
|
||||
|
||||
const parsed = exists
|
||||
? parseHashlineContent(await Filesystem.readBytes(sourcePath))
|
||||
: {
|
||||
bom: false,
|
||||
eol: "\n",
|
||||
trailing: false,
|
||||
lines: [] as string[],
|
||||
text: "",
|
||||
raw: "",
|
||||
}
|
||||
|
||||
before = parsed.raw
|
||||
const next = applyHashlineEdits({
|
||||
lines: parsed.lines,
|
||||
trailing: parsed.trailing,
|
||||
edits,
|
||||
autocorrect,
|
||||
aggressiveAutocorrect,
|
||||
})
|
||||
const output = serializeHashlineContent({
|
||||
lines: next.lines,
|
||||
trailing: next.trailing,
|
||||
eol: parsed.eol,
|
||||
bom: parsed.bom,
|
||||
})
|
||||
after = output.text
|
||||
|
||||
const filediff: Snapshot.FileDiff = {
|
||||
file: filePath,
|
||||
before: contentOld,
|
||||
after: contentNew,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
for (const change of diffLines(contentOld, contentNew)) {
|
||||
if (change.added) filediff.additions += change.count || 0
|
||||
if (change.removed) filediff.deletions += change.count || 0
|
||||
if (before === after && sourcePath === targetPath) {
|
||||
noop = 1
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), normalizeLineEndings(after)),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.metadata({
|
||||
diff = trimDiff(
|
||||
createTwoFilesPatch(sourcePath, targetPath, normalizeLineEndings(before), normalizeLineEndings(after)),
|
||||
)
|
||||
const patterns = [path.relative(Instance.worktree, sourcePath)]
|
||||
if (sourcePath !== targetPath) patterns.push(path.relative(Instance.worktree, targetPath))
|
||||
await ctx.ask({
|
||||
permission: "edit",
|
||||
patterns: Array.from(new Set(patterns)),
|
||||
always: ["*"],
|
||||
metadata: {
|
||||
filepath: sourcePath,
|
||||
diff,
|
||||
filediff,
|
||||
diagnostics: {},
|
||||
},
|
||||
})
|
||||
|
||||
let output = "Edit applied successfully."
|
||||
await LSP.touchFile(filePath, true)
|
||||
const diagnostics = await LSP.diagnostics()
|
||||
const normalizedFilePath = Filesystem.normalizePath(filePath)
|
||||
const issues = diagnostics[normalizedFilePath] ?? []
|
||||
const errors = issues.filter((item) => item.severity === 1)
|
||||
if (errors.length > 0) {
|
||||
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
|
||||
const suffix =
|
||||
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
|
||||
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
|
||||
if (sourcePath === targetPath) {
|
||||
await Filesystem.write(sourcePath, output.bytes)
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: sourcePath,
|
||||
})
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: sourcePath,
|
||||
event: exists ? "change" : "add",
|
||||
})
|
||||
FileTime.read(ctx.sessionID, sourcePath)
|
||||
changed = true
|
||||
return
|
||||
}
|
||||
|
||||
const targetExists = await Filesystem.exists(targetPath)
|
||||
await Filesystem.write(targetPath, output.bytes)
|
||||
await fs.rm(sourcePath, { force: true })
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: sourcePath,
|
||||
})
|
||||
await Bus.publish(File.Event.Edited, {
|
||||
file: targetPath,
|
||||
})
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: sourcePath,
|
||||
event: "unlink",
|
||||
})
|
||||
await Bus.publish(FileWatcher.Event.Updated, {
|
||||
file: targetPath,
|
||||
event: targetExists ? "change" : "add",
|
||||
})
|
||||
FileTime.read(ctx.sessionID, targetPath)
|
||||
changed = true
|
||||
})
|
||||
|
||||
const file = deleted ? sourcePath : targetPath
|
||||
const filediff = createFileDiff(file, before, after)
|
||||
ctx.metadata({
|
||||
metadata: {
|
||||
diff,
|
||||
filediff,
|
||||
diagnostics,
|
||||
edit_mode: HASHLINE_EDIT_MODE,
|
||||
noop,
|
||||
},
|
||||
})
|
||||
|
||||
if (!deleted && (changed || noop === 0)) {
|
||||
const result = await diagnosticsOutput(targetPath, noop > 0 ? "No changes applied." : "Edit applied successfully.")
|
||||
diagnostics = result.diagnostics
|
||||
return {
|
||||
metadata: {
|
||||
diagnostics,
|
||||
diff,
|
||||
filediff,
|
||||
edit_mode: HASHLINE_EDIT_MODE,
|
||||
noop,
|
||||
},
|
||||
title: `${path.relative(Instance.worktree, filePath)}`,
|
||||
output,
|
||||
title: `${path.relative(Instance.worktree, targetPath)}`,
|
||||
output: result.output,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
diagnostics,
|
||||
diff,
|
||||
filediff,
|
||||
edit_mode: HASHLINE_EDIT_MODE,
|
||||
noop,
|
||||
},
|
||||
title: `${path.relative(Instance.worktree, file)}`,
|
||||
output: deleted ? "Edit applied successfully." : "No changes applied.",
|
||||
}
|
||||
}
|
||||
|
||||
export const EditTool = Tool.define("edit", {
|
||||
description: DESCRIPTION,
|
||||
parameters: EditParams,
|
||||
formatValidationError,
|
||||
async execute(params, ctx) {
|
||||
if (!params.filePath) {
|
||||
throw new Error("filePath is required")
|
||||
}
|
||||
|
||||
const config = await Config.get()
|
||||
return executeHashline(
|
||||
params,
|
||||
ctx,
|
||||
config.experimental?.hashline_autocorrect !== false || Bun.env.OPENCODE_HL_AUTOCORRECT === "1",
|
||||
Bun.env.OPENCODE_HL_AUTOCORRECT === "1",
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
|
||||
|
||||
// Similarity thresholds for block anchor fallback matching
|
||||
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.0
|
||||
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.3
|
||||
|
||||
/**
|
||||
* Levenshtein distance algorithm implementation
|
||||
*/
|
||||
function levenshtein(a: string, b: string): number {
|
||||
// Handle empty strings
|
||||
if (a === "" || b === "") {
|
||||
return Math.max(a.length, b.length)
|
||||
}
|
||||
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
|
||||
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
|
||||
)
|
||||
|
||||
for (let i = 1; i <= a.length; i++) {
|
||||
for (let j = 1; j <= b.length; j++) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
||||
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
|
||||
}
|
||||
}
|
||||
return matrix[a.length][b.length]
|
||||
}
|
||||
|
||||
export const SimpleReplacer: Replacer = function* (_content, find) {
|
||||
yield find
|
||||
}
|
||||
|
||||
export const LineTrimmedReplacer: Replacer = function* (content, find) {
|
||||
const originalLines = content.split("\n")
|
||||
const searchLines = find.split("\n")
|
||||
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
|
||||
let matches = true
|
||||
|
||||
for (let j = 0; j < searchLines.length; j++) {
|
||||
const originalTrimmed = originalLines[i + j].trim()
|
||||
const searchTrimmed = searchLines[j].trim()
|
||||
|
||||
if (originalTrimmed !== searchTrimmed) {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (matches) {
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < i; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
}
|
||||
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = 0; k < searchLines.length; k++) {
|
||||
matchEndIndex += originalLines[i + k].length
|
||||
if (k < searchLines.length - 1) {
|
||||
matchEndIndex += 1 // Add newline character except for the last line
|
||||
}
|
||||
}
|
||||
|
||||
yield content.substring(matchStartIndex, matchEndIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const BlockAnchorReplacer: Replacer = function* (content, find) {
|
||||
const originalLines = content.split("\n")
|
||||
const searchLines = find.split("\n")
|
||||
|
||||
if (searchLines.length < 3) {
|
||||
return
|
||||
}
|
||||
|
||||
if (searchLines[searchLines.length - 1] === "") {
|
||||
searchLines.pop()
|
||||
}
|
||||
|
||||
const firstLineSearch = searchLines[0].trim()
|
||||
const lastLineSearch = searchLines[searchLines.length - 1].trim()
|
||||
const searchBlockSize = searchLines.length
|
||||
|
||||
// Collect all candidate positions where both anchors match
|
||||
const candidates: Array<{ startLine: number; endLine: number }> = []
|
||||
for (let i = 0; i < originalLines.length; i++) {
|
||||
if (originalLines[i].trim() !== firstLineSearch) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Look for the matching last line after this first line
|
||||
for (let j = i + 2; j < originalLines.length; j++) {
|
||||
if (originalLines[j].trim() === lastLineSearch) {
|
||||
candidates.push({ startLine: i, endLine: j })
|
||||
break // Only match the first occurrence of the last line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return immediately if no candidates
|
||||
if (candidates.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle single candidate scenario (using relaxed threshold)
|
||||
if (candidates.length === 1) {
|
||||
const { startLine, endLine } = candidates[0]
|
||||
const actualBlockSize = endLine - startLine + 1
|
||||
|
||||
let similarity = 0
|
||||
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
|
||||
|
||||
if (linesToCheck > 0) {
|
||||
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
||||
const originalLine = originalLines[startLine + j].trim()
|
||||
const searchLine = searchLines[j].trim()
|
||||
const maxLen = Math.max(originalLine.length, searchLine.length)
|
||||
if (maxLen === 0) {
|
||||
continue
|
||||
}
|
||||
const distance = levenshtein(originalLine, searchLine)
|
||||
similarity += (1 - distance / maxLen) / linesToCheck
|
||||
|
||||
// Exit early when threshold is reached
|
||||
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No middle lines to compare, just accept based on anchors
|
||||
similarity = 1.0
|
||||
}
|
||||
|
||||
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < startLine; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
}
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = startLine; k <= endLine; k++) {
|
||||
matchEndIndex += originalLines[k].length
|
||||
if (k < endLine) {
|
||||
matchEndIndex += 1 // Add newline character except for the last line
|
||||
}
|
||||
}
|
||||
yield content.substring(matchStartIndex, matchEndIndex)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate similarity for multiple candidates
|
||||
let bestMatch: { startLine: number; endLine: number } | null = null
|
||||
let maxSimilarity = -1
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const { startLine, endLine } = candidate
|
||||
const actualBlockSize = endLine - startLine + 1
|
||||
|
||||
let similarity = 0
|
||||
let linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
|
||||
|
||||
if (linesToCheck > 0) {
|
||||
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
|
||||
const originalLine = originalLines[startLine + j].trim()
|
||||
const searchLine = searchLines[j].trim()
|
||||
const maxLen = Math.max(originalLine.length, searchLine.length)
|
||||
if (maxLen === 0) {
|
||||
continue
|
||||
}
|
||||
const distance = levenshtein(originalLine, searchLine)
|
||||
similarity += 1 - distance / maxLen
|
||||
}
|
||||
similarity /= linesToCheck // Average similarity
|
||||
} else {
|
||||
// No middle lines to compare, just accept based on anchors
|
||||
similarity = 1.0
|
||||
}
|
||||
|
||||
if (similarity > maxSimilarity) {
|
||||
maxSimilarity = similarity
|
||||
bestMatch = candidate
|
||||
}
|
||||
}
|
||||
|
||||
// Threshold judgment
|
||||
if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
|
||||
const { startLine, endLine } = bestMatch
|
||||
let matchStartIndex = 0
|
||||
for (let k = 0; k < startLine; k++) {
|
||||
matchStartIndex += originalLines[k].length + 1
|
||||
}
|
||||
let matchEndIndex = matchStartIndex
|
||||
for (let k = startLine; k <= endLine; k++) {
|
||||
matchEndIndex += originalLines[k].length
|
||||
if (k < endLine) {
|
||||
matchEndIndex += 1
|
||||
}
|
||||
}
|
||||
yield content.substring(matchStartIndex, matchEndIndex)
|
||||
}
|
||||
}
|
||||
|
||||
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
|
||||
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
|
||||
const normalizedFind = normalizeWhitespace(find)
|
||||
|
||||
// Handle single line matches
|
||||
const lines = content.split("\n")
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (normalizeWhitespace(line) === normalizedFind) {
|
||||
yield line
|
||||
} else {
|
||||
// Only check for substring matches if the full line doesn't match
|
||||
const normalizedLine = normalizeWhitespace(line)
|
||||
if (normalizedLine.includes(normalizedFind)) {
|
||||
// Find the actual substring in the original line that matches
|
||||
const words = find.trim().split(/\s+/)
|
||||
if (words.length > 0) {
|
||||
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
|
||||
try {
|
||||
const regex = new RegExp(pattern)
|
||||
const match = line.match(regex)
|
||||
if (match) {
|
||||
yield match[0]
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid regex pattern, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle multi-line matches
|
||||
const findLines = find.split("\n")
|
||||
if (findLines.length > 1) {
|
||||
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||
const block = lines.slice(i, i + findLines.length)
|
||||
if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
|
||||
yield block.join("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
|
||||
const removeIndentation = (text: string) => {
|
||||
const lines = text.split("\n")
|
||||
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
|
||||
if (nonEmptyLines.length === 0) return text
|
||||
|
||||
const minIndent = Math.min(
|
||||
...nonEmptyLines.map((line) => {
|
||||
const match = line.match(/^(\s*)/)
|
||||
return match ? match[1].length : 0
|
||||
}),
|
||||
)
|
||||
|
||||
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
|
||||
}
|
||||
|
||||
const normalizedFind = removeIndentation(find)
|
||||
const contentLines = content.split("\n")
|
||||
const findLines = find.split("\n")
|
||||
|
||||
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
|
||||
const block = contentLines.slice(i, i + findLines.length).join("\n")
|
||||
if (removeIndentation(block) === normalizedFind) {
|
||||
yield block
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
|
||||
const unescapeString = (str: string): string => {
|
||||
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
|
||||
switch (capturedChar) {
|
||||
case "n":
|
||||
return "\n"
|
||||
case "t":
|
||||
return "\t"
|
||||
case "r":
|
||||
return "\r"
|
||||
case "'":
|
||||
return "'"
|
||||
case '"':
|
||||
return '"'
|
||||
case "`":
|
||||
return "`"
|
||||
case "\\":
|
||||
return "\\"
|
||||
case "\n":
|
||||
return "\n"
|
||||
case "$":
|
||||
return "$"
|
||||
default:
|
||||
return match
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const unescapedFind = unescapeString(find)
|
||||
|
||||
// Try direct match with unescaped find string
|
||||
if (content.includes(unescapedFind)) {
|
||||
yield unescapedFind
|
||||
}
|
||||
|
||||
// Also try finding escaped versions in content that match unescaped find
|
||||
const lines = content.split("\n")
|
||||
const findLines = unescapedFind.split("\n")
|
||||
|
||||
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||
const block = lines.slice(i, i + findLines.length).join("\n")
|
||||
const unescapedBlock = unescapeString(block)
|
||||
|
||||
if (unescapedBlock === unescapedFind) {
|
||||
yield block
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
|
||||
// This replacer yields all exact matches, allowing the replace function
|
||||
// to handle multiple occurrences based on replaceAll parameter
|
||||
let startIndex = 0
|
||||
|
||||
while (true) {
|
||||
const index = content.indexOf(find, startIndex)
|
||||
if (index === -1) break
|
||||
|
||||
yield find
|
||||
startIndex = index + find.length
|
||||
}
|
||||
}
|
||||
|
||||
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
|
||||
const trimmedFind = find.trim()
|
||||
|
||||
if (trimmedFind === find) {
|
||||
// Already trimmed, no point in trying
|
||||
return
|
||||
}
|
||||
|
||||
// Try to find the trimmed version
|
||||
if (content.includes(trimmedFind)) {
|
||||
yield trimmedFind
|
||||
}
|
||||
|
||||
// Also try finding blocks where trimmed content matches
|
||||
const lines = content.split("\n")
|
||||
const findLines = find.split("\n")
|
||||
|
||||
for (let i = 0; i <= lines.length - findLines.length; i++) {
|
||||
const block = lines.slice(i, i + findLines.length).join("\n")
|
||||
|
||||
if (block.trim() === trimmedFind) {
|
||||
yield block
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ContextAwareReplacer: Replacer = function* (content, find) {
|
||||
const findLines = find.split("\n")
|
||||
if (findLines.length < 3) {
|
||||
// Need at least 3 lines to have meaningful context
|
||||
return
|
||||
}
|
||||
|
||||
// Remove trailing empty line if present
|
||||
if (findLines[findLines.length - 1] === "") {
|
||||
findLines.pop()
|
||||
}
|
||||
|
||||
const contentLines = content.split("\n")
|
||||
|
||||
// Extract first and last lines as context anchors
|
||||
const firstLine = findLines[0].trim()
|
||||
const lastLine = findLines[findLines.length - 1].trim()
|
||||
|
||||
// Find blocks that start and end with the context anchors
|
||||
for (let i = 0; i < contentLines.length; i++) {
|
||||
if (contentLines[i].trim() !== firstLine) continue
|
||||
|
||||
// Look for the matching last line
|
||||
for (let j = i + 2; j < contentLines.length; j++) {
|
||||
if (contentLines[j].trim() === lastLine) {
|
||||
// Found a potential context block
|
||||
const blockLines = contentLines.slice(i, j + 1)
|
||||
const block = blockLines.join("\n")
|
||||
|
||||
// Check if the middle content has reasonable similarity
|
||||
// (simple heuristic: at least 50% of non-empty lines should match when trimmed)
|
||||
if (blockLines.length === findLines.length) {
|
||||
let matchingLines = 0
|
||||
let totalNonEmptyLines = 0
|
||||
|
||||
for (let k = 1; k < blockLines.length - 1; k++) {
|
||||
const blockLine = blockLines[k].trim()
|
||||
const findLine = findLines[k].trim()
|
||||
|
||||
if (blockLine.length > 0 || findLine.length > 0) {
|
||||
totalNonEmptyLines++
|
||||
if (blockLine === findLine) {
|
||||
matchingLines++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
|
||||
yield block
|
||||
break // Only match the first occurrence
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function trimDiff(diff: string): string {
|
||||
const lines = diff.split("\n")
|
||||
const contentLines = lines.filter(
|
||||
@@ -613,42 +367,3 @@ export function trimDiff(diff: string): string {
|
||||
|
||||
return trimmedLines.join("\n")
|
||||
}
|
||||
|
||||
export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
|
||||
if (oldString === newString) {
|
||||
throw new Error("No changes to apply: oldString and newString are identical.")
|
||||
}
|
||||
|
||||
let notFound = true
|
||||
|
||||
for (const replacer of [
|
||||
SimpleReplacer,
|
||||
LineTrimmedReplacer,
|
||||
BlockAnchorReplacer,
|
||||
WhitespaceNormalizedReplacer,
|
||||
IndentationFlexibleReplacer,
|
||||
EscapeNormalizedReplacer,
|
||||
TrimmedBoundaryReplacer,
|
||||
ContextAwareReplacer,
|
||||
MultiOccurrenceReplacer,
|
||||
]) {
|
||||
for (const search of replacer(content, oldString)) {
|
||||
const index = content.indexOf(search)
|
||||
if (index === -1) continue
|
||||
notFound = false
|
||||
if (replaceAll) {
|
||||
return content.replaceAll(search, newString)
|
||||
}
|
||||
const lastIndex = content.lastIndexOf(search)
|
||||
if (index !== lastIndex) continue
|
||||
return content.substring(0, index) + newString + content.substring(index + search.length)
|
||||
}
|
||||
}
|
||||
|
||||
if (notFound) {
|
||||
throw new Error(
|
||||
"Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.",
|
||||
)
|
||||
}
|
||||
throw new Error("Found multiple matches for oldString. Provide more surrounding context to make the match unique.")
|
||||
}
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
Performs exact string replacements in files.
|
||||
Performs file edits with hashline anchors.
|
||||
|
||||
Usage:
|
||||
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
|
||||
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + colon + space (e.g., `1: `). Everything after that space is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.
|
||||
- You must use your `Read` tool at least once before editing an existing file. This tool rejects stale edits when file contents changed since read.
|
||||
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
|
||||
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
|
||||
- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content".
|
||||
- The edit will FAIL if `oldString` is found multiple times in the file with an error "Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match." Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
|
||||
- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
|
||||
- `{ filePath, edits, delete?, rename? }`
|
||||
- `edits` is required; use `[]` when only delete or rename is intended.
|
||||
- Use strict anchor references from `Read` output: `LINE#ID`.
|
||||
- Autocorrect cleanup is on by default and can be turned off with `experimental.hashline_autocorrect: false`.
|
||||
- Default autocorrect only strips copied `LINE#ID:`/`>>>` prefixes; set `OPENCODE_HL_AUTOCORRECT=1` to opt into heavier cleanup heuristics.
|
||||
- When `Read` returns `LINE#ID:<content>`, prefer hashline operations.
|
||||
- Operations:
|
||||
- `set_line { line, text }`
|
||||
- `replace_lines { start_line, end_line, text }`
|
||||
- `insert_after { line, text }`
|
||||
- `insert_before { line, text }`
|
||||
- `insert_between { after_line, before_line, text }`
|
||||
- `append { text }`
|
||||
- `prepend { text }`
|
||||
- `replace { old_text, new_text, all? }`
|
||||
- Provide the exact `LINE#ID` anchors from the latest `Read` result. Mismatched anchors are rejected and should be retried with the returned `retry with` anchors.
|
||||
- GPT-family models can use `apply_patch` as fallback when needed.
|
||||
|
||||
@@ -9,6 +9,7 @@ import DESCRIPTION from "./grep.txt"
|
||||
import { Instance } from "../project/instance"
|
||||
import path from "path"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { hashlineRef } from "./hashline"
|
||||
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
|
||||
@@ -129,7 +130,7 @@ export const GrepTool = Tool.define("grep", {
|
||||
}
|
||||
const truncatedLineText =
|
||||
match.lineText.length > MAX_LINE_LENGTH ? match.lineText.substring(0, MAX_LINE_LENGTH) + "..." : match.lineText
|
||||
outputLines.push(` Line ${match.lineNum}: ${truncatedLineText}`)
|
||||
outputLines.push(` ${hashlineRef(match.lineNum, match.lineText)}:${truncatedLineText}`)
|
||||
}
|
||||
|
||||
if (truncated) {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
- Searches file contents using regular expressions
|
||||
- Supports full regex syntax (eg. "log.*Error", "function\s+\w+", etc.)
|
||||
- Filter files by pattern with the include parameter (eg. "*.js", "*.{ts,tsx}")
|
||||
- Returns file paths and line numbers with at least one match sorted by modification time
|
||||
- Returns file paths with matching lines sorted by modification time
|
||||
- Output format uses hashline anchors: `N#ID:<content>`
|
||||
- Use this tool when you need to find files containing specific patterns
|
||||
- If you need to identify/count the number of matches within files, use the Bash tool with `rg` (ripgrep) directly. Do NOT use `grep`.
|
||||
- When you are doing an open-ended search that may require multiple rounds of globbing and grepping, use the Task tool instead
|
||||
|
||||
@@ -0,0 +1,646 @@
|
||||
// hashline autocorrect heuristics in this file are inspired by
|
||||
// https://github.com/can1357/oh-my-pi (mit license), adapted for opencode.
|
||||
|
||||
import z from "zod"
|
||||
|
||||
export const HASHLINE_ALPHABET = "ZPMQVRWSNKTXJBYH"
|
||||
|
||||
const HASHLINE_ID_LENGTH = 2
|
||||
const HASHLINE_ID_REGEX = new RegExp(`^[${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}}$`)
|
||||
const HASHLINE_REF_REGEX = new RegExp(`(\\d+)#([${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}})(?=$|\\s|:)`)
|
||||
const LOW_SIGNAL_CONTENT_RE = /^[^a-zA-Z0-9]+$/
|
||||
|
||||
type TextValue = string | string[]
|
||||
|
||||
export const HashlineText = z.union([z.string(), z.array(z.string())])
|
||||
|
||||
export const HashlineEdit = z.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("set_line"),
|
||||
line: z.string(),
|
||||
text: HashlineText,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("replace_lines"),
|
||||
start_line: z.string(),
|
||||
end_line: z.string(),
|
||||
text: HashlineText,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("insert_after"),
|
||||
line: z.string(),
|
||||
text: HashlineText,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("insert_before"),
|
||||
line: z.string(),
|
||||
text: HashlineText,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("insert_between"),
|
||||
after_line: z.string(),
|
||||
before_line: z.string(),
|
||||
text: HashlineText,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("append"),
|
||||
text: HashlineText,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("prepend"),
|
||||
text: HashlineText,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("replace"),
|
||||
old_text: z.string(),
|
||||
new_text: HashlineText,
|
||||
all: z.boolean().optional(),
|
||||
})
|
||||
.strict(),
|
||||
])
|
||||
|
||||
export type HashlineEdit = z.infer<typeof HashlineEdit>
|
||||
|
||||
function isLowSignalContent(normalized: string) {
|
||||
if (normalized.length === 0) return true
|
||||
if (normalized.length <= 2) return true
|
||||
return LOW_SIGNAL_CONTENT_RE.test(normalized)
|
||||
}
|
||||
|
||||
export function hashlineID(lineNumber: number, line: string): string {
|
||||
let normalized = line
|
||||
if (normalized.endsWith("\r")) normalized = normalized.slice(0, -1)
|
||||
normalized = normalized.replace(/\s+/g, "")
|
||||
const seed = isLowSignalContent(normalized) ? `${normalized}:${lineNumber}` : normalized
|
||||
const hash = Bun.hash.xxHash32(seed) & 0xff
|
||||
const high = (hash >>> 4) & 0x0f
|
||||
const low = hash & 0x0f
|
||||
return `${HASHLINE_ALPHABET[high]}${HASHLINE_ALPHABET[low]}`
|
||||
}
|
||||
|
||||
export function hashlineRef(lineNumber: number, line: string): string {
|
||||
return `${lineNumber}#${hashlineID(lineNumber, line)}`
|
||||
}
|
||||
|
||||
export function hashlineLine(lineNumber: number, line: string): string {
|
||||
return `${hashlineRef(lineNumber, line)}:${line}`
|
||||
}
|
||||
|
||||
export function parseHashlineRef(input: string, label: string) {
|
||||
const match = input.match(HASHLINE_REF_REGEX)
|
||||
if (!match) {
|
||||
throw new Error(`${label} must contain a LINE#ID reference`)
|
||||
}
|
||||
|
||||
const line = Number.parseInt(match[1], 10)
|
||||
if (!Number.isInteger(line) || line < 1) {
|
||||
throw new Error(`${label} has invalid line number: ${match[1]}`)
|
||||
}
|
||||
|
||||
const id = match[2]
|
||||
if (!HASHLINE_ID_REGEX.test(id)) {
|
||||
throw new Error(`${label} has invalid hash id: ${id}`)
|
||||
}
|
||||
|
||||
return {
|
||||
raw: `${line}#${id}`,
|
||||
line,
|
||||
id,
|
||||
}
|
||||
}
|
||||
|
||||
function toLines(text: TextValue) {
|
||||
if (Array.isArray(text)) return text
|
||||
return text.split(/\r?\n/)
|
||||
}
|
||||
|
||||
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[ZPMQVRWSNKTXJBYH]{2}:/
|
||||
const WRAPPER_PREFIX_RE = /^\s*(?:>>>|>>)\s?/
|
||||
|
||||
function stripByMajority(lines: string[], test: (line: string) => boolean, rewrite: (line: string) => string) {
|
||||
const nonEmpty = lines.filter((line) => line.length > 0)
|
||||
if (nonEmpty.length === 0) return lines
|
||||
|
||||
const matches = nonEmpty.filter(test).length
|
||||
if (matches === 0 || matches < nonEmpty.length * 0.5) return lines
|
||||
|
||||
return lines.map(rewrite)
|
||||
}
|
||||
|
||||
function stripNewLinePrefixes(lines: string[]) {
|
||||
const stripped = stripByMajority(
|
||||
lines,
|
||||
(line) => HASHLINE_PREFIX_RE.test(line),
|
||||
(line) => line.replace(HASHLINE_PREFIX_RE, ""),
|
||||
)
|
||||
return stripByMajority(
|
||||
stripped,
|
||||
(line) => WRAPPER_PREFIX_RE.test(line),
|
||||
(line) => line.replace(WRAPPER_PREFIX_RE, ""),
|
||||
)
|
||||
}
|
||||
|
||||
function equalsIgnoringWhitespace(a: string, b: string) {
|
||||
if (a === b) return true
|
||||
return a.replace(/\s+/g, "") === b.replace(/\s+/g, "")
|
||||
}
|
||||
|
||||
function leadingWhitespace(line: string) {
|
||||
const match = line.match(/^\s*/)
|
||||
if (!match) return ""
|
||||
return match[0]
|
||||
}
|
||||
|
||||
function restoreLeadingIndent(template: string, line: string) {
|
||||
if (line.length === 0) return line
|
||||
const templateIndent = leadingWhitespace(template)
|
||||
if (templateIndent.length === 0) return line
|
||||
const indent = leadingWhitespace(line)
|
||||
if (indent.length > 0) return line
|
||||
return templateIndent + line
|
||||
}
|
||||
|
||||
function restoreIndentForPairedReplacement(oldLines: string[], newLines: string[]) {
|
||||
if (oldLines.length !== newLines.length) return newLines
|
||||
let changed = false
|
||||
const out = new Array<string>(newLines.length)
|
||||
for (let idx = 0; idx < newLines.length; idx++) {
|
||||
const restored = restoreLeadingIndent(oldLines[idx], newLines[idx])
|
||||
out[idx] = restored
|
||||
if (restored !== newLines[idx]) changed = true
|
||||
}
|
||||
if (changed) return out
|
||||
return newLines
|
||||
}
|
||||
|
||||
function stripAllWhitespace(s: string) {
|
||||
return s.replace(/\s+/g, "")
|
||||
}
|
||||
|
||||
function restoreOldWrappedLines(oldLines: string[], newLines: string[]) {
|
||||
if (oldLines.length === 0 || newLines.length < 2) return newLines
|
||||
|
||||
const canonToOld = new Map<string, { line: string; count: number }>()
|
||||
for (const line of oldLines) {
|
||||
const canon = stripAllWhitespace(line)
|
||||
const bucket = canonToOld.get(canon)
|
||||
if (bucket) bucket.count++
|
||||
if (!bucket) canonToOld.set(canon, { line, count: 1 })
|
||||
}
|
||||
|
||||
const candidates: Array<{ start: number; len: number; replacement: string; canon: string }> = []
|
||||
for (let start = 0; start < newLines.length; start++) {
|
||||
for (let len = 2; len <= 10 && start + len <= newLines.length; len++) {
|
||||
const canonSpan = stripAllWhitespace(newLines.slice(start, start + len).join(""))
|
||||
const old = canonToOld.get(canonSpan)
|
||||
if (old && old.count === 1 && canonSpan.length >= 6) {
|
||||
candidates.push({
|
||||
start,
|
||||
len,
|
||||
replacement: old.line,
|
||||
canon: canonSpan,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if (candidates.length === 0) return newLines
|
||||
|
||||
const canonCounts = new Map<string, number>()
|
||||
for (const candidate of candidates) {
|
||||
canonCounts.set(candidate.canon, (canonCounts.get(candidate.canon) ?? 0) + 1)
|
||||
}
|
||||
|
||||
const unique = candidates.filter((candidate) => (canonCounts.get(candidate.canon) ?? 0) === 1)
|
||||
if (unique.length === 0) return newLines
|
||||
|
||||
unique.sort((a, b) => b.start - a.start)
|
||||
const out = [...newLines]
|
||||
for (const candidate of unique) {
|
||||
out.splice(candidate.start, candidate.len, candidate.replacement)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function stripInsertAnchorEchoAfter(anchorLine: string, lines: string[]) {
|
||||
if (lines.length <= 1) return lines
|
||||
if (equalsIgnoringWhitespace(lines[0], anchorLine)) return lines.slice(1)
|
||||
return lines
|
||||
}
|
||||
|
||||
function stripInsertAnchorEchoBefore(anchorLine: string, lines: string[]) {
|
||||
if (lines.length <= 1) return lines
|
||||
if (equalsIgnoringWhitespace(lines[lines.length - 1], anchorLine)) return lines.slice(0, -1)
|
||||
return lines
|
||||
}
|
||||
|
||||
function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, lines: string[]) {
|
||||
let out = lines
|
||||
if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) out = out.slice(1)
|
||||
if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) out = out.slice(0, -1)
|
||||
return out
|
||||
}
|
||||
|
||||
function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, lines: string[]) {
|
||||
const count = endLine - startLine + 1
|
||||
if (lines.length <= 1 || lines.length <= count) return lines
|
||||
|
||||
let out = lines
|
||||
const beforeIdx = startLine - 2
|
||||
if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], fileLines[beforeIdx])) {
|
||||
out = out.slice(1)
|
||||
}
|
||||
|
||||
const afterIdx = endLine
|
||||
if (
|
||||
afterIdx < fileLines.length &&
|
||||
out.length > 0 &&
|
||||
equalsIgnoringWhitespace(out[out.length - 1], fileLines[afterIdx])
|
||||
) {
|
||||
out = out.slice(0, -1)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
function ensureText(text: TextValue, label: string) {
|
||||
const value = Array.isArray(text) ? text.join("") : text
|
||||
if (value.length > 0) return
|
||||
throw new Error(`${label} must be non-empty`)
|
||||
}
|
||||
|
||||
function applyReplace(content: string, oldText: string, newText: TextValue, all = false) {
|
||||
if (oldText.length === 0) throw new Error("replace.old_text must be non-empty")
|
||||
|
||||
const next = toLines(newText).join("\n")
|
||||
const first = content.indexOf(oldText)
|
||||
if (first < 0) throw new Error(`replace.old_text not found: ${JSON.stringify(oldText)}`)
|
||||
|
||||
if (all) return content.replaceAll(oldText, next)
|
||||
|
||||
const last = content.lastIndexOf(oldText)
|
||||
if (first !== last) {
|
||||
throw new Error("replace.old_text matched multiple times. Set all=true or provide a more specific old_text.")
|
||||
}
|
||||
|
||||
return content.slice(0, first) + next + content.slice(first + oldText.length)
|
||||
}
|
||||
|
||||
function mismatchContext(lines: string[], line: number) {
|
||||
if (lines.length === 0) return ">>> (file is empty)"
|
||||
const start = Math.max(1, line - 1)
|
||||
const end = Math.min(lines.length, line + 1)
|
||||
return Array.from({ length: end - start + 1 }, (_, idx) => start + idx)
|
||||
.map((num) => {
|
||||
const marker = num === line ? ">>>" : " "
|
||||
return `${marker} ${hashlineLine(num, lines[num - 1])}`
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function mismatchSummary(lines: string[], mismatch: { expected: string; line: number }) {
|
||||
if (mismatch.line < 1 || mismatch.line > lines.length) {
|
||||
return `- expected ${mismatch.expected} -> line ${mismatch.line} is out of range (1-${Math.max(lines.length, 1)})`
|
||||
}
|
||||
return `- expected ${mismatch.expected} -> retry with ${hashlineRef(mismatch.line, lines[mismatch.line - 1])}`
|
||||
}
|
||||
|
||||
function throwMismatch(lines: string[], mismatches: Array<{ expected: string; line: number }>) {
|
||||
const seen = new Set<string>()
|
||||
const unique = mismatches.filter((mismatch) => {
|
||||
const key = `${mismatch.expected}:${mismatch.line}`
|
||||
if (seen.has(key)) return false
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
const preview = unique.slice(0, 2).map((mismatch) => mismatchSummary(lines, mismatch))
|
||||
const hidden = unique.length - preview.length
|
||||
const count = unique.length
|
||||
const linesOut = [
|
||||
`Hashline edit rejected: ${count} anchor mismatch${count === 1 ? "" : "es"}. Re-read the file and retry with the updated anchors below.`,
|
||||
...preview,
|
||||
...(hidden > 0 ? [`- ... and ${hidden} more mismatches`] : []),
|
||||
]
|
||||
|
||||
if (Bun.env.OPENCODE_HL_MISMATCH_DEBUG === "1") {
|
||||
const body = unique
|
||||
.map((mismatch) => {
|
||||
if (mismatch.line < 1 || mismatch.line > lines.length) {
|
||||
return [
|
||||
`>>> expected ${mismatch.expected}`,
|
||||
`>>> current line ${mismatch.line} is out of range (1-${Math.max(lines.length, 1)})`,
|
||||
].join("\n")
|
||||
}
|
||||
return [
|
||||
`>>> expected ${mismatch.expected}`,
|
||||
mismatchContext(lines, mismatch.line),
|
||||
`>>> retry with ${hashlineRef(mismatch.line, lines[mismatch.line - 1])}`,
|
||||
].join("\n")
|
||||
})
|
||||
.join("\n\n")
|
||||
linesOut.push("", body)
|
||||
}
|
||||
|
||||
throw new Error(linesOut.join("\n"))
|
||||
}
|
||||
|
||||
function validateAnchors(lines: string[], refs: Array<{ raw: string; line: number; id: string }>) {
|
||||
const mismatches = refs
|
||||
.filter((ref) => {
|
||||
if (ref.line < 1 || ref.line > lines.length) return true
|
||||
return hashlineID(ref.line, lines[ref.line - 1]) !== ref.id
|
||||
})
|
||||
.map((ref) => ({ expected: ref.raw, line: ref.line }))
|
||||
|
||||
if (mismatches.length > 0) throwMismatch(lines, mismatches)
|
||||
}
|
||||
|
||||
function splitLines(text: string) {
|
||||
if (text === "") {
|
||||
return {
|
||||
lines: [] as string[],
|
||||
trailing: false,
|
||||
}
|
||||
}
|
||||
|
||||
const trailing = text.endsWith("\n")
|
||||
const lines = text.split(/\r?\n/)
|
||||
if (trailing) lines.pop()
|
||||
|
||||
return { lines, trailing }
|
||||
}
|
||||
|
||||
export function parseHashlineContent(bytes: Buffer) {
|
||||
const raw = bytes.toString("utf8")
|
||||
let text = raw
|
||||
const bom = raw.startsWith("\uFEFF")
|
||||
if (bom) text = raw.slice(1)
|
||||
|
||||
const eol = text.includes("\r\n") ? "\r\n" : "\n"
|
||||
const { lines, trailing } = splitLines(text)
|
||||
|
||||
return {
|
||||
bom,
|
||||
eol,
|
||||
trailing,
|
||||
lines,
|
||||
text,
|
||||
raw,
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeHashlineContent(input: { lines: string[]; bom: boolean; eol: string; trailing: boolean }) {
|
||||
let text = input.lines.join(input.eol)
|
||||
if (input.trailing && input.lines.length > 0) text += input.eol
|
||||
if (input.bom) text = `\uFEFF${text}`
|
||||
return {
|
||||
text,
|
||||
bytes: Buffer.from(text, "utf8"),
|
||||
}
|
||||
}
|
||||
|
||||
type Splice = {
|
||||
start: number
|
||||
del: number
|
||||
text: string[]
|
||||
order: number
|
||||
kind: "set_line" | "replace_lines" | "insert_after" | "insert_before" | "insert_between" | "append" | "prepend"
|
||||
sortLine: number
|
||||
precedence: number
|
||||
startLine?: number
|
||||
endLine?: number
|
||||
anchorLine?: number
|
||||
beforeLine?: number
|
||||
afterLine?: number
|
||||
}
|
||||
|
||||
export function applyHashlineEdits(input: {
|
||||
lines: string[]
|
||||
trailing: boolean
|
||||
edits: HashlineEdit[]
|
||||
autocorrect?: boolean
|
||||
aggressiveAutocorrect?: boolean
|
||||
}) {
|
||||
const lines = [...input.lines]
|
||||
const originalLines = [...input.lines]
|
||||
let trailing = input.trailing
|
||||
const refs: Array<{ raw: string; line: number; id: string }> = []
|
||||
const replaceOps: Array<Extract<HashlineEdit, { type: "replace" }>> = []
|
||||
const ops: Splice[] = []
|
||||
const autocorrect = input.autocorrect ?? Bun.env.OPENCODE_HL_AUTOCORRECT === "1"
|
||||
const aggressiveAutocorrect = input.aggressiveAutocorrect ?? Bun.env.OPENCODE_HL_AUTOCORRECT === "1"
|
||||
const parseText = (text: TextValue) => {
|
||||
const next = toLines(text)
|
||||
if (!autocorrect) return next
|
||||
return stripNewLinePrefixes(next)
|
||||
}
|
||||
|
||||
input.edits.forEach((edit, order) => {
|
||||
if (edit.type === "replace") {
|
||||
replaceOps.push(edit)
|
||||
return
|
||||
}
|
||||
|
||||
if (edit.type === "append") {
|
||||
ensureText(edit.text, "append.text")
|
||||
ops.push({
|
||||
start: lines.length,
|
||||
del: 0,
|
||||
text: parseText(edit.text),
|
||||
order,
|
||||
kind: "append",
|
||||
sortLine: lines.length + 1,
|
||||
precedence: 1,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (edit.type === "prepend") {
|
||||
ensureText(edit.text, "prepend.text")
|
||||
ops.push({
|
||||
start: 0,
|
||||
del: 0,
|
||||
text: parseText(edit.text),
|
||||
order,
|
||||
kind: "prepend",
|
||||
sortLine: 0,
|
||||
precedence: 2,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (edit.type === "set_line") {
|
||||
const line = parseHashlineRef(edit.line, "set_line.line")
|
||||
refs.push(line)
|
||||
ops.push({
|
||||
start: line.line - 1,
|
||||
del: 1,
|
||||
text: parseText(edit.text),
|
||||
order,
|
||||
kind: "set_line",
|
||||
sortLine: line.line,
|
||||
precedence: 0,
|
||||
startLine: line.line,
|
||||
endLine: line.line,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (edit.type === "replace_lines") {
|
||||
const start = parseHashlineRef(edit.start_line, "replace_lines.start_line")
|
||||
const end = parseHashlineRef(edit.end_line, "replace_lines.end_line")
|
||||
refs.push(start)
|
||||
refs.push(end)
|
||||
|
||||
if (start.line > end.line) {
|
||||
throw new Error("replace_lines.start_line must be less than or equal to replace_lines.end_line")
|
||||
}
|
||||
|
||||
ops.push({
|
||||
start: start.line - 1,
|
||||
del: end.line - start.line + 1,
|
||||
text: parseText(edit.text),
|
||||
order,
|
||||
kind: "replace_lines",
|
||||
sortLine: end.line,
|
||||
precedence: 0,
|
||||
startLine: start.line,
|
||||
endLine: end.line,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (edit.type === "insert_after") {
|
||||
const line = parseHashlineRef(edit.line, "insert_after.line")
|
||||
ensureText(edit.text, "insert_after.text")
|
||||
refs.push(line)
|
||||
ops.push({
|
||||
start: line.line,
|
||||
del: 0,
|
||||
text: parseText(edit.text),
|
||||
order,
|
||||
kind: "insert_after",
|
||||
sortLine: line.line,
|
||||
precedence: 1,
|
||||
anchorLine: line.line,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (edit.type === "insert_before") {
|
||||
const line = parseHashlineRef(edit.line, "insert_before.line")
|
||||
ensureText(edit.text, "insert_before.text")
|
||||
refs.push(line)
|
||||
ops.push({
|
||||
start: line.line - 1,
|
||||
del: 0,
|
||||
text: parseText(edit.text),
|
||||
order,
|
||||
kind: "insert_before",
|
||||
sortLine: line.line,
|
||||
precedence: 2,
|
||||
anchorLine: line.line,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const after = parseHashlineRef(edit.after_line, "insert_between.after_line")
|
||||
const before = parseHashlineRef(edit.before_line, "insert_between.before_line")
|
||||
ensureText(edit.text, "insert_between.text")
|
||||
refs.push(after)
|
||||
refs.push(before)
|
||||
|
||||
if (after.line >= before.line) {
|
||||
throw new Error("insert_between.after_line must be less than insert_between.before_line")
|
||||
}
|
||||
|
||||
ops.push({
|
||||
start: after.line,
|
||||
del: 0,
|
||||
text: parseText(edit.text),
|
||||
order,
|
||||
kind: "insert_between",
|
||||
sortLine: before.line,
|
||||
precedence: 3,
|
||||
afterLine: after.line,
|
||||
beforeLine: before.line,
|
||||
})
|
||||
})
|
||||
|
||||
validateAnchors(lines, refs)
|
||||
|
||||
const sorted = [...ops].sort((a, b) => {
|
||||
if (a.sortLine !== b.sortLine) return b.sortLine - a.sortLine
|
||||
if (a.precedence !== b.precedence) return a.precedence - b.precedence
|
||||
return a.order - b.order
|
||||
})
|
||||
|
||||
sorted.forEach((op) => {
|
||||
if (op.start < 0 || op.start > lines.length) {
|
||||
throw new Error(`line index ${op.start + 1} is out of range`)
|
||||
}
|
||||
|
||||
let text = op.text
|
||||
if (autocorrect && aggressiveAutocorrect) {
|
||||
if (op.kind === "set_line" || op.kind === "replace_lines") {
|
||||
const start = op.startLine ?? op.start + 1
|
||||
const end = op.endLine ?? start + op.del - 1
|
||||
const old = originalLines.slice(start - 1, end)
|
||||
text = stripRangeBoundaryEcho(originalLines, start, end, text)
|
||||
text = restoreOldWrappedLines(old, text)
|
||||
text = restoreIndentForPairedReplacement(old, text)
|
||||
}
|
||||
|
||||
if ((op.kind === "insert_after" || op.kind === "append") && op.anchorLine) {
|
||||
text = stripInsertAnchorEchoAfter(originalLines[op.anchorLine - 1], text)
|
||||
}
|
||||
|
||||
if ((op.kind === "insert_before" || op.kind === "prepend") && op.anchorLine) {
|
||||
text = stripInsertAnchorEchoBefore(originalLines[op.anchorLine - 1], text)
|
||||
}
|
||||
|
||||
if (op.kind === "insert_between" && op.afterLine && op.beforeLine) {
|
||||
text = stripInsertBoundaryEcho(originalLines[op.afterLine - 1], originalLines[op.beforeLine - 1], text)
|
||||
}
|
||||
}
|
||||
|
||||
lines.splice(op.start, op.del, ...text)
|
||||
})
|
||||
|
||||
if (replaceOps.length > 0) {
|
||||
const content = `${lines.join("\n")}${trailing && lines.length > 0 ? "\n" : ""}`
|
||||
const replaced = replaceOps.reduce(
|
||||
(acc, op) =>
|
||||
applyReplace(acc, op.old_text, autocorrect ? stripNewLinePrefixes(toLines(op.new_text)) : op.new_text, op.all),
|
||||
content,
|
||||
)
|
||||
const split = splitLines(replaced)
|
||||
lines.splice(0, lines.length, ...split.lines)
|
||||
trailing = split.trailing
|
||||
}
|
||||
|
||||
return {
|
||||
lines,
|
||||
trailing,
|
||||
}
|
||||
}
|
||||
|
||||
export function hashlineOnlyCreates(edits: HashlineEdit[]) {
|
||||
return edits.every((edit) => edit.type === "append" || edit.type === "prepend")
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { EditTool } from "./edit"
|
||||
import { WriteTool } from "./write"
|
||||
import DESCRIPTION from "./multiedit.txt"
|
||||
import path from "path"
|
||||
import { Instance } from "../project/instance"
|
||||
@@ -22,17 +23,32 @@ export const MultiEditTool = Tool.define("multiedit", {
|
||||
}),
|
||||
async execute(params, ctx) {
|
||||
const tool = await EditTool.init()
|
||||
const write = await WriteTool.init()
|
||||
const results = []
|
||||
for (const [, edit] of params.edits.entries()) {
|
||||
const result = await tool.execute(
|
||||
{
|
||||
filePath: params.filePath,
|
||||
oldString: edit.oldString,
|
||||
newString: edit.newString,
|
||||
replaceAll: edit.replaceAll,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
for (const edit of params.edits) {
|
||||
const result =
|
||||
edit.oldString === ""
|
||||
? await write.execute(
|
||||
{
|
||||
filePath: params.filePath,
|
||||
content: edit.newString,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
: await tool.execute(
|
||||
{
|
||||
filePath: params.filePath,
|
||||
edits: [
|
||||
{
|
||||
type: "replace",
|
||||
old_text: edit.oldString,
|
||||
new_text: edit.newString,
|
||||
all: edit.replaceAll,
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
results.push(result)
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Instance } from "../project/instance"
|
||||
import { assertExternalDirectory } from "./external-directory"
|
||||
import { InstructionPrompt } from "../session/instruction"
|
||||
import { Filesystem } from "../util/filesystem"
|
||||
import { hashlineRef } from "./hashline"
|
||||
|
||||
const DEFAULT_READ_LIMIT = 2000
|
||||
const MAX_LINE_LENGTH = 2000
|
||||
@@ -156,6 +157,7 @@ export const ReadTool = Tool.define("read", {
|
||||
const offset = params.offset ?? 1
|
||||
const start = offset - 1
|
||||
const raw: string[] = []
|
||||
const full: string[] = []
|
||||
let bytes = 0
|
||||
let lines = 0
|
||||
let truncatedByBytes = false
|
||||
@@ -179,6 +181,7 @@ export const ReadTool = Tool.define("read", {
|
||||
}
|
||||
|
||||
raw.push(line)
|
||||
full.push(text)
|
||||
bytes += size
|
||||
}
|
||||
} finally {
|
||||
@@ -191,7 +194,8 @@ export const ReadTool = Tool.define("read", {
|
||||
}
|
||||
|
||||
const content = raw.map((line, index) => {
|
||||
return `${index + offset}: ${line}`
|
||||
const lineNumber = index + offset
|
||||
return `${hashlineRef(lineNumber, full[index])}:${line}`
|
||||
})
|
||||
const preview = raw.slice(0, 20).join("\n")
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ Usage:
|
||||
- To read later sections, call this tool again with a larger offset.
|
||||
- Use the grep tool to find specific content in large files or files with long lines.
|
||||
- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.
|
||||
- Contents are returned with each line prefixed by its line number as `<line>: <content>`. For example, if a file has contents "foo\n", you will receive "1: foo\n". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.
|
||||
- Contents are returned with a line prefix.
|
||||
- Default format: `LINE#ID:<content>` (example: `1#AB:foo`). Use these anchors for hashline edits.
|
||||
- For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.
|
||||
- Any line longer than 2000 characters is truncated.
|
||||
- Call this tool in parallel when you know there are multiple files you want to read.
|
||||
- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.
|
||||
|
||||
@@ -136,6 +136,8 @@ export namespace ToolRegistry {
|
||||
agent?: Agent.Info,
|
||||
) {
|
||||
const tools = await all()
|
||||
const usePatch =
|
||||
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
|
||||
const result = await Promise.all(
|
||||
tools
|
||||
.filter((t) => {
|
||||
@@ -144,11 +146,7 @@ export namespace ToolRegistry {
|
||||
return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
|
||||
}
|
||||
|
||||
// use apply tool in same format as codex
|
||||
const usePatch =
|
||||
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
|
||||
if (t.id === "apply_patch") return usePatch
|
||||
if (t.id === "edit" || t.id === "write") return !usePatch
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createHash } from "crypto"
|
||||
|
||||
export namespace Hash {
|
||||
export function fast(input: string | Buffer): string {
|
||||
return createHash("sha1").update(input).digest("hex")
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,48 @@ test("loads JSONC config file", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("parses experimental.hashline_autocorrect", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
experimental: {
|
||||
hashline_autocorrect: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.experimental?.hashline_autocorrect).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("ignores removed experimental.hashline_edit", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
experimental: {
|
||||
hashline_edit: true,
|
||||
hashline_autocorrect: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.experimental?.hashline_autocorrect).toBe(true)
|
||||
expect((config.experimental as Record<string, unknown>)?.hashline_edit).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("merges multiple config files with correct precedence", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { afterAll } from "bun:test"
|
||||
|
||||
// Set XDG env vars FIRST, before any src/ imports
|
||||
@@ -15,7 +16,7 @@ afterAll(async () => {
|
||||
typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY"
|
||||
const rm = async (left: number): Promise<void> => {
|
||||
Bun.gc(true)
|
||||
await Bun.sleep(100)
|
||||
await sleep(100)
|
||||
return fs.rm(dir, { recursive: true, force: true }).catch((error) => {
|
||||
if (!busy(error)) throw error
|
||||
if (left <= 1) throw error
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Pty } from "../../src/pty"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
|
||||
describe("pty", () => {
|
||||
test("does not leak output when websocket objects are reused", async () => {
|
||||
@@ -43,7 +44,7 @@ describe("pty", () => {
|
||||
|
||||
// Output from a must never show up in b.
|
||||
Pty.write(a.id, "AAA\n")
|
||||
await Bun.sleep(100)
|
||||
await sleep(100)
|
||||
|
||||
expect(outB.join("")).not.toContain("AAA")
|
||||
} finally {
|
||||
@@ -88,7 +89,7 @@ describe("pty", () => {
|
||||
}
|
||||
|
||||
Pty.write(a.id, "AAA\n")
|
||||
await Bun.sleep(100)
|
||||
await sleep(100)
|
||||
|
||||
expect(outB.join("")).not.toContain("AAA")
|
||||
} finally {
|
||||
@@ -128,7 +129,7 @@ describe("pty", () => {
|
||||
ctx.connId = 2
|
||||
|
||||
Pty.write(a.id, "AAA\n")
|
||||
await Bun.sleep(100)
|
||||
await sleep(100)
|
||||
|
||||
expect(out.join("")).toContain("AAA")
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { NamedError } from "@opencode-ai/util/error"
|
||||
import { APICallError } from "ai"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { SessionRetry } from "../../src/session/retry"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
|
||||
@@ -135,7 +136,7 @@ describe("session.message-v2.fromError", () => {
|
||||
new ReadableStream({
|
||||
async pull(controller) {
|
||||
controller.enqueue("Hello,")
|
||||
await Bun.sleep(10000)
|
||||
await sleep(10000)
|
||||
controller.enqueue(" World!")
|
||||
controller.close()
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import { EditTool } from "../../src/tool/edit"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { FileTime } from "../../src/file/time"
|
||||
import { hashlineLine, hashlineRef } from "../../src/tool/hashline"
|
||||
|
||||
const ctx = {
|
||||
sessionID: "test-edit-session",
|
||||
@@ -18,479 +19,506 @@ const ctx = {
|
||||
}
|
||||
|
||||
describe("tool.edit", () => {
|
||||
describe("creating new files", () => {
|
||||
test("creates new file when oldString is empty", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "newfile.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "new content",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.metadata.diff).toContain("new content")
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("new content")
|
||||
},
|
||||
})
|
||||
test("rejects legacy payloads", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8")
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
|
||||
test("creates new file with nested directories", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "nested file",
|
||||
},
|
||||
oldString: "b",
|
||||
newString: "B",
|
||||
} as any,
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("nested file")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("emits add event for new files", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "new.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { File } = await import("../../src/file")
|
||||
const { FileWatcher } = await import("../../src/file/watcher")
|
||||
|
||||
const events: string[] = []
|
||||
const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
|
||||
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "content",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(events).toContain("edited")
|
||||
expect(events).toContain("updated")
|
||||
unsubEdited()
|
||||
unsubUpdated()
|
||||
},
|
||||
})
|
||||
),
|
||||
).rejects.toThrow("Legacy edit payload has been removed")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe("editing existing files", () => {
|
||||
test("replaces text in existing file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "existing.txt")
|
||||
await fs.writeFile(filepath, "old content here", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old content",
|
||||
newString: "new content",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.output).toContain("Edit applied successfully")
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("new content here")
|
||||
},
|
||||
})
|
||||
test("replaces a single line", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8")
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
|
||||
test("throws error when file does not exist", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "nonexistent.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
const edit = await EditTool.init()
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
type: "set_line",
|
||||
line: hashlineRef(2, "b"),
|
||||
text: "B",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("not found")
|
||||
},
|
||||
})
|
||||
})
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
test("throws error when oldString equals newString", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "same",
|
||||
newString: "same",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("identical")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when oldString not found in file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "actual content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "not in file",
|
||||
newString: "replacement",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when file was not read first (FileTime)", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "content",
|
||||
newString: "modified",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("You must read file")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when file has been modified since read", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// Read first
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
// Wait a bit to ensure different timestamps
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Simulate external modification
|
||||
await fs.writeFile(filepath, "modified externally", "utf-8")
|
||||
|
||||
// Try to edit with the new content
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "modified externally",
|
||||
newString: "edited",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("modified since it was last read")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("replaces all occurrences with replaceAll option", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "foo",
|
||||
newString: "qux",
|
||||
replaceAll: true,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("qux bar qux baz qux")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("emits change event for existing files", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "original", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { File } = await import("../../src/file")
|
||||
const { FileWatcher } = await import("../../src/file/watcher")
|
||||
|
||||
const events: string[] = []
|
||||
const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
|
||||
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "original",
|
||||
newString: "modified",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(events).toContain("edited")
|
||||
expect(events).toContain("updated")
|
||||
unsubEdited()
|
||||
unsubUpdated()
|
||||
},
|
||||
})
|
||||
expect(await fs.readFile(filepath, "utf-8")).toBe("a\nB\nc")
|
||||
expect(result.metadata.edit_mode).toBe("hashline")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
test("handles multiline replacements", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "line2",
|
||||
newString: "new line 2\nextra line",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("line1\nnew line 2\nextra line\nline3")
|
||||
},
|
||||
})
|
||||
test("supports replace operations with all=true", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await fs.writeFile(path.join(dir, "file.txt"), "foo bar foo baz foo", "utf-8")
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
|
||||
test("handles CRLF line endings", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toBe("line1\r\nnew\r\nline3")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("throws error when oldString equals newString", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "",
|
||||
newString: "",
|
||||
type: "replace",
|
||||
old_text: "foo",
|
||||
new_text: "qux",
|
||||
all: true,
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("identical")
|
||||
},
|
||||
})
|
||||
})
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
test("throws error when path is directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const dirpath = path.join(tmp.path, "adir")
|
||||
await fs.mkdir(dirpath)
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, dirpath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: dirpath,
|
||||
oldString: "old",
|
||||
newString: "new",
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("directory")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("tracks file diff statistics", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "line2",
|
||||
newString: "new line a\nnew line b",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.metadata.filediff).toBeDefined()
|
||||
expect(result.metadata.filediff.file).toBe(filepath)
|
||||
expect(result.metadata.filediff.additions).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
expect(await fs.readFile(filepath, "utf-8")).toBe("qux bar qux baz qux")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
describe("concurrent editing", () => {
|
||||
test("serializes concurrent edits to same file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
await fs.writeFile(filepath, "0", "utf-8")
|
||||
test("supports range replacement and insert modes", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc\nd", "utf-8")
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
type: "replace_lines",
|
||||
start_line: hashlineRef(2, "b"),
|
||||
end_line: hashlineRef(3, "c"),
|
||||
text: ["B", "C"],
|
||||
},
|
||||
{
|
||||
type: "insert_before",
|
||||
line: hashlineRef(2, "b"),
|
||||
text: "x",
|
||||
},
|
||||
{
|
||||
type: "insert_after",
|
||||
line: hashlineRef(3, "c"),
|
||||
text: "y",
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const edit = await EditTool.init()
|
||||
expect(await fs.readFile(filepath, "utf-8")).toBe("a\nx\nB\nC\ny\nd")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Two concurrent edits
|
||||
const promise1 = edit.execute(
|
||||
test("creates missing files from append and prepend operations", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "created.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
type: "prepend",
|
||||
text: "start",
|
||||
},
|
||||
{
|
||||
type: "append",
|
||||
text: "end",
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(await fs.readFile(filepath, "utf-8")).toBe("start\nend")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("requires a prior read for existing files", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await fs.writeFile(path.join(dir, "file.txt"), "content", "utf-8")
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "0",
|
||||
newString: "1",
|
||||
edits: [
|
||||
{
|
||||
type: "set_line",
|
||||
line: hashlineRef(1, "content"),
|
||||
text: "changed",
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
),
|
||||
).rejects.toThrow("You must read file")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Need to read again since FileTime tracks per-session
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
test("rejects files modified since read", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await fs.writeFile(path.join(dir, "file.txt"), "original", "utf-8")
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
|
||||
const promise2 = edit.execute(
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
await fs.writeFile(filepath, "external", "utf-8")
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
oldString: "0",
|
||||
newString: "2",
|
||||
edits: [
|
||||
{
|
||||
type: "set_line",
|
||||
line: hashlineRef(1, "original"),
|
||||
text: "changed",
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
),
|
||||
).rejects.toThrow("modified since it was last read")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Both should complete without error (though one might fail due to content mismatch)
|
||||
const results = await Promise.allSettled([promise1, promise2])
|
||||
expect(results.some((r) => r.status === "fulfilled")).toBe(true)
|
||||
test("rejects missing files for non-append and non-prepend edits", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "missing.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
type: "replace",
|
||||
old_text: "a",
|
||||
new_text: "b",
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("Missing file can only be created")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects directories", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await fs.mkdir(path.join(dir, "adir"))
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "adir")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
await expect(
|
||||
edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
type: "append",
|
||||
text: "x",
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
),
|
||||
).rejects.toThrow("directory")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("tracks file diff statistics", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await fs.writeFile(path.join(dir, "file.txt"), "line1\nline2\nline3", "utf-8")
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
const edit = await EditTool.init()
|
||||
const result = await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
type: "replace_lines",
|
||||
start_line: hashlineRef(2, "line2"),
|
||||
end_line: hashlineRef(2, "line2"),
|
||||
text: ["new line a", "new line b"],
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(result.metadata.filediff).toBeDefined()
|
||||
expect(result.metadata.filediff.file).toBe(filepath)
|
||||
expect(result.metadata.filediff.additions).toBeGreaterThan(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("emits change events for existing files", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await fs.writeFile(path.join(dir, "file.txt"), "a\nb", "utf-8")
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { File } = await import("../../src/file")
|
||||
const { FileWatcher } = await import("../../src/file/watcher")
|
||||
|
||||
const events: string[] = []
|
||||
const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
|
||||
const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
|
||||
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
type: "set_line",
|
||||
line: hashlineRef(2, "b"),
|
||||
text: "B",
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(events).toContain("edited")
|
||||
expect(events).toContain("updated")
|
||||
unsubEdited()
|
||||
unsubUpdated()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("applies hashline autocorrect prefixes through config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
experimental: {
|
||||
hashline_autocorrect: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
init: async (dir) => {
|
||||
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8")
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
const edit = await EditTool.init()
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
type: "set_line",
|
||||
line: hashlineRef(2, "b"),
|
||||
text: hashlineLine(2, "B"),
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(await fs.readFile(filepath, "utf-8")).toBe("a\nB\nc")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("supports delete and rename flows", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await fs.writeFile(path.join(dir, "src.txt"), "a\nb", "utf-8")
|
||||
await fs.writeFile(path.join(dir, "delete.txt"), "delete me", "utf-8")
|
||||
},
|
||||
})
|
||||
const source = path.join(tmp.path, "src.txt")
|
||||
const target = path.join(tmp.path, "renamed.txt")
|
||||
const doomed = path.join(tmp.path, "delete.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const edit = await EditTool.init()
|
||||
|
||||
FileTime.read(ctx.sessionID, source)
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: source,
|
||||
rename: target,
|
||||
edits: [
|
||||
{
|
||||
type: "set_line",
|
||||
line: hashlineRef(2, "b"),
|
||||
text: "B",
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
expect(await fs.readFile(target, "utf-8")).toBe("a\nB")
|
||||
await expect(fs.stat(source)).rejects.toThrow()
|
||||
|
||||
FileTime.read(ctx.sessionID, doomed)
|
||||
await edit.execute(
|
||||
{
|
||||
filePath: doomed,
|
||||
delete: true,
|
||||
edits: [],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
await expect(fs.stat(doomed)).rejects.toThrow()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("serializes concurrent edits to the same file", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await fs.writeFile(path.join(dir, "file.txt"), "0", "utf-8")
|
||||
},
|
||||
})
|
||||
const filepath = path.join(tmp.path, "file.txt")
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
const edit = await EditTool.init()
|
||||
|
||||
const first = edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
type: "set_line",
|
||||
line: hashlineRef(1, "0"),
|
||||
text: "1",
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
FileTime.read(ctx.sessionID, filepath)
|
||||
const second = edit.execute(
|
||||
{
|
||||
filePath: filepath,
|
||||
edits: [
|
||||
{
|
||||
type: "set_line",
|
||||
line: hashlineRef(1, "0"),
|
||||
text: "2",
|
||||
},
|
||||
],
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
const results = await Promise.allSettled([first, second])
|
||||
expect(results.some((result) => result.status === "fulfilled")).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,6 +37,29 @@ describe("tool.grep", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("emits hashline anchors by default", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "test.txt"), "alpha\nbeta")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const grep = await GrepTool.init()
|
||||
const result = await grep.execute(
|
||||
{
|
||||
pattern: "alpha",
|
||||
path: tmp.path,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.output).toMatch(/\b1#[ZPMQVRWSNKTXJBYH]{2}:alpha\b/)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("no matches returns correct output", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -61,10 +84,8 @@ describe("tool.grep", () => {
|
||||
})
|
||||
|
||||
test("handles CRLF line endings in output", async () => {
|
||||
// This test verifies the regex split handles both \n and \r\n
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
// Create a test file with content
|
||||
await Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3")
|
||||
},
|
||||
})
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { applyHashlineEdits, hashlineID, hashlineLine, hashlineRef, parseHashlineRef } from "../../src/tool/hashline"
|
||||
|
||||
function swapID(ref: string) {
|
||||
const [line, id] = ref.split("#")
|
||||
const next = id[0] === "Z" ? `P${id[1]}` : `Z${id[1]}`
|
||||
return `${line}#${next}`
|
||||
}
|
||||
|
||||
function errorMessage(run: () => void) {
|
||||
try {
|
||||
run()
|
||||
return ""
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
}
|
||||
|
||||
describe("tool.hashline", () => {
|
||||
test("hash computation is stable and 2-char alphabet encoded", () => {
|
||||
const a = hashlineID(1, " const x = 1")
|
||||
const b = hashlineID(1, "constx=1")
|
||||
const c = hashlineID(99, "constx=1")
|
||||
expect(a).toBe(b)
|
||||
expect(a).toBe(c)
|
||||
expect(a).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/)
|
||||
})
|
||||
|
||||
test("low-signal lines mix line index into hash id", () => {
|
||||
const a = hashlineID(1, "")
|
||||
const b = hashlineID(2, "")
|
||||
const c = hashlineID(1, "{}")
|
||||
const d = hashlineID(2, "{}")
|
||||
expect(a).not.toBe(b)
|
||||
expect(c).not.toBe(d)
|
||||
})
|
||||
|
||||
test("autocorrect strips copied hashline prefixes when enabled", () => {
|
||||
const old = Bun.env.OPENCODE_HL_AUTOCORRECT
|
||||
Bun.env.OPENCODE_HL_AUTOCORRECT = "1"
|
||||
try {
|
||||
const result = applyHashlineEdits({
|
||||
lines: ["a"],
|
||||
trailing: false,
|
||||
edits: [
|
||||
{
|
||||
type: "set_line",
|
||||
line: hashlineRef(1, "a"),
|
||||
text: hashlineLine(1, "a"),
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(result.lines).toEqual(["a"])
|
||||
} finally {
|
||||
if (old === undefined) delete Bun.env.OPENCODE_HL_AUTOCORRECT
|
||||
else Bun.env.OPENCODE_HL_AUTOCORRECT = old
|
||||
}
|
||||
})
|
||||
|
||||
test("default autocorrect does not rewrite non-prefix content", () => {
|
||||
const result = applyHashlineEdits({
|
||||
lines: ["a"],
|
||||
trailing: false,
|
||||
edits: [
|
||||
{
|
||||
type: "set_line",
|
||||
line: hashlineRef(1, "a"),
|
||||
text: "+a",
|
||||
},
|
||||
],
|
||||
autocorrect: true,
|
||||
aggressiveAutocorrect: false,
|
||||
})
|
||||
expect(result.lines).toEqual(["+a"])
|
||||
})
|
||||
|
||||
test("parses strict LINE#ID references with tolerant extraction", () => {
|
||||
const ref = parseHashlineRef(">>> 12#ZP:const value = 1", "line")
|
||||
expect(ref.line).toBe(12)
|
||||
expect(ref.id).toBe("ZP")
|
||||
expect(ref.raw).toBe("12#ZP")
|
||||
|
||||
expect(() => parseHashlineRef("12#ab", "line")).toThrow("LINE#ID")
|
||||
})
|
||||
|
||||
test("reports compact mismatch errors with retry anchors", () => {
|
||||
const lines = ["alpha", "beta", "gamma"]
|
||||
const wrong = swapID(hashlineRef(2, lines[1]))
|
||||
|
||||
const message = errorMessage(() =>
|
||||
applyHashlineEdits({
|
||||
lines,
|
||||
trailing: false,
|
||||
edits: [
|
||||
{
|
||||
type: "set_line",
|
||||
line: wrong,
|
||||
text: "BETA",
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(message).toContain("anchor mismatch")
|
||||
expect(message).toContain("retry with")
|
||||
expect(message).not.toContain(">>>")
|
||||
expect(message.length).toBeLessThan(260)
|
||||
})
|
||||
|
||||
test("applies batched line edits bottom-up for stable results", () => {
|
||||
const lines = ["a", "b", "c", "d"]
|
||||
const one = hashlineRef(1, lines[0])
|
||||
const two = hashlineRef(2, lines[1])
|
||||
const three = hashlineRef(3, lines[2])
|
||||
const four = hashlineRef(4, lines[3])
|
||||
|
||||
const result = applyHashlineEdits({
|
||||
lines,
|
||||
trailing: false,
|
||||
edits: [
|
||||
{
|
||||
type: "replace_lines",
|
||||
start_line: two,
|
||||
end_line: three,
|
||||
text: ["B", "C"],
|
||||
},
|
||||
{
|
||||
type: "insert_after",
|
||||
line: one,
|
||||
text: "A1",
|
||||
},
|
||||
{
|
||||
type: "set_line",
|
||||
line: four,
|
||||
text: "D",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.lines).toEqual(["a", "A1", "B", "C", "D"])
|
||||
})
|
||||
|
||||
test("orders append and prepend deterministically on empty files", () => {
|
||||
const result = applyHashlineEdits({
|
||||
lines: [],
|
||||
trailing: false,
|
||||
edits: [
|
||||
{
|
||||
type: "append",
|
||||
text: "end",
|
||||
},
|
||||
{
|
||||
type: "prepend",
|
||||
text: "start",
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.lines).toEqual(["start", "end"])
|
||||
})
|
||||
|
||||
test("validates ranges, between constraints, and non-empty insert text", () => {
|
||||
const lines = ["a", "b", "c"]
|
||||
const one = hashlineRef(1, lines[0])
|
||||
const two = hashlineRef(2, lines[1])
|
||||
|
||||
expect(() =>
|
||||
applyHashlineEdits({
|
||||
lines,
|
||||
trailing: false,
|
||||
edits: [
|
||||
{
|
||||
type: "replace_lines",
|
||||
start_line: two,
|
||||
end_line: one,
|
||||
text: "x",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow("start_line")
|
||||
|
||||
expect(() =>
|
||||
applyHashlineEdits({
|
||||
lines,
|
||||
trailing: false,
|
||||
edits: [
|
||||
{
|
||||
type: "insert_between",
|
||||
after_line: two,
|
||||
before_line: one,
|
||||
text: "x",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow("insert_between.after_line")
|
||||
|
||||
expect(() =>
|
||||
applyHashlineEdits({
|
||||
lines,
|
||||
trailing: false,
|
||||
edits: [
|
||||
{
|
||||
type: "append",
|
||||
text: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow("append.text")
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { PermissionNext } from "../../src/permission/next"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { hashlineLine } from "../../src/tool/hashline"
|
||||
|
||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||
|
||||
@@ -269,10 +270,10 @@ describe("tool.read truncation", () => {
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx)
|
||||
expect(result.output).toContain("10: line10")
|
||||
expect(result.output).toContain("14: line14")
|
||||
expect(result.output).not.toContain("9: line10")
|
||||
expect(result.output).not.toContain("15: line15")
|
||||
expect(result.output).toContain(hashlineLine(10, "line10"))
|
||||
expect(result.output).toContain(hashlineLine(14, "line14"))
|
||||
expect(result.output).not.toContain(hashlineLine(9, "line9"))
|
||||
expect(result.output).not.toContain(hashlineLine(15, "line15"))
|
||||
expect(result.output).toContain("line10")
|
||||
expect(result.output).toContain("line14")
|
||||
expect(result.output).not.toContain("line0")
|
||||
@@ -443,6 +444,27 @@ root_type Monster;`
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool.read hashline output", () => {
|
||||
test("returns LINE#ID prefixes by default", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "hashline.txt"), "foo\nbar")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const read = await ReadTool.init()
|
||||
const result = await read.execute({ filePath: path.join(tmp.path, "hashline.txt") }, ctx)
|
||||
expect(result.output).toContain(hashlineLine(1, "foo"))
|
||||
expect(result.output).toContain(hashlineLine(2, "bar"))
|
||||
expect(result.output).not.toContain("1: foo")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool.read loaded instructions", () => {
|
||||
test("loads AGENTS.md from parent directory and includes in metadata", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { ToolRegistry } from "../../src/tool/registry"
|
||||
|
||||
describe("tool.registry hashline routing", () => {
|
||||
test("keeps edit and apply_patch for GPT models", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const tools = await ToolRegistry.tools({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
})
|
||||
const ids = tools.map((tool) => tool.id)
|
||||
expect(ids).toContain("edit")
|
||||
expect(ids).toContain("write")
|
||||
expect(ids).toContain("apply_patch")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps edit and removes apply_patch for non-GPT models", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const tools = await ToolRegistry.tools({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-3-7-sonnet",
|
||||
})
|
||||
const ids = tools.map((tool) => tool.id)
|
||||
expect(ids).toContain("edit")
|
||||
expect(ids).toContain("write")
|
||||
expect(ids).not.toContain("apply_patch")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -1751,11 +1751,11 @@ ToolRegistry.register({
|
||||
mode="diff"
|
||||
before={{
|
||||
name: props.metadata?.filediff?.file || props.input.filePath,
|
||||
contents: props.metadata?.filediff?.before || props.input.oldString,
|
||||
contents: props.metadata?.filediff?.before ?? props.input.oldString,
|
||||
}}
|
||||
after={{
|
||||
name: props.metadata?.filediff?.file || props.input.filePath,
|
||||
contents: props.metadata?.filediff?.after || props.input.newString,
|
||||
contents: props.metadata?.filediff?.after ?? props.input.newString,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -59,6 +59,7 @@ OpenCode Zen هو بوابة للذكاء الاصطناعي تتيح لك ال
|
||||
|
||||
| النموذج | معرّف النموذج | نقطة النهاية | حزمة AI SDK |
|
||||
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -141,6 +142,7 @@ https://opencode.ai/zen/v1/models
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
@@ -184,6 +186,19 @@ https://opencode.ai/zen/v1/models
|
||||
|
||||
---
|
||||
|
||||
### نماذج مهملة
|
||||
|
||||
| النموذج | تاريخ الإيقاف |
|
||||
| ---------------- | ------------- |
|
||||
| Qwen3 Coder 480B | 6 فبراير 2026 |
|
||||
| Kimi K2 Thinking | 6 مارس 2026 |
|
||||
| Kimi K2 | 6 مارس 2026 |
|
||||
| MiniMax M2.1 | 15 مارس 2026 |
|
||||
| GLM 4.7 | 15 مارس 2026 |
|
||||
| GLM 4.6 | 15 مارس 2026 |
|
||||
|
||||
---
|
||||
|
||||
## الخصوصية
|
||||
|
||||
تتم استضافة جميع نماذجنا في الولايات المتحدة. يلتزم مزوّدونا بسياسة عدم الاحتفاظ بالبيانات (zero-retention) ولا يستخدمون بياناتك لتدريب النماذج، مع الاستثناءات التالية:
|
||||
|
||||
@@ -55,6 +55,7 @@ Nasim modelima mozete pristupiti i preko sljedecih API endpointa.
|
||||
|
||||
| Model | Model ID | Endpoint | AI SDK Package |
|
||||
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -136,6 +137,7 @@ Podrzavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**.
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
@@ -178,6 +180,19 @@ Na primjer, ako postavite mjesecni limit na $20, Zen nece potrositi vise od $20
|
||||
|
||||
---
|
||||
|
||||
### Zastarjeli modeli
|
||||
|
||||
| Model | Datum ukidanja |
|
||||
| ---------------- | -------------- |
|
||||
| Qwen3 Coder 480B | 6. feb. 2026. |
|
||||
| Kimi K2 Thinking | 6. mart 2026. |
|
||||
| Kimi K2 | 6. mart 2026. |
|
||||
| MiniMax M2.1 | 15. mart 2026. |
|
||||
| GLM 4.7 | 15. mart 2026. |
|
||||
| GLM 4.6 | 15. mart 2026. |
|
||||
|
||||
---
|
||||
|
||||
## Privatnost
|
||||
|
||||
Svi nasi modeli su hostovani u SAD-u. Provajderi prate zero-retention politiku i ne koriste vase podatke za treniranje modela, uz sljedece izuzetke:
|
||||
|
||||
@@ -64,6 +64,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints.
|
||||
|
||||
| Model | Model ID | Endpoint | AI SDK Pakke |
|
||||
| ------------------- | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -147,6 +148,7 @@ Vi støtter en pay-as-you-go-model. Nedenfor er priserne **per 1 million tokens*
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2,00 | $12,00 | $0,20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4,00 | $18,00 | $0,40 | - |
|
||||
| Gemini 3 Flash | $0,50 | $3,00 | $0,05 | - |
|
||||
| GPT 5.4 | $2,50 | $15,00 | $0,25 | - |
|
||||
| GPT 5.3 Codex | $1,75 | $14,00 | $0,175 | - |
|
||||
| GPT 5.2 | $1,75 | $14,00 | $0,175 | - |
|
||||
| GPT 5.2 Codex | $1,75 | $14,00 | $0,175 | - |
|
||||
@@ -192,6 +194,19 @@ at opkræve dig mere end $20, hvis din saldo går under $5.
|
||||
|
||||
---
|
||||
|
||||
### Udfasede modeller
|
||||
|
||||
| Model | Udfasningsdato |
|
||||
| ---------------- | -------------- |
|
||||
| Qwen3-koder 480B | 6. feb. 2026 |
|
||||
| Kimi K2 Tenker | 6. marts 2026 |
|
||||
| Kimi K2 | 6. marts 2026 |
|
||||
| MiniMax M2.1 | 15. marts 2026 |
|
||||
| GLM 4.7 | 15. marts 2026 |
|
||||
| GLM 4.6 | 15. marts 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Privatliv
|
||||
|
||||
Alle vores modeller er hostet i USA. Vores udbydere følger en nul-opbevaringspolitik og bruger ikke dine data til modeltræning, med følgende undtagelser:
|
||||
|
||||
@@ -57,6 +57,7 @@ Du kannst unsere Modelle auch ueber die folgenden API-Endpunkte aufrufen.
|
||||
|
||||
| Model | Model ID | Endpoint | AI SDK Package |
|
||||
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -114,12 +115,12 @@ Unten siehst du die Preise **pro 1 Mio. Tokens**.
|
||||
| --------------------------------- | ------ | ------ | ----------- | ------------ |
|
||||
| Big Pickle | Free | Free | Free | - |
|
||||
| MiniMax M2.5 Free | Free | Free | Free | - |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
|
||||
| MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
|
||||
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.08 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
|
||||
| Kimi K2 | $0.40 | $2.50 | - | - |
|
||||
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
|
||||
@@ -140,6 +141,7 @@ Unten siehst du die Preise **pro 1 Mio. Tokens**.
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
@@ -184,6 +186,19 @@ Mit aktiviertem Auto-Reload kann die Abrechnung dennoch darueber liegen, falls d
|
||||
|
||||
---
|
||||
|
||||
### Veraltete Modelle
|
||||
|
||||
| Model | Datum der Abschaltung |
|
||||
| ---------------- | --------------------- |
|
||||
| Qwen3 Coder 480B | 6. Feb. 2026 |
|
||||
| Kimi K2 Thinking | 6. Maerz 2026 |
|
||||
| Kimi K2 | 6. Maerz 2026 |
|
||||
| MiniMax M2.1 | 15. Maerz 2026 |
|
||||
| GLM 4.7 | 15. Maerz 2026 |
|
||||
| GLM 4.6 | 15. Maerz 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Datenschutz
|
||||
|
||||
Alle Modelle werden in den USA gehostet.
|
||||
|
||||
@@ -62,6 +62,7 @@ También puede acceder a nuestros modelos a través de los siguientes puntos fin
|
||||
|
||||
| Modelo | Model ID | Endpoint | AI SDK package |
|
||||
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -145,6 +146,7 @@ Apoyamos un modelo de pago por uso. A continuación se muestran los precios **po
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0,20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0,40 | - |
|
||||
| Gemini 3 Flash | $0,50 | $3.00 | $0,05 | - |
|
||||
| GPT 5.4 | $2,50 | $15,00 | $0,25 | - |
|
||||
| GPT 5.3 Codex | $1,75 | $14.00 | $0,175 | - |
|
||||
| GPT 5.2 | $1,75 | $14.00 | $0,175 | - |
|
||||
| GPT 5.2 Codex | $1,75 | $14.00 | $0,175 | - |
|
||||
@@ -190,6 +192,19 @@ cobrarle más de $20 si su saldo es inferior a $5.
|
||||
|
||||
---
|
||||
|
||||
### Modelos obsoletos
|
||||
|
||||
| Modelo | Fecha de retiro |
|
||||
| ---------------- | ------------------- |
|
||||
| Qwen3 Coder 480B | 6 de feb. de 2026 |
|
||||
| Kimi K2 Thinking | 6 de marzo de 2026 |
|
||||
| Kimi K2 | 6 de marzo de 2026 |
|
||||
| MiniMax M2.1 | 15 de marzo de 2026 |
|
||||
| GLM 4.7 | 15 de marzo de 2026 |
|
||||
| GLM 4.6 | 15 de marzo de 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Privacidad
|
||||
|
||||
Todos nuestros modelos están alojados en los EE. UU. Nuestros proveedores siguen una política de retención cero y no utilizan sus datos para la capacitación de modelos, con las siguientes excepciones:
|
||||
|
||||
@@ -55,6 +55,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP
|
||||
|
||||
| Modèle | ID du modèle | Point de terminaison | Package SDK IA |
|
||||
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -136,6 +137,7 @@ Nous soutenons un modèle de paiement à l'utilisation. Vous trouverez ci-dessou
|
||||
| Gemini 3 Pro (≤ 200K jetons) | 2,00 $ | 12,00 $ | 0,20 $ | - |
|
||||
| Gemini 3 Pro (> 200K jetons) | 4,00 $ | 18,00 $ | 0,40 $ | - |
|
||||
| Gemini 3 Flash | 0,50 $ | 3,00 $ | 0,05 $ | - |
|
||||
| GPT 5.4 | 2,50 $ | 15,00 $ | 0,25 $ | - |
|
||||
| GPT 5.3 Codex | 1,75 $ | 14,00 $ | 0,175 $ | - |
|
||||
| GPT 5.2 | 1,75 $ | 14,00 $ | 0,175 $ | - |
|
||||
| GPT 5.2 Codex | 1,75 $ | 14,00 $ | 0,175 $ | - |
|
||||
@@ -178,6 +180,19 @@ Par exemple, disons que vous définissez une limite d'utilisation mensuelle à 2
|
||||
|
||||
---
|
||||
|
||||
### Modèles obsolètes
|
||||
|
||||
| Modèle | Date de dépréciation |
|
||||
| ---------------- | -------------------- |
|
||||
| Qwen3 Coder 480B | 6 février 2026 |
|
||||
| Kimi K2 Thinking | 6 mars 2026 |
|
||||
| Kimi K2 | 6 mars 2026 |
|
||||
| MiniMax M2.1 | 15 mars 2026 |
|
||||
| GLM 4.7 | 15 mars 2026 |
|
||||
| GLM 4.6 | 15 mars 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Confidentialité
|
||||
|
||||
Tous nos modèles sont hébergés aux États-Unis. Nos fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour la formation de modèles, avec les exceptions suivantes :
|
||||
|
||||
@@ -55,6 +55,7 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API.
|
||||
|
||||
| Modello | ID modello | Endpoint | Pacchetto AI SDK |
|
||||
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -136,6 +137,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**.
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
@@ -178,6 +180,19 @@ Per esempio, se imposti un limite mensile a $20, Zen non usera piu di $20 in un
|
||||
|
||||
---
|
||||
|
||||
### Modelli deprecati
|
||||
|
||||
| Modello | Data di deprecazione |
|
||||
| ---------------- | -------------------- |
|
||||
| Qwen3 Coder 480B | 6 feb 2026 |
|
||||
| Kimi K2 Thinking | 6 mar 2026 |
|
||||
| Kimi K2 | 6 mar 2026 |
|
||||
| MiniMax M2.1 | 15 mar 2026 |
|
||||
| GLM 4.7 | 15 mar 2026 |
|
||||
| GLM 4.6 | 15 mar 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Privacy
|
||||
|
||||
Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una policy di zero-retention e non usano i tuoi dati per training dei modelli, con le seguenti eccezioni:
|
||||
|
||||
@@ -54,6 +54,7 @@ OpenCode Zen は、OpenCode の他のプロバイダーと同様に機能しま
|
||||
|
||||
| Model | Model ID | Endpoint | AI SDK Package |
|
||||
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -137,6 +138,7 @@ https://opencode.ai/zen/v1/models
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
@@ -179,6 +181,19 @@ https://opencode.ai/zen/v1/models
|
||||
|
||||
---
|
||||
|
||||
### 非推奨モデル
|
||||
|
||||
| Model | Deprecation date |
|
||||
| ---------------- | ---------------- |
|
||||
| Qwen3 Coder 480B | 2026年2月6日 |
|
||||
| Kimi K2 Thinking | 2026年3月6日 |
|
||||
| Kimi K2 | 2026年3月6日 |
|
||||
| MiniMax M2.1 | 2026年3月15日 |
|
||||
| GLM 4.7 | 2026年3月15日 |
|
||||
| GLM 4.6 | 2026年3月15日 |
|
||||
|
||||
---
|
||||
|
||||
## プライバシー
|
||||
|
||||
すべてのモデルは米国でホストされています。当社のプロバイダーはゼロ保持ポリシーに従い、次の例外を除いて、モデルのトレーニングにデータを使用しません。
|
||||
|
||||
@@ -55,6 +55,7 @@ OpenCode Zen은 OpenCode의 다른 제공자와 동일한 방식으로 작동합
|
||||
|
||||
| 모델 | 모델 ID | 엔드포인트 | AI SDK 패키지 |
|
||||
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -111,12 +112,12 @@ https://opencode.ai/zen/v1/models
|
||||
| --------------------------------- | ------ | ------ | --------- | --------- |
|
||||
| Big Pickle | Free | Free | Free | - |
|
||||
| MiniMax M2.5 Free | Free | Free | Free | - |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
|
||||
| MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
|
||||
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.08 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
|
||||
| Kimi K2 | $0.40 | $2.50 | - | - |
|
||||
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
|
||||
@@ -137,6 +138,7 @@ https://opencode.ai/zen/v1/models
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
@@ -180,6 +182,19 @@ https://opencode.ai/zen/v1/models
|
||||
|
||||
---
|
||||
|
||||
### 지원 중단 모델
|
||||
|
||||
| 모델 | 지원 중단일 |
|
||||
| ---------------- | --------------- |
|
||||
| Qwen3 Coder 480B | 2026년 2월 6일 |
|
||||
| Kimi K2 Thinking | 2026년 3월 6일 |
|
||||
| Kimi K2 | 2026년 3월 6일 |
|
||||
| MiniMax M2.1 | 2026년 3월 15일 |
|
||||
| GLM 4.7 | 2026년 3월 15일 |
|
||||
| GLM 4.6 | 2026년 3월 15일 |
|
||||
|
||||
---
|
||||
|
||||
## 개인정보 보호
|
||||
|
||||
당사의 모든 모델은 미국에서 호스팅됩니다. 당사 제공자는 데이터 무보존(zero-retention) 정책을 따르며, 아래의 예외를 제외하고는 귀하의 데이터를 모델 학습에 사용하지 않습니다.
|
||||
|
||||
@@ -64,6 +64,7 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter.
|
||||
|
||||
| Modell | Modell ID | Endepunkt | AI SDK Pakke |
|
||||
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -121,7 +122,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1 million tokens*
|
||||
| --------------------------------- | ------- | ------ | ------------- | --------------- |
|
||||
| Big Pickle | Gratis | Gratis | Gratis | - |
|
||||
| MiniMax M2.5 Free | Gratis | Gratis | Gratis | - |
|
||||
| MiniMax M2.5 | $0,30 | $1,20 | $0,06 | - |
|
||||
| MiniMax M2.5 | $0,30 | $1,20 | $0,06 | $0,375 |
|
||||
| MiniMax M2.1 | $0,30 | $1,20 | $0,10 | - |
|
||||
| GLM 5 | $1,00 | $3,20 | $0,20 | - |
|
||||
| GLM 4.7 | $0,60 | $2,20 | $0,10 | - |
|
||||
@@ -147,6 +148,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1 million tokens*
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2,00 | $12,00 | $0,20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4,00 | $18,00 | $0,40 | - |
|
||||
| Gemini 3 Flash | $0,50 | $3,00 | $0,05 | - |
|
||||
| GPT 5.4 | $2,50 | $15,00 | $0,25 | - |
|
||||
| GPT 5.3 Codex | $1,75 | $14,00 | $0,175 | - |
|
||||
| GPT 5.2 | $1,75 | $14,00 | $0,175 | - |
|
||||
| GPT 5.2 Codex | $1,75 | $14,00 | $0,175 | - |
|
||||
@@ -192,6 +194,19 @@ belaster deg mer enn $20 hvis saldoen din går under $5.
|
||||
|
||||
---
|
||||
|
||||
### Utfasede modeller
|
||||
|
||||
| Modell | Utfasingdato |
|
||||
| ---------------- | ------------- |
|
||||
| Qwen3 Coder 480B | 6. feb. 2026 |
|
||||
| Kimi K2 Thinking | 6. mars 2026 |
|
||||
| Kimi K2 | 6. mars 2026 |
|
||||
| MiniMax M2.1 | 15. mars 2026 |
|
||||
| GLM 4.7 | 15. mars 2026 |
|
||||
| GLM 4.6 | 15. mars 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Personvern
|
||||
|
||||
Alle våre modeller er hostet i USA. Leverandørene våre følger retningslinjer om ingen datalagring og bruker ikke dataene dine til modellopplæring, med følgende unntak:
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
---
|
||||
title: Zen
|
||||
description: Wyselekcjonowana lista modeli dostarczonych przez opencode.
|
||||
description: Wyselekcjonowana lista modeli dostarczonych przez OpenCode.
|
||||
---
|
||||
|
||||
import config from "../../../../config.mjs"
|
||||
export const console = config.console
|
||||
export const email = `mailto:${config.email}`
|
||||
|
||||
OpenCode Zen to lista przetestowanych i zweryfikowanych modeli udostępniona przez zespół opencode.
|
||||
OpenCode Zen to lista przetestowanych i zweryfikowanych modeli udostępniona przez zespół OpenCode.
|
||||
|
||||
:::note
|
||||
OpenCode Zen is currently in beta.
|
||||
OpenCode Zen jest obecnie w wersji beta.
|
||||
:::
|
||||
|
||||
Zen działa jak każdy inny dostawca opencode. Logujesz się do OpenCode Zen i dostajesz
|
||||
Twój klucz API. Jest **całkowicie opcjonalny** i nie musisz go używać, aby z niego korzystać
|
||||
opencode.
|
||||
Zen działa jak każdy inny dostawca w OpenCode. Logujesz się do OpenCode Zen i otrzymujesz
|
||||
swój klucz API. Jest to **całkowicie opcjonalne** i nie musisz tego używać, aby korzystać z
|
||||
OpenCode.
|
||||
|
||||
---
|
||||
|
||||
@@ -23,23 +23,23 @@ opencode.
|
||||
|
||||
Istnieje ogromna liczba modeli, ale tylko kilka z nich
|
||||
działa dobrze jako agenci kodujący. Dodatkowo większość dostawców jest
|
||||
skonfigurowana bardzo różnie; więc otrzymujesz zupełnie inną wydajność i jakość.
|
||||
skonfigurowana bardzo różnie, więc otrzymujesz bardzo różną wydajność i jakość.
|
||||
|
||||
:::tip
|
||||
Przetestowaliśmy wybraną grupę modeli i dostawców, którzy dobrze współpracują z opencode.
|
||||
Przetestowaliśmy wybraną grupę modeli i dostawców, którzy dobrze współpracują z OpenCode.
|
||||
:::
|
||||
|
||||
Jeśli więc używasz modelu za pośrednictwem czegoś takiego jak OpenRouter, nigdy nie będzie to możliwe
|
||||
Jeśli więc używasz modelu za pośrednictwem czegoś takiego jak OpenRouter, nigdy nie możesz być
|
||||
pewien, czy otrzymujesz najlepszą wersję modelu, jaki chcesz.
|
||||
|
||||
Aby to naprawić, zrobiliśmy kilka rzeczy:
|
||||
|
||||
1. Przetestowaliśmy wybraną grupę modeli i rozmawialiśmy z ich zespołami o tym, jak to zrobić
|
||||
najlepiej je uruchom.
|
||||
1. Przetestowaliśmy wybraną grupę modeli i rozmawialiśmy z ich zespołami o tym, jak
|
||||
najlepiej je uruchamiać.
|
||||
2. Następnie współpracowaliśmy z kilkoma dostawcami, aby upewnić się, że są one obsługiwane
|
||||
correctly.
|
||||
3. Na koniec porównaliśmy kombinację modelu/dostawcy i otrzymaliśmy wynik
|
||||
z listą, którą z przyjemnością polecamy.
|
||||
poprawnie.
|
||||
3. Na koniec sprawdziliśmy wydajność kombinacji modelu/dostawcy i stworzyliśmy
|
||||
listę, którą z czystym sumieniem polecamy.
|
||||
|
||||
OpenCode Zen to brama AI, która zapewnia dostęp do tych modeli.
|
||||
|
||||
@@ -47,14 +47,14 @@ OpenCode Zen to brama AI, która zapewnia dostęp do tych modeli.
|
||||
|
||||
## Jak to działa
|
||||
|
||||
OpenCode Zen działa jak każdy inny dostawca opencode.
|
||||
OpenCode Zen działa jak każdy inny dostawca w OpenCode.
|
||||
|
||||
1. Logujesz się do **<a href={console}>OpenCode Zen</a>**, dodajesz swoje rozliczenia
|
||||
szczegóły i skopiuj klucz API.
|
||||
1. Logujesz się do **<a href={console}>OpenCode Zen</a>**, dodajesz dane rozliczeniowe
|
||||
i kopiujesz swój klucz API.
|
||||
2. Uruchamiasz polecenie `/connect` w TUI, wybierasz OpenCode Zen i wklejasz klucz API.
|
||||
3. Uruchom `/models` w TUI, aby zobaczyć listę zalecanych przez nas modeli.
|
||||
|
||||
Opłata jest pobierana za każde żądanie i możesz dodać kredyty do swojego konta.
|
||||
Opłata jest pobierana za każde żądanie i możesz dodać środki do swojego konta.
|
||||
|
||||
---
|
||||
|
||||
@@ -64,6 +64,7 @@ Dostęp do naszych modeli można również uzyskać za pośrednictwem następuj
|
||||
|
||||
| Model | Identyfikator modelu | Punkt końcowy | Pakiet SDK AI |
|
||||
| ------------------ | -------------------- | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -97,9 +98,9 @@ Dostęp do naszych modeli można również uzyskać za pośrednictwem następuj
|
||||
| Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
[Identyfikator modelu](/docs/config/#models) w konfiguracji opencode
|
||||
używa formatu `opencode/<model-id>`. Na przykład w przypadku Kodeksu GPT 5.2 zrobiłbyś to
|
||||
użyj `opencode/gpt-5.2-codex` w swojej konfiguracji.
|
||||
[Identyfikator modelu](/docs/config/#models) w konfiguracji OpenCode
|
||||
używa formatu `opencode/<model-id>`. Na przykład w przypadku GPT 5.2 Codex użyłbyś
|
||||
`opencode/gpt-5.2-codex` w swojej konfiguracji.
|
||||
|
||||
---
|
||||
|
||||
@@ -121,12 +122,12 @@ Wspieramy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów**.
|
||||
| --------------------------------- | ------- | ------- | --------------------------- | -------------------------- |
|
||||
| Big Pickle | Free | Free | Free | - |
|
||||
| MiniMax M2.5 Free | Free | Free | Free | - |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
|
||||
| MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
|
||||
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.08 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
|
||||
| Kimi K2 | $0.40 | $2.50 | - | - |
|
||||
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
|
||||
@@ -147,6 +148,7 @@ Wspieramy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów**.
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
@@ -158,10 +160,10 @@ Wspieramy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów**.
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
|
||||
Możesz zauważyć _Claude Haiku 3.5_ w swojej historii użytkowania. To jest [model niskokosztowy](/docs/config/#models), który służy do generowania tytułów sesji.
|
||||
Możesz zauważyć _Claude Haiku 3.5_ w swojej historii użytkowania. Jest to [tani model](/docs/config/#models), który jest używany do generowania tytułów Twoich sesji.
|
||||
|
||||
:::note
|
||||
Opłaty za karty kredytowe są przenoszone na koszt (4,4% + 0,30 USD za transakcję); nie pobieramy żadnych dodatkowych opłat.
|
||||
Opłaty za karty kredytowe są przenoszone po kosztach (4,4% + 0,30 USD za transakcję); nie pobieramy nic poza tym.
|
||||
:::
|
||||
|
||||
Darmowe modele:
|
||||
@@ -177,18 +179,31 @@ Darmowe modele:
|
||||
|
||||
Jeśli Twoje saldo spadnie poniżej 5 USD, Zen automatycznie doładuje 20 USD.
|
||||
|
||||
Możesz zmienić kwotę automatycznego doładowania. Możesz także całkowicie wyłączyć automatyczne przeładowywanie.
|
||||
Możesz zmienić kwotę automatycznego doładowania. Możesz także całkowicie wyłączyć automatyczne doładowanie.
|
||||
|
||||
---
|
||||
|
||||
### Limity miesięczne
|
||||
|
||||
Możesz także ustawić miesięczny limit wykorzystania dla całego obszaru roboczego i dla każdego z nich
|
||||
członek Twojego zespołu.
|
||||
Możesz także ustawić miesięczny limit użytkowania dla całego obszaru roboczego i dla każdego
|
||||
członka Twojego zespołu.
|
||||
|
||||
Załóżmy na przykład, że ustawiłeś miesięczny limit użytkowania na 20 USD, Zen nie będzie z niego korzystał
|
||||
ponad 20 dolarów miesięcznie. Ale jeśli masz włączone automatyczne przeładowywanie, Zen może się skończyć
|
||||
obciąży Cię kwotą wyższą niż 20 USD, jeśli saldo spadnie poniżej 5 USD.
|
||||
Na przykład, jeśli ustawisz miesięczny limit użytkowania na 20 USD, Zen nie zużyje
|
||||
więcej niż 20 dolarów w miesiącu. Ale jeśli masz włączone automatyczne doładowanie, Zen może
|
||||
obciążyć Cię kwotą wyższą niż 20 USD, jeśli saldo spadnie poniżej 5 USD.
|
||||
|
||||
---
|
||||
|
||||
### Przestarzałe modele
|
||||
|
||||
| Model | Data wycofania |
|
||||
| ---------------- | -------------- |
|
||||
| Qwen3 Coder 480B | 6 lutego 2026 |
|
||||
| Kimi K2 Thinking | 6 marca 2026 |
|
||||
| Kimi K2 | 6 marca 2026 |
|
||||
| MiniMax M2.1 | 15 marca 2026 |
|
||||
| GLM 4.7 | 15 marca 2026 |
|
||||
| GLM 4.6 | 15 marca 2026 |
|
||||
|
||||
---
|
||||
|
||||
@@ -198,22 +213,22 @@ Wszystkie nasze modele są hostowane w USA. Nasi dostawcy przestrzegają polityk
|
||||
|
||||
- Big Pickle: W okresie bezpłatnym zebrane dane mogą zostać wykorzystane do udoskonalenia modelu.
|
||||
- MiniMax M2.5 Free: W okresie bezpłatnym zebrane dane mogą zostać wykorzystane do udoskonalenia modelu.
|
||||
- Interfejsy API OpenAI: żądania są przechowywane przez 30 dni zgodnie z [Zasadami dotyczącymi danych OpenAI](https://platform.openai.com/docs/guides/your-data).
|
||||
- Interfejsy API Anthropic: żądania są przechowywane przez 30 dni zgodnie z [Zasadami dotyczącymi danych firmy Anthropic](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
- API OpenAI: Żądania są przechowywane przez 30 dni zgodnie z [Zasadami dotyczącymi danych OpenAI](https://platform.openai.com/docs/guides/your-data).
|
||||
- API Anthropic: Żądania są przechowywane przez 30 dni zgodnie z [Zasadami dotyczącymi danych Anthropic](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
|
||||
---
|
||||
|
||||
## Dla zespołów
|
||||
|
||||
Zen świetnie sprawdza się także w zespołach. Możesz zapraszać członków zespołu, przypisywać role, zarządzać
|
||||
Zen działa świetnie także dla zespołów. Możesz zapraszać członków zespołu, przypisywać role, dobierać
|
||||
modele, z których korzysta Twój zespół i nie tylko.
|
||||
|
||||
:::note
|
||||
Obszary robocze są obecnie bezpłatne dla zespołów w ramach wersji beta.
|
||||
:::
|
||||
|
||||
Zarządzanie obszarem roboczym jest obecnie bezpłatne dla zespołów w ramach wersji beta. Będziemy
|
||||
wkrótce udostępnimy więcej szczegółów na temat cen.
|
||||
Zarządzanie obszarem roboczym jest obecnie bezpłatne dla zespołów w ramach wersji beta.
|
||||
Wkrótce udostępnimy więcej szczegółów na temat cen.
|
||||
|
||||
---
|
||||
|
||||
@@ -221,8 +236,8 @@ wkrótce udostępnimy więcej szczegółów na temat cen.
|
||||
|
||||
Możesz zapraszać członków zespołu do swojego obszaru roboczego i przypisywać role:
|
||||
|
||||
- **Administrator**: Zarządzaj modelami, członkami, kluczami API i rozliczeniami
|
||||
- **Członek**: Zarządzaj tylko własnymi kluczami API
|
||||
- **Admin**: Zarządzanie modelami, członkami, kluczami API i rozliczeniami
|
||||
- **Członek**: Zarządzanie tylko własnymi kluczami API
|
||||
|
||||
Administratorzy mogą także ustawić miesięczne limity wydatków dla każdego członka, aby utrzymać koszty pod kontrolą.
|
||||
|
||||
@@ -233,7 +248,7 @@ Administratorzy mogą także ustawić miesięczne limity wydatków dla każdego
|
||||
Administratorzy mogą włączać i wyłączać określone modele w obszarze roboczym. Żądania skierowane do wyłączonego modelu zwrócą błąd.
|
||||
|
||||
Jest to przydatne w przypadkach, gdy chcesz wyłączyć korzystanie z modelu, który
|
||||
collects data.
|
||||
zbiera dane.
|
||||
|
||||
---
|
||||
|
||||
@@ -253,6 +268,6 @@ i chcesz go używać zamiast tego, który zapewnia Zen.
|
||||
Stworzyliśmy OpenCode Zen, aby:
|
||||
|
||||
1. **Testować** (Benchmark) najlepsze modele/dostawców dla agentów kodujących.
|
||||
2. Miej dostęp do opcji **najwyższej jakości**, a nie obniżaj wydajności ani nie kieruj się do tańszych dostawców.
|
||||
3. Przekaż wszelkie **obniżki cen**, sprzedając po kosztach; więc jedyną marżą jest pokrycie naszych opłat manipulacyjnych.
|
||||
4. Nie **nie blokuj**, umożliwiając używanie go z dowolnym innym agentem kodującym. I zawsze pozwalaj na korzystanie z opencode dowolnego innego dostawcy.
|
||||
2. Mieć dostęp do opcji **najwyższej jakości**, a nie obniżać wydajności ani nie kierować do tańszych dostawców.
|
||||
3. Przekazywać wszelkie **obniżki cen**, sprzedając po kosztach; więc jedyną marżą jest pokrycie naszych opłat manipulacyjnych.
|
||||
4. Nie **mieć blokady** (no lock-in), umożliwiając używanie go z dowolnym innym agentem kodującym. I zawsze pozwalać na korzystanie z dowolnego innego dostawcy w OpenCode.
|
||||
|
||||
@@ -55,6 +55,7 @@ Você também pode acessar nossos modelos através dos seguintes endpoints da AP
|
||||
|
||||
| Modelo | ID do Modelo | Endpoint | Pacote AI SDK |
|
||||
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -136,6 +137,7 @@ Nós suportamos um modelo de pagamento conforme o uso. Abaixo estão os preços
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
@@ -178,6 +180,19 @@ Por exemplo, digamos que você defina um limite de uso mensal de $20, o Zen não
|
||||
|
||||
---
|
||||
|
||||
### Modelos obsoletos
|
||||
|
||||
| Modelo | Data de descontinuação |
|
||||
| ---------------- | ---------------------- |
|
||||
| Qwen3 Coder 480B | 6 de fev. de 2026 |
|
||||
| Kimi K2 Thinking | 6 de mar. de 2026 |
|
||||
| Kimi K2 | 6 de mar. de 2026 |
|
||||
| MiniMax M2.1 | 15 de mar. de 2026 |
|
||||
| GLM 4.7 | 15 de mar. de 2026 |
|
||||
| GLM 4.6 | 15 de mar. de 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Privacidade
|
||||
|
||||
Todos os nossos modelos estão hospedados nos EUA. Nossos provedores seguem uma política de zero retenção e não usam seus dados para treinamento de modelos, com as seguintes exceções:
|
||||
|
||||
@@ -63,6 +63,7 @@ OpenCode Zen работает так же, как и любой другой п
|
||||
|
||||
| Модель | Идентификатор модели | Конечная точка | Пакет AI SDK |
|
||||
| ------------------ | -------------------- | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -146,6 +147,7 @@ https://opencode.ai/zen/v1/models
|
||||
| Gemini 3 Pro (≤ 200 тыс. токенов) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200 тыс. токенов) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
@@ -191,6 +193,19 @@ https://opencode.ai/zen/v1/models
|
||||
|
||||
---
|
||||
|
||||
### Устаревшие модели
|
||||
|
||||
| Модель | Дата отключения |
|
||||
| ---------------- | ---------------- |
|
||||
| Qwen3 Coder 480B | 6 февр. 2026 г. |
|
||||
| Kimi K2 Thinking | 6 марта 2026 г. |
|
||||
| Kimi K2 | 6 марта 2026 г. |
|
||||
| MiniMax M2.1 | 15 марта 2026 г. |
|
||||
| GLM 4.7 | 15 марта 2026 г. |
|
||||
| GLM 4.6 | 15 марта 2026 г. |
|
||||
|
||||
---
|
||||
|
||||
## Конфиденциальность
|
||||
|
||||
Все наши модели размещены в США. Наши поставщики придерживаются политики нулевого хранения и не используют ваши данные для обучения моделей, за следующими исключениями:
|
||||
|
||||
@@ -64,6 +64,7 @@ OpenCode Zen ทำงานเหมือนกับผู้ให้บร
|
||||
|
||||
| Model | Model ID | Endpoint | แพ็คเกจ AI SDK |
|
||||
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -121,12 +122,12 @@ https://opencode.ai/zen/v1/models
|
||||
| --------------------------------- | ---------- | -------- | ------- | ---------- |
|
||||
| Big Pickle | ฟรี | ฟรี | ฟรี | - |
|
||||
| MiniMax M2.5 Free | ฟรี | ฟรี | ฟรี | - |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
|
||||
| MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
|
||||
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.08 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
|
||||
| Kimi K2 | $0.40 | $2.50 | - | - |
|
||||
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
|
||||
@@ -147,6 +148,7 @@ https://opencode.ai/zen/v1/models
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
@@ -192,11 +194,24 @@ https://opencode.ai/zen/v1/models
|
||||
|
||||
---
|
||||
|
||||
### โมเดลที่เลิกใช้แล้ว
|
||||
|
||||
| Model | วันที่เลิกใช้ |
|
||||
| ---------------- | ------------- |
|
||||
| Qwen3 Coder 480B | 6 ก.พ. 2026 |
|
||||
| Kimi K2 Thinking | 6 มี.ค. 2026 |
|
||||
| Kimi K2 | 6 มี.ค. 2026 |
|
||||
| MiniMax M2.1 | 15 มี.ค. 2026 |
|
||||
| GLM 4.7 | 15 มี.ค. 2026 |
|
||||
| GLM 4.6 | 15 มี.ค. 2026 |
|
||||
|
||||
---
|
||||
|
||||
## ความเป็นส่วนตัว
|
||||
|
||||
โมเดลทั้งหมดของเราโฮสต์ในสหรัฐอเมริกา ผู้ให้บริการของเราปฏิบัติตามนโยบายการเก็บรักษาเป็นศูนย์ และไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล โดยมีข้อยกเว้นต่อไปนี้:
|
||||
|
||||
- Big Pickle: ในช่วงระยะเวลาว่าง ข้อมูลที่รวบรวมอาจนำไปใช้ในการปรับปรุงโมเดลได้
|
||||
- Big Pickle: ในช่วงระยะเวลาฟรี ข้อมูลที่รวบรวมอาจนำไปใช้ในการปรับปรุงโมเดลได้
|
||||
- MiniMax M2.5 Free: ในช่วงระยะเวลาฟรี ข้อมูลที่รวบรวมอาจนำไปใช้ในการปรับปรุงโมเดล
|
||||
- OpenAI API: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [นโยบายข้อมูลของ OpenAI](https://platform.openai.com/docs/guides/your-data)
|
||||
- Anthropic API: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [นโยบายข้อมูลของ Anthropic](https://docs.anthropic.com/en/docs/claude-code/data-usage)
|
||||
|
||||
@@ -55,6 +55,7 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin
|
||||
|
||||
| Model | Model ID | Endpoint | AI SDK Package |
|
||||
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -136,6 +137,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1 milyon token başına*
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
@@ -178,6 +180,19 @@ Ayrıca tüm çalışma alanı ve ekibinizin her üyesi için aylık kullanım l
|
||||
|
||||
---
|
||||
|
||||
### Kullanımdan kaldırılan modeller
|
||||
|
||||
| Model | Kullanımdan kaldırılma tarihi |
|
||||
| ---------------- | ----------------------------- |
|
||||
| Qwen3 Coder 480B | 6 Şub 2026 |
|
||||
| Kimi K2 Thinking | 6 Mar 2026 |
|
||||
| Kimi K2 | 6 Mar 2026 |
|
||||
| MiniMax M2.1 | 15 Mar 2026 |
|
||||
| GLM 4.7 | 15 Mar 2026 |
|
||||
| GLM 4.6 | 15 Mar 2026 |
|
||||
|
||||
---
|
||||
|
||||
## Gizlilik
|
||||
|
||||
Tüm modellerimiz ABD'de barındırılmaktadır. Sağlayıcılarımız sıfır saklama politikasını izler ve aşağıdaki istisnalar dışında verilerinizi model eğitimi için kullanmaz:
|
||||
|
||||
@@ -62,44 +62,47 @@ You are charged per request and you can add credits to your account.
|
||||
|
||||
You can also access our models through the following API endpoints.
|
||||
|
||||
| Model | Model ID | Endpoint | AI SDK Package |
|
||||
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Pro | gemini-3-pro | `https://opencode.ai/zen/v1/models/gemini-3-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.1 | minimax-m2.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 4.7 | glm-4.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 4.6 | glm-4.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2 Thinking | kimi-k2-thinking | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2 | kimi-k2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Model | Model ID | Endpoint | AI SDK Package |
|
||||
| ------------------- | ------------------- | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 Pro | gpt-5.4-pro | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex Spark | gpt-5.3-codex-spark | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 | gpt-5.1 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex | gpt-5.1-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex Max | gpt-5.1-codex-max | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.1 Codex Mini | gpt-5.1-codex-mini | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 | gpt-5 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.5 | claude-opus-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4.6 | claude-sonnet-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4.5 | claude-sonnet-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Pro | gemini-3-pro | `https://opencode.ai/zen/v1/models/gemini-3-pro` | `@ai-sdk/google` |
|
||||
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.5 Free | minimax-m2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M2.1 | minimax-m2.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 4.7 | glm-4.7 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM 4.6 | glm-4.6 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2 Thinking | kimi-k2-thinking | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2 | kimi-k2 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Qwen3 Coder 480B | qwen3-coder | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
|
||||
The [model id](/docs/config/#models) in your OpenCode config
|
||||
uses the format `opencode/<model-id>`. For example, for GPT 5.2 Codex, you would
|
||||
use `opencode/gpt-5.2-codex` in your config.
|
||||
uses the format `opencode/<model-id>`. For example, for GPT 5.3 Codex, you would
|
||||
use `opencode/gpt-5.3-codex` in your config.
|
||||
|
||||
---
|
||||
|
||||
@@ -117,46 +120,49 @@ https://opencode.ai/zen/v1/models
|
||||
|
||||
We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
|
||||
|
||||
| Model | Input | Output | Cached Read | Cached Write |
|
||||
| --------------------------------- | ------ | ------ | ----------- | ------------ |
|
||||
| Big Pickle | Free | Free | Free | - |
|
||||
| MiniMax M2.5 Free | Free | Free | Free | - |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
|
||||
| MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
|
||||
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
|
||||
| Kimi K2 | $0.40 | $2.50 | - | - |
|
||||
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
|
||||
| Claude Opus 4.6 (≤ 200K tokens) | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.6 (> 200K tokens) | $10.00 | $37.50 | $1.00 | $12.50 |
|
||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
|
||||
| Claude Sonnet 4.6 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4.6 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
|
||||
| Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4.5 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
|
||||
| Claude Sonnet 4 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
|
||||
| Claude Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 |
|
||||
| Claude Haiku 3.5 | $0.80 | $4.00 | $0.08 | $1.00 |
|
||||
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.1 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5.1 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5.1 Codex Max | $1.25 | $10.00 | $0.125 | - |
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
| Model | Input | Output | Cached Read | Cached Write |
|
||||
| --------------------------------- | ------ | ------- | ----------- | ------------ |
|
||||
| Big Pickle | Free | Free | Free | - |
|
||||
| MiniMax M2.5 Free | Free | Free | Free | - |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
|
||||
| MiniMax M2.1 | $0.30 | $1.20 | $0.10 | - |
|
||||
| GLM 5 | $1.00 | $3.20 | $0.20 | - |
|
||||
| GLM 4.7 | $0.60 | $2.20 | $0.10 | - |
|
||||
| GLM 4.6 | $0.60 | $2.20 | $0.10 | - |
|
||||
| Kimi K2.5 | $0.60 | $3.00 | $0.10 | - |
|
||||
| Kimi K2 Thinking | $0.40 | $2.50 | - | - |
|
||||
| Kimi K2 | $0.40 | $2.50 | - | - |
|
||||
| Qwen3 Coder 480B | $0.45 | $1.50 | - | - |
|
||||
| Claude Opus 4.6 (≤ 200K tokens) | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.6 (> 200K tokens) | $10.00 | $37.50 | $1.00 | $12.50 |
|
||||
| Claude Opus 4.5 | $5.00 | $25.00 | $0.50 | $6.25 |
|
||||
| Claude Opus 4.1 | $15.00 | $75.00 | $1.50 | $18.75 |
|
||||
| Claude Sonnet 4.6 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4.6 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
|
||||
| Claude Sonnet 4.5 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4.5 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
|
||||
| Claude Sonnet 4 (≤ 200K tokens) | $3.00 | $15.00 | $0.30 | $3.75 |
|
||||
| Claude Sonnet 4 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
|
||||
| Claude Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 |
|
||||
| Claude Haiku 3.5 | $0.80 | $4.00 | $0.08 | $1.00 |
|
||||
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.4 Pro | $30.00 | $180.00 | $30.00 | - |
|
||||
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
|
||||
| GPT 5.3 Codex Spark | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.1 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5.1 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5.1 Codex Max | $1.25 | $10.00 | $0.125 | - |
|
||||
| GPT 5.1 Codex Mini | $0.25 | $2.00 | $0.025 | - |
|
||||
| GPT 5 | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Codex | $1.07 | $8.50 | $0.107 | - |
|
||||
| GPT 5 Nano | Free | Free | Free | - |
|
||||
|
||||
You might notice _Claude Haiku 3.5_ in your usage history. This is a [low cost model](/docs/config/#models) that's used to generate the titles of your sessions.
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。
|
||||
|
||||
| 模型 | 模型 ID | 端点 | AI SDK 包 |
|
||||
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -136,6 +137,7 @@ https://opencode.ai/zen/v1/models
|
||||
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
@@ -178,6 +180,19 @@ https://opencode.ai/zen/v1/models
|
||||
|
||||
---
|
||||
|
||||
### 已弃用模型
|
||||
|
||||
| 模型 | 弃用日期 |
|
||||
| ---------------- | ------------------ |
|
||||
| Qwen3 Coder 480B | 2026 年 2 月 6 日 |
|
||||
| Kimi K2 Thinking | 2026 年 3 月 6 日 |
|
||||
| Kimi K2 | 2026 年 3 月 6 日 |
|
||||
| MiniMax M2.1 | 2026 年 3 月 15 日 |
|
||||
| GLM 4.7 | 2026 年 3 月 15 日 |
|
||||
| GLM 4.6 | 2026 年 3 月 15 日 |
|
||||
|
||||
---
|
||||
|
||||
## 隐私
|
||||
|
||||
我们所有的模型都托管在美国。我们的提供商遵循零保留政策,不会将你的数据用于模型训练,但以下情况除外:
|
||||
|
||||
@@ -55,6 +55,7 @@ OpenCode Zen 的工作方式與 OpenCode 中的任何其他供應商相同。
|
||||
|
||||
| 模型 | 模型 ID | 端點 | AI SDK 套件 |
|
||||
| ------------------ | ------------------ | -------------------------------------------------- | --------------------------- |
|
||||
| GPT 5.4 | gpt-5.4 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.3 Codex | gpt-5.3-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 | gpt-5.2 | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
| GPT 5.2 Codex | gpt-5.2-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` |
|
||||
@@ -136,6 +137,7 @@ https://opencode.ai/zen/v1/models
|
||||
| Gemini 3 Pro (≤ 200K Token) | $2.00 | $12.00 | $0.20 | - |
|
||||
| Gemini 3 Pro (> 200K Token) | $4.00 | $18.00 | $0.40 | - |
|
||||
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |
|
||||
| GPT 5.4 | $2.50 | $15.00 | $0.25 | - |
|
||||
| GPT 5.3 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 | $1.75 | $14.00 | $0.175 | - |
|
||||
| GPT 5.2 Codex | $1.75 | $14.00 | $0.175 | - |
|
||||
@@ -178,6 +180,19 @@ https://opencode.ai/zen/v1/models
|
||||
|
||||
---
|
||||
|
||||
### 已棄用的模型
|
||||
|
||||
| 模型 | 棄用日期 |
|
||||
| ---------------- | ------------------ |
|
||||
| Qwen3 Coder 480B | 2026 年 2 月 6 日 |
|
||||
| Kimi K2 Thinking | 2026 年 3 月 6 日 |
|
||||
| Kimi K2 | 2026 年 3 月 6 日 |
|
||||
| MiniMax M2.1 | 2026 年 3 月 15 日 |
|
||||
| GLM 4.7 | 2026 年 3 月 15 日 |
|
||||
| GLM 4.6 | 2026 年 3 月 15 日 |
|
||||
|
||||
---
|
||||
|
||||
## 隱私
|
||||
|
||||
我們所有的模型都託管在美國。我們的供應商遵循零保留政策,不會將你的資料用於模型訓練,但以下情況除外:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.2.18",
|
||||
"version": "1.2.19",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
Reference in New Issue
Block a user