Compare commits

..

26 Commits

Author SHA1 Message Date
Dax Raad
0d2bd182b1 sync 2026-02-16 09:57:42 -05:00
Dax Raad
6daf86dd6e feat: add reference agent for searching external repositories
Add ability to configure external git repositories or local paths that
subagents can search across. References are configured in opencode.json
and automatically cloned/fetched to a cache directory.
2026-02-15 01:27:22 -05:00
opencode
d1482e1483 release: v1.2.4 2026-02-15 01:55:33 +00:00
Dax Raad
45f0050372 core: add db command for database inspection and querying 2026-02-14 20:37:17 -05:00
Dax Raad
b5c8bd3421 test: add tests for path-derived IDs in json migration
Tests verify that file paths are used for IDs even when JSON contains
different values - ensuring robustness against stale JSON content.
2026-02-14 20:37:17 -05:00
Dax Raad
2bab5e8c39 fix: derive all IDs from file paths during json migration
Earlier migrations moved data to new directories without updating JSON
fields. Now consistently derives all IDs from file paths:

- Projects: id from filename
- Sessions: id from filename, projectID from parent directory
- Messages: id from filename, sessionID from parent directory
- Parts: id from filename, messageID from parent directory

This ensures migrated data matches the actual file layout regardless of
stale values in JSON content.
2026-02-14 20:37:17 -05:00
Adam
85b5f5b705 feat(app): clear notifications action (#13668)
Co-authored-by: adamelmore <2363879+adamdottv@users.noreply.github.com>
2026-02-14 19:33:22 -06:00
Adam
460a87f359 fix(app): stack overflow in filetree (#13667)
Co-authored-by: adamelmore <2363879+adamdottv@users.noreply.github.com>
2026-02-14 19:24:48 -06:00
opencode
c190f5f611 release: v1.2.3 2026-02-15 00:34:56 +00:00
opencode-agent[bot]
7911cb62ab chore: update nix node_modules hashes 2026-02-14 20:38:57 +00:00
Aiden Cline
839c5cda12 fix: ensure anthropic models on OR also have variant support (#13498) 2026-02-14 14:30:07 -06:00
Dax
67c985ce82 fix: add WAL checkpoint on database open (#13633) 2026-02-14 19:33:08 +00:00
Alberto Valverde
575f2cf2a5 chore: bump nixpkgs to get bun 1.3.9 (#13302) 2026-02-14 13:21:31 -06:00
Aiden Cline
933a491ade fix: ensure vercel variants pass amazon models under bedrock key (#13631) 2026-02-14 13:18:52 -06:00
opencode
3b6b3e6fc8 release: v1.2.2 2026-02-14 19:08:58 +00:00
Dax Raad
8631d6c01d core: add comprehensive test coverage for Session.list() filters
Adds test cases for filtering sessions by directory, root sessions only,

start time, search terms, and result limits to ensure the listing

functionality works correctly for all filter combinations.
2026-02-14 13:43:41 -05:00
Dax Raad
68bb8ce1da core: filter sessions at database level to improve session list loading performance 2026-02-14 13:41:15 -05:00
opencode-agent[bot]
306fc77076 chore: update nix node_modules hashes 2026-02-14 18:38:18 +00:00
Aiden Cline
759ec104b6 fix vercel gateway variants (#13541)
Co-authored-by: Benjamin Woodruff <github@benjam.info>"
2026-02-14 12:32:29 -06:00
Aiden Cline
ef205c3660 bump vertex ai packages (#13625) 2026-02-14 12:29:01 -06:00
Brendan Allan
df3203d2dd ci: move signpath policy 2026-02-14 14:47:50 +08:00
Brendan Allan
ed439b2057 ci: test-signing signpath policy 2026-02-14 06:39:53 +00:00
opencode
cd775a2862 release: v1.2.1 2026-02-14 06:39:47 +00:00
Dax Raad
b020758446 tui: show all project sessions from any working directory
Previously sessions were only listed if they were created in the current
working directory or its subdirectories. Users can now view and switch
to any session in the project regardless of which directory they're in.
2026-02-14 01:21:41 -05:00
Aiden Cline
179c40749d fix: tweak websearch tool description date info to avoid cache busts (#13559) 2026-02-13 23:59:10 -06:00
Frank
1e25df21a2 zen: minimax m2.5 & glm5 2026-02-14 00:47:26 -05:00
64 changed files with 1330 additions and 220 deletions

View File

@@ -3,6 +3,7 @@
// "enterprise": {
// "url": "https://enterprise.dev.opencode.ai",
// },
"references": ["git@github.com:Effect-TS/effect.git"],
"provider": {
"opencode": {
"options": {},

View File

@@ -0,0 +1,5 @@
github-policies:
runners:
allowed_groups:
- "GitHub Actions"
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"

View File

@@ -23,7 +23,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.2.0",
"version": "1.2.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -73,7 +73,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.2.0",
"version": "1.2.4",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -107,7 +107,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.2.0",
"version": "1.2.4",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -134,7 +134,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.2.0",
"version": "1.2.4",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -158,7 +158,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.2.0",
"version": "1.2.4",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -182,7 +182,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.2.0",
"version": "1.2.4",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -215,7 +215,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.2.0",
"version": "1.2.4",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -244,7 +244,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.2.0",
"version": "1.2.4",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -260,7 +260,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.2.0",
"version": "1.2.4",
"bin": {
"opencode": "./bin/opencode",
},
@@ -276,7 +276,7 @@
"@ai-sdk/deepinfra": "1.0.36",
"@ai-sdk/gateway": "2.0.30",
"@ai-sdk/google": "2.0.52",
"@ai-sdk/google-vertex": "3.0.98",
"@ai-sdk/google-vertex": "3.0.103",
"@ai-sdk/groq": "2.0.34",
"@ai-sdk/mistral": "2.0.27",
"@ai-sdk/openai": "2.0.89",
@@ -369,7 +369,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.2.0",
"version": "1.2.4",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -389,7 +389,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.2.0",
"version": "1.2.4",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -400,7 +400,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.2.0",
"version": "1.2.4",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -413,7 +413,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.2.0",
"version": "1.2.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -455,7 +455,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.2.0",
"version": "1.2.4",
"dependencies": {
"zod": "catalog:",
},
@@ -466,7 +466,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.2.0",
"version": "1.2.4",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -504,6 +504,7 @@
"tree-sitter-bash",
],
"patchedDependencies": {
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
},
"overrides": {
@@ -594,7 +595,7 @@
"@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.98", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uuv0RHkdJ5vTzeH1+iuBlv7GAjRcOPd2jiqtGLz6IKOUDH+PRQoE3ExrvOysVnKuhhTBMqvawkktDhMDQE6sVQ=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.103", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.63", "@ai-sdk/google": "2.0.53", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MPZRSVOJFxYGHE4s6XjSWaiUPru7u2i/LUUA1Ih2nzNYZaei8c46Z56imOCD/KQjQX3afRA2iZh6P5McsmwhqA=="],
"@ai-sdk/groq": ["@ai-sdk/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="],
@@ -4208,7 +4209,11 @@
"@ai-sdk/fireworks/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.63", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zXlUPCkumnvp8lWS9VFcen/MLF6CL/t1zAKDhpobYj9y/nmylQrKtRvn3RwH871Wd3dF3KYEUXd6M2c6dfCKOA=="],
"@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.53", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ccCxr5mrd3AC2CjLq4e1ST7+UiN5T2Pdmgi0XdWM3QohmNBwUQ/RBG7BvL+cB/ex/j6y64tkMmpYz9zBw/SEFQ=="],
"@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1770073757,
"narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
"lastModified": 1770812194,
"narHash": "sha256-OH+lkaIKAvPXR3nITO7iYZwew2nW9Y7Xxq0yfM/UcUU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "47472570b1e607482890801aeaf29bfb749884f6",
"rev": "8482c7ded03bae7550f3d69884f1e611e3bd19e8",
"type": "github"
},
"original": {

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-hVf8rBEqy3q4xexOqyKDtKmlMydl1hFoDV0JiEvmfgs=",
"aarch64-linux": "sha256-4m3UZllEmfJXB70cOgIoyWRIYMXxGzzenyOfF3kEQKk=",
"aarch64-darwin": "sha256-27xGR9+FVnC0rsUIyepk2tCP1eEUmGvqWUGAZ+rk7IQ=",
"x86_64-darwin": "sha256-+At7bHSeg6QJu6yGawyvzt53Tu/fddDg6Ms+xhaMLhY="
"x86_64-linux": "sha256-5pgd2xuvIIkTbIOGIdK5MIXo6O9qRpvk1RKQZ1e1R+8=",
"aarch64-linux": "sha256-FZiHwihM4b82ipQ9XfW08X+sd5CvZhx/+pU/8X1zsns=",
"aarch64-darwin": "sha256-iZv0w1NthV53pY5uvuf3JlI14GeKmCu7WHwGSRdEQeM=",
"x86_64-darwin": "sha256-c3Zm3P1goFPgg3vNAZPMFOhHX/gyTmsCN/PKbGO/v0E="
}
}

View File

@@ -103,6 +103,7 @@
"@types/node": "catalog:"
},
"patchedDependencies": {
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch"
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch"
}
}

View File

@@ -30,6 +30,9 @@ export const projectMenuTriggerSelector = (slug: string) =>
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
export const projectClearNotificationsSelector = (slug: string) =>
`[data-action="project-clear-notifications"][data-project="${slug}"]`
export const projectWorkspacesToggleSelector = (slug: string) =>
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.2.0",
"version": "1.2.4",
"description": "",
"type": "module",
"exports": {

View File

@@ -21,6 +21,8 @@ import {
import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2"
const MAX_DEPTH = 128
function pathToFileUrl(filepath: string): string {
return `file://${encodeFilePath(filepath)}`
}
@@ -260,12 +262,20 @@ export default function FileTree(props: {
_marks?: Set<string>
_deeps?: Map<string, number>
_kinds?: ReadonlyMap<string, Kind>
_chain?: readonly string[]
}) {
const file = useFile()
const level = props.level ?? 0
const draggable = () => props.draggable ?? true
const tooltip = () => props.tooltip ?? true
const key = (p: string) =>
file
.normalize(p)
.replace(/[\\/]+$/, "")
.replaceAll("\\", "/")
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
const filter = createMemo(() => {
if (props._filter) return props._filter
@@ -307,23 +317,45 @@ export default function FileTree(props: {
const out = new Map<string, number>()
const visit = (dir: string, lvl: number): number => {
const expanded = file.tree.state(dir)?.expanded ?? false
if (!expanded) return -1
const root = props.path
if (!(file.tree.state(root)?.expanded ?? false)) return out
const nodes = file.tree.children(dir)
const max = nodes.reduce((max, node) => {
if (node.type !== "directory") return max
const open = file.tree.state(node.path)?.expanded ?? false
if (!open) return max
return Math.max(max, visit(node.path, lvl + 1))
}, lvl)
const seen = new Set<string>()
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
out.set(dir, max)
return max
const push = (dir: string, lvl: number) => {
const id = key(dir)
if (seen.has(id)) return
seen.add(id)
const kids = file.tree
.children(dir)
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
.map((node) => node.path)
stack.push({ dir, lvl, i: 0, kids, max: lvl })
}
push(root, level - 1)
while (stack.length > 0) {
const top = stack[stack.length - 1]!
if (top.i < top.kids.length) {
const next = top.kids[top.i]!
top.i++
push(next, top.lvl + 1)
continue
}
out.set(top.dir, top.max)
stack.pop()
const parent = stack[stack.length - 1]
if (!parent) continue
parent.max = Math.max(parent.max, top.max)
}
visit(props.path, level - 1)
return out
})
@@ -459,21 +491,27 @@ export default function FileTree(props: {
}}
style={`left: ${Math.max(0, 8 + level * 12 - 4) + 8}px`}
/>
<FileTree
path={node.path}
level={level + 1}
allowed={props.allowed}
modified={props.modified}
kinds={props.kinds}
active={props.active}
draggable={props.draggable}
tooltip={props.tooltip}
onFileClick={props.onFileClick}
_filter={filter()}
_marks={marks()}
_deeps={deeps()}
_kinds={kinds()}
/>
<Show
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
>
<FileTree
path={node.path}
level={level + 1}
allowed={props.allowed}
modified={props.modified}
kinds={props.kinds}
active={props.active}
draggable={props.draggable}
tooltip={props.tooltip}
onFileClick={props.onFileClick}
_filter={filter()}
_marks={marks()}
_deeps={deeps()}
_kinds={kinds()}
_chain={chain}
/>
</Show>
</Collapsible.Content>
</Collapsible>
</Match>

View File

@@ -509,6 +509,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "قم بتوصيل أي موفر لاستخدام النماذج، بما في ذلك Claude و GPT و Gemini وما إلى ذلك.",
"sidebar.project.recentSessions": "الجلسات الحديثة",
"sidebar.project.viewAllSessions": "عرض جميع الجلسات",
"sidebar.project.clearNotifications": "مسح الإشعارات",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "سطح المكتب",
"settings.section.server": "الخادم",

View File

@@ -515,6 +515,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Conecte qualquer provedor para usar modelos, incluindo Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sessões recentes",
"sidebar.project.viewAllSessions": "Ver todas as sessões",
"sidebar.project.clearNotifications": "Limpar notificações",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
"settings.section.server": "Servidor",

View File

@@ -576,6 +576,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Poveži bilo kojeg provajdera da koristiš modele, npr. Claude, GPT, Gemini itd.",
"sidebar.project.recentSessions": "Nedavne sesije",
"sidebar.project.viewAllSessions": "Prikaži sve sesije",
"sidebar.project.clearNotifications": "Očisti obavijesti",
"app.name.desktop": "OpenCode Desktop",

View File

@@ -572,6 +572,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Forbind enhver udbyder for at bruge modeller, inkl. Claude, GPT, Gemini osv.",
"sidebar.project.recentSessions": "Seneste sessioner",
"sidebar.project.viewAllSessions": "Vis alle sessioner",
"sidebar.project.clearNotifications": "Ryd notifikationer",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",

View File

@@ -524,6 +524,7 @@ export const dict = {
"Verbinden Sie einen beliebigen Anbieter, um Modelle wie Claude, GPT, Gemini usw. zu nutzen.",
"sidebar.project.recentSessions": "Letzte Sitzungen",
"sidebar.project.viewAllSessions": "Alle Sitzungen anzeigen",
"sidebar.project.clearNotifications": "Benachrichtigungen löschen",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Desktop",
"settings.section.server": "Server",

View File

@@ -577,6 +577,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Connect any provider to use models, inc. Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Recent sessions",
"sidebar.project.viewAllSessions": "View all sessions",
"sidebar.project.clearNotifications": "Clear notifications",
"app.name.desktop": "OpenCode Desktop",

View File

@@ -579,6 +579,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Conecta cualquier proveedor para usar modelos, inc. Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sesiones recientes",
"sidebar.project.viewAllSessions": "Ver todas las sesiones",
"sidebar.project.clearNotifications": "Borrar notificaciones",
"app.name.desktop": "OpenCode Desktop",

View File

@@ -523,6 +523,7 @@ export const dict = {
"Connectez n'importe quel fournisseur pour utiliser des modèles, y compris Claude, GPT, Gemini etc.",
"sidebar.project.recentSessions": "Sessions récentes",
"sidebar.project.viewAllSessions": "Voir toutes les sessions",
"sidebar.project.clearNotifications": "Effacer les notifications",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Bureau",
"settings.section.server": "Serveur",

View File

@@ -513,6 +513,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "プロバイダーを接続して、Claude、GPT、Geminiなどのモデルを使用できます。",
"sidebar.project.recentSessions": "最近のセッション",
"sidebar.project.viewAllSessions": "すべてのセッションを表示",
"sidebar.project.clearNotifications": "通知をクリア",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "デスクトップ",
"settings.section.server": "サーバー",

View File

@@ -514,6 +514,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Claude, GPT, Gemini 등을 포함한 모델을 사용하려면 공급자를 연결하세요.",
"sidebar.project.recentSessions": "최근 세션",
"sidebar.project.viewAllSessions": "모든 세션 보기",
"sidebar.project.clearNotifications": "알림 지우기",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "데스크톱",
"settings.section.server": "서버",

View File

@@ -579,6 +579,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Koble til en leverandør for å bruke modeller, inkl. Claude, GPT, Gemini osv.",
"sidebar.project.recentSessions": "Nylige sesjoner",
"sidebar.project.viewAllSessions": "Vis alle sesjoner",
"sidebar.project.clearNotifications": "Fjern varsler",
"app.name.desktop": "OpenCode Desktop",

View File

@@ -514,6 +514,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "Połącz dowolnego dostawcę, aby używać modeli, w tym Claude, GPT, Gemini itp.",
"sidebar.project.recentSessions": "Ostatnie sesje",
"sidebar.project.viewAllSessions": "Zobacz wszystkie sesje",
"sidebar.project.clearNotifications": "Wyczyść powiadomienia",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Pulpit",
"settings.section.server": "Serwer",

View File

@@ -578,6 +578,7 @@ export const dict = {
"Подключите любого провайдера для использования моделей, включая Claude, GPT, Gemini и др.",
"sidebar.project.recentSessions": "Недавние сессии",
"sidebar.project.viewAllSessions": "Посмотреть все сессии",
"sidebar.project.clearNotifications": "Очистить уведомления",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "Приложение",

View File

@@ -571,6 +571,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "เชื่อมต่อผู้ให้บริการใด ๆ เพื่อใช้โมเดล รวมถึง Claude, GPT, Gemini ฯลฯ",
"sidebar.project.recentSessions": "เซสชันล่าสุด",
"sidebar.project.viewAllSessions": "ดูเซสชันทั้งหมด",
"sidebar.project.clearNotifications": "ล้างการแจ้งเตือน",
"app.name.desktop": "OpenCode Desktop",

View File

@@ -569,6 +569,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "连接任意提供商即可使用更多模型,如 Claude、GPT、Gemini 等。",
"sidebar.project.recentSessions": "最近会话",
"sidebar.project.viewAllSessions": "查看全部会话",
"sidebar.project.clearNotifications": "清除通知",
"app.name.desktop": "OpenCode Desktop",

View File

@@ -567,6 +567,7 @@ export const dict = {
"sidebar.gettingStarted.line2": "連線任意提供者即可使用更多模型,如 Claude、GPT、Gemini 等。",
"sidebar.project.recentSessions": "最近工作階段",
"sidebar.project.viewAllSessions": "查看全部工作階段",
"sidebar.project.clearNotifications": "清除通知",
"app.name.desktop": "OpenCode Desktop",
"settings.section.desktop": "桌面",

View File

@@ -1692,6 +1692,13 @@ export default function Layout(props: ParentProps) {
})
const projectId = createMemo(() => panelProps.project?.id ?? "")
const workspaces = createMemo(() => workspaceIds(panelProps.project))
const unseenCount = createMemo(() =>
workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
)
const clearNotifications = () =>
workspaces()
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
const workspacesEnabled = createMemo(() => {
const project = panelProps.project
if (!project) return false
@@ -1769,6 +1776,16 @@ export default function Layout(props: ParentProps) {
: language.t("sidebar.workspaces.enable")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(p().worktree)}
disabled={unseenCount() === 0}
onSelect={clearNotifications}
>
<DropdownMenu.ItemLabel>
{language.t("sidebar.project.clearNotifications")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
data-action="project-close-menu"

View File

@@ -10,6 +10,7 @@ import { createSortable } from "@thisbeyond/solid-dnd"
import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
import { projectSelected, projectTileActive } from "./sidebar-project-helpers"
@@ -59,6 +60,7 @@ const ProjectTile = (props: {
selected: Accessor<boolean>
active: Accessor<boolean>
overlay: Accessor<boolean>
dirs: Accessor<string[]>
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void
@@ -70,73 +72,94 @@ const ProjectTile = (props: {
setMenu: (value: boolean) => void
setOpen: (value: boolean) => void
language: ReturnType<typeof useLanguage>
}): JSX.Element => (
<ContextMenu
modal={!props.sidebarHovering()}
onOpenChange={(value) => {
props.setMenu(value)
if (value) props.setOpen(false)
}}
>
<ContextMenu.Trigger
as="button"
type="button"
aria-label={displayName(props.project)}
data-action="project-switch"
data-project={base64Encode(props.project.worktree)}
classList={{
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
!props.selected() && !props.active(),
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
}): JSX.Element => {
const notification = useNotification()
const unseenCount = createMemo(() =>
props.dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
)
const clear = () =>
props
.dirs()
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
return (
<ContextMenu
modal={!props.sidebarHovering()}
onOpenChange={(value) => {
props.setMenu(value)
if (value) props.setOpen(false)
}}
onMouseEnter={(event: MouseEvent) => {
if (!props.overlay()) return
props.onProjectMouseEnter(props.project.worktree, event)
}}
onMouseLeave={() => {
if (!props.overlay()) return
props.onProjectMouseLeave(props.project.worktree)
}}
onFocus={() => {
if (!props.overlay()) return
props.onProjectFocus(props.project.worktree)
}}
onClick={() => props.navigateToProject(props.project.worktree)}
onBlur={() => props.setOpen(false)}
>
<ProjectIcon project={props.project} notify />
</ContextMenu.Trigger>
<ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(props.project.worktree)}
disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
onSelect={() => props.toggleProjectWorkspaces(props.project)}
>
<ContextMenu.ItemLabel>
{props.workspacesEnabled(props.project)
? props.language.t("sidebar.workspaces.disable")
: props.language.t("sidebar.workspaces.enable")}
</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
data-action="project-close-menu"
data-project={base64Encode(props.project.worktree)}
onSelect={() => props.closeProject(props.project.worktree)}
>
<ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
<ContextMenu.Trigger
as="button"
type="button"
aria-label={displayName(props.project)}
data-action="project-switch"
data-project={base64Encode(props.project.worktree)}
classList={{
"flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
"bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": props.selected(),
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
!props.selected() && !props.active(),
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
}}
onMouseEnter={(event: MouseEvent) => {
if (!props.overlay()) return
props.onProjectMouseEnter(props.project.worktree, event)
}}
onMouseLeave={() => {
if (!props.overlay()) return
props.onProjectMouseLeave(props.project.worktree)
}}
onFocus={() => {
if (!props.overlay()) return
props.onProjectFocus(props.project.worktree)
}}
onClick={() => props.navigateToProject(props.project.worktree)}
onBlur={() => props.setOpen(false)}
>
<ProjectIcon project={props.project} notify />
</ContextMenu.Trigger>
<ContextMenu.Portal mount={!props.mobile ? props.nav() : undefined}>
<ContextMenu.Content>
<ContextMenu.Item onSelect={() => props.showEditProjectDialog(props.project)}>
<ContextMenu.ItemLabel>{props.language.t("common.edit")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item
data-action="project-workspaces-toggle"
data-project={base64Encode(props.project.worktree)}
disabled={props.project.vcs !== "git" && !props.workspacesEnabled(props.project)}
onSelect={() => props.toggleProjectWorkspaces(props.project)}
>
<ContextMenu.ItemLabel>
{props.workspacesEnabled(props.project)
? props.language.t("sidebar.workspaces.disable")
: props.language.t("sidebar.workspaces.enable")}
</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Item
data-action="project-clear-notifications"
data-project={base64Encode(props.project.worktree)}
disabled={unseenCount() === 0}
onSelect={clear}
>
<ContextMenu.ItemLabel>{props.language.t("sidebar.project.clearNotifications")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
<ContextMenu.Separator />
<ContextMenu.Item
data-action="project-close-menu"
data-project={base64Encode(props.project.worktree)}
onSelect={() => props.closeProject(props.project.worktree)}
>
<ContextMenu.ItemLabel>{props.language.t("common.close")}</ContextMenu.ItemLabel>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Portal>
</ContextMenu>
)
}
const ProjectPreviewPanel = (props: {
project: LocalProject
@@ -254,6 +277,7 @@ export const SortableProject = (props: {
)
const workspaces = createMemo(() => props.ctx.workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
const [open, setOpen] = createSignal(false)
const [menu, setMenu] = createSignal(false)
@@ -304,6 +328,7 @@ export const SortableProject = (props: {
selected={selected}
active={active}
overlay={overlay}
dirs={dirs}
onProjectMouseEnter={props.ctx.onProjectMouseEnter}
onProjectMouseLeave={props.ctx.onProjectMouseLeave}
onProjectFocus={props.ctx.onProjectFocus}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.2.0",
"version": "1.2.4",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -110,6 +110,7 @@ export async function handler(
providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
...(providerInfo.payloadModifier ?? {}),
}),
)
logger.debug("REQUEST URL: " + reqUrl)
@@ -274,8 +275,8 @@ export async function handler(
part = part.trim()
usageParser.parse(part)
if (providerInfo.bodyModifier) {
for (const [k, v] of Object.entries(providerInfo.bodyModifier)) {
if (providerInfo.responseModifier) {
for (const [k, v] of Object.entries(providerInfo.responseModifier)) {
part = part.replace(k, v)
}
c.enqueue(encoder.encode(part + "\n\n"))
@@ -285,7 +286,7 @@ export async function handler(
}
}
if (!providerInfo.bodyModifier && providerInfo.format === opts.format) {
if (!providerInfo.responseModifier && providerInfo.format === opts.format) {
c.enqueue(value)
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.2.0",
"version": "1.2.4",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -62,12 +62,13 @@ export namespace ZenData {
apiKey: z.string(),
format: FormatSchema.optional(),
headerMappings: z.record(z.string(), z.string()).optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
family: z.string().optional(),
})
const ProviderFamilySchema = z.object({
headers: z.record(z.string(), z.string()).optional(),
bodyModifier: z.record(z.string(), z.string()).optional(),
responseModifier: z.record(z.string(), z.string()).optional(),
})
const ModelsSchema = z.object({

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.2.0",
"version": "1.2.4",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.2.0",
"version": "1.2.4",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.2.0",
"version": "1.2.4",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.2.0",
"version": "1.2.4",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.2.0"
version = "1.2.4"
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.0/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.4/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.0/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.4/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.0/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.4/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.0/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.4/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.0/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.4/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.2.0",
"version": "1.2.4",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.2.0",
"version": "1.2.4",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -62,7 +62,7 @@
"@ai-sdk/deepinfra": "1.0.36",
"@ai-sdk/gateway": "2.0.30",
"@ai-sdk/google": "2.0.52",
"@ai-sdk/google-vertex": "3.0.98",
"@ai-sdk/google-vertex": "3.0.103",
"@ai-sdk/groq": "2.0.34",
"@ai-sdk/mistral": "2.0.27",
"@ai-sdk/openai": "2.0.89",

View File

@@ -19,6 +19,7 @@ import { Global } from "@/global"
import path from "path"
import { Plugin } from "@/plugin"
import { Skill } from "../skill"
import { Reference } from "@/reference"
export namespace Agent {
export const Info = z
@@ -73,6 +74,17 @@ export namespace Agent {
})
const user = PermissionNext.fromConfig(cfg.permission ?? {})
let explorePrompt = PROMPT_EXPLORE
if (cfg.references?.length) {
const refs = cfg.references.map((r) => Reference.parse(r))
const fresh = await Promise.all(refs.map((r) => Reference.ensureFresh(r)))
const valid = fresh.filter(Boolean) as Reference.Info[]
if (valid.length > 0) {
explorePrompt +=
"\n\n<references>\n" + valid.map((r) => `- ${r.url} -> ${r.path}`).join("\n") + "\n</references>"
}
}
const result: Record<string, Info> = {
build: {
name: "build",
@@ -143,12 +155,17 @@ export namespace Agent {
read: "allow",
external_directory: {
[Truncate.GLOB]: "allow",
[path.join(Global.Path.reference, "*")]: "allow",
},
}),
user,
),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE,
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.${
cfg.references?.length
? `\n\nAlways use this to answer questions about the following referenced projects:\n${cfg.references.map((r) => `- ${r}`).join("\n")}`
: ""
}`,
prompt: explorePrompt,
options: {},
mode: "subagent",
native: true,

View File

@@ -15,4 +15,13 @@ Guidelines:
- For clear communication, avoid using emojis
- Do not create any files, or run bash commands that modify the user's system state in any way
Referenced projects:
When configured, you also have access to referenced projects - external codebases that may contain relevant code or patterns. Use these when:
- The user asks about code not found in the main project
- You need to understand how a library or dependency works
- The user mentions an external repository or package
- Searching for patterns across multiple codebases
Search referenced projects by using their absolute paths (provided in a <references> tag) with Glob, Grep, and Read tools.
Complete the user's search request efficiently and report your findings clearly.

View File

@@ -0,0 +1,68 @@
import type { Argv } from "yargs"
import { spawn } from "child_process"
import { Database } from "../../storage/db"
import { Database as BunDatabase } from "bun:sqlite"
import { UI } from "../ui"
import { cmd } from "./cmd"
const QueryCommand = cmd({
command: "$0 [query]",
describe: "open an interactive sqlite3 shell or run a query",
builder: (yargs: Argv) => {
return yargs
.positional("query", {
type: "string",
describe: "SQL query to execute",
})
.option("format", {
type: "string",
choices: ["json", "tsv"],
default: "tsv",
describe: "Output format",
})
},
handler: async (args: { query?: string; format: string }) => {
const query = args.query as string | undefined
if (query) {
const db = new BunDatabase(Database.Path, { readonly: true })
try {
const result = db.query(query).all() as Record<string, unknown>[]
if (args.format === "json") {
console.log(JSON.stringify(result, null, 2))
} else if (result.length > 0) {
const keys = Object.keys(result[0])
console.log(keys.join("\t"))
for (const row of result) {
console.log(keys.map((k) => row[k]).join("\t"))
}
}
} catch (err) {
UI.error(err instanceof Error ? err.message : String(err))
process.exit(1)
}
db.close()
return
}
const child = spawn("sqlite3", [Database.Path], {
stdio: "inherit",
})
await new Promise((resolve) => child.on("close", resolve))
},
})
const PathCommand = cmd({
command: "path",
describe: "print the database path",
handler: () => {
console.log(Database.Path)
},
})
export const DbCommand = cmd({
command: "db",
describe: "database tools",
builder: (yargs: Argv) => {
return yargs.command(QueryCommand).command(PathCommand).demandCommand()
},
handler: () => {},
})

View File

@@ -1192,6 +1192,10 @@ export namespace Config {
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
})
.optional(),
references: z
.array(z.string())
.optional()
.describe("Git repositories or local paths to reference from subagents"),
})
.strict()
.meta({

View File

@@ -20,6 +20,7 @@ export namespace Global {
bin: path.join(data, "bin"),
log: path.join(data, "log"),
cache,
reference: path.join(cache, "references"),
config,
state,
}
@@ -31,6 +32,7 @@ await Promise.all([
fs.mkdir(Global.Path.state, { recursive: true }),
fs.mkdir(Global.Path.log, { recursive: true }),
fs.mkdir(Global.Path.bin, { recursive: true }),
fs.mkdir(Global.Path.reference, { recursive: true }),
])
const CACHE_VERSION = "21"

View File

@@ -26,6 +26,7 @@ import { EOL } from "os"
import { WebCommand } from "./cli/cmd/web"
import { PrCommand } from "./cli/cmd/pr"
import { SessionCommand } from "./cli/cmd/session"
import { DbCommand } from "./cli/cmd/db"
import path from "path"
import { Global } from "./global"
import { JsonMigration } from "./storage/json-migration"
@@ -138,6 +139,7 @@ const cli = yargs(hideBin(process.argv))
.command(GithubCommand)
.command(PrCommand)
.command(SessionCommand)
.command(DbCommand)
.fail((msg, err) => {
if (
msg?.startsWith("Unknown argument") ||

View File

@@ -171,7 +171,7 @@ export namespace ProviderTransform {
return msgs
}
function applyCaching(msgs: ModelMessage[], providerID: string): ModelMessage[] {
function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
const system = msgs.filter((msg) => msg.role === "system").slice(0, 2)
const final = msgs.filter((msg) => msg.role !== "system").slice(-2)
@@ -194,7 +194,7 @@ export namespace ProviderTransform {
}
for (const msg of unique([...system, ...final])) {
const useMessageLevelOptions = providerID === "anthropic" || providerID.includes("bedrock")
const useMessageLevelOptions = model.providerID === "anthropic" || model.providerID.includes("bedrock")
const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0
if (shouldUseContentOptions) {
@@ -253,14 +253,15 @@ export namespace ProviderTransform {
msgs = unsupportedParts(msgs, model)
msgs = normalizeMessages(msgs, model, options)
if (
model.providerID === "anthropic" ||
model.api.id.includes("anthropic") ||
model.api.id.includes("claude") ||
model.id.includes("anthropic") ||
model.id.includes("claude") ||
model.api.npm === "@ai-sdk/anthropic"
(model.providerID === "anthropic" ||
model.api.id.includes("anthropic") ||
model.api.id.includes("claude") ||
model.id.includes("anthropic") ||
model.id.includes("claude") ||
model.api.npm === "@ai-sdk/anthropic") &&
model.api.npm !== "@ai-sdk/gateway"
) {
msgs = applyCaching(msgs, model.providerID)
msgs = applyCaching(msgs, model)
}
// Remap providerOptions keys from stored providerID to expected SDK key
@@ -360,11 +361,53 @@ export namespace ProviderTransform {
switch (model.api.npm) {
case "@openrouter/ai-sdk-provider":
if (!model.id.includes("gpt") && !model.id.includes("gemini-3")) return {}
if (!model.id.includes("gpt") && !model.id.includes("gemini-3") && !model.id.includes("claude")) return {}
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }]))
// TODO: YOU CANNOT SET max_tokens if this is set!!!
case "@ai-sdk/gateway":
if (model.id.includes("anthropic")) {
return {
high: {
thinking: {
type: "enabled",
budgetTokens: 16000,
},
},
max: {
thinking: {
type: "enabled",
budgetTokens: 31999,
},
},
}
}
if (model.id.includes("google")) {
if (id.includes("2.5")) {
return {
high: {
thinkingConfig: {
includeThoughts: true,
thinkingBudget: 16000,
},
},
max: {
thinkingConfig: {
includeThoughts: true,
thinkingBudget: 24576,
},
},
}
}
return Object.fromEntries(
["low", "high"].map((effort) => [
effort,
{
includeThoughts: true,
thinkingLevel: effort,
},
]),
)
}
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
case "@ai-sdk/github-copilot":
@@ -720,6 +763,15 @@ export namespace ProviderTransform {
result["promptCacheKey"] = input.sessionID
}
if (input.model.providerID === "openrouter") {
result["prompt_cache_key"] = input.sessionID
}
if (input.model.api.npm === "@ai-sdk/gateway") {
result["gateway"] = {
caching: "auto",
}
}
return result
}
@@ -753,7 +805,43 @@ export namespace ProviderTransform {
return {}
}
// Maps model ID prefix to provider slug used in providerOptions.
// Example: "amazon/nova-2-lite" → "bedrock"
const SLUG_OVERRIDES: Record<string, string> = {
amazon: "bedrock",
}
export function providerOptions(model: Provider.Model, options: { [x: string]: any }) {
if (model.api.npm === "@ai-sdk/gateway") {
// Gateway providerOptions are split across two namespaces:
// - `gateway`: gateway-native routing/caching controls (order, only, byok, etc.)
// - `<upstream slug>`: provider-specific model options (anthropic/openai/...)
// We keep `gateway` as-is and route every other top-level option under the
// model-derived upstream slug.
const i = model.api.id.indexOf("/")
const rawSlug = i > 0 ? model.api.id.slice(0, i) : undefined
const slug = rawSlug ? (SLUG_OVERRIDES[rawSlug] ?? rawSlug) : undefined
const gateway = options.gateway
const rest = Object.fromEntries(Object.entries(options).filter(([k]) => k !== "gateway"))
const has = Object.keys(rest).length > 0
const result: Record<string, any> = {}
if (gateway !== undefined) result.gateway = gateway
if (has) {
if (slug) {
// Route model-specific options under the provider slug
result[slug] = rest
} else if (gateway && typeof gateway === "object" && !Array.isArray(gateway)) {
result.gateway = { ...gateway, ...rest }
} else {
result.gateway = rest
}
}
return result
}
const key = sdkKey(model.api.npm) ?? model.providerID
return { [key]: options }
}

View File

@@ -0,0 +1,131 @@
import path from "path"
import { mkdir, stat } from "fs/promises"
import { createHash } from "crypto"
import { Global } from "../global"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { git } from "../util/git"
import { Instance } from "../project/instance"
export namespace Reference {
const log = Log.create({ service: "reference" })
const STALE_THRESHOLD_MS = 60 * 60 * 1000
export interface Info {
url: string
path: string
branch?: string
type: "git" | "local"
}
function hashUrl(url: string): string {
return createHash("sha256").update(url).digest("hex").slice(0, 16)
}
export function parse(url: string): Info {
if (url.startsWith("/") || url.startsWith("~") || url.startsWith(".")) {
const resolved = url.startsWith("~")
? path.join(Global.Path.home, url.slice(1))
: url.startsWith(".")
? path.resolve(Instance.worktree, url)
: url
return {
url,
path: resolved,
type: "local",
}
}
const branchMatch = url.match(/^(.+)#(.+)$/)
const gitUrl = branchMatch ? branchMatch[1] : url
const branch = branchMatch ? branchMatch[2] : undefined
return {
url: gitUrl,
path: path.join(Global.Path.reference, hashUrl(gitUrl)),
branch,
type: "git",
}
}
export async function isStale(ref: Info): Promise<boolean> {
if (ref.type === "local") return false
const fetchHead = path.join(ref.path, ".git", "FETCH_HEAD")
const s = await stat(fetchHead).catch(() => null)
if (!s) return true
return Date.now() - s.mtime.getTime() > STALE_THRESHOLD_MS
}
export async function fetch(ref: Info): Promise<boolean> {
if (ref.type === "local") {
const exists = await stat(ref.path).catch(() => null)
if (!exists?.isDirectory()) {
log.error("local reference not found", { path: ref.path })
return false
}
return true
}
await mkdir(path.dirname(ref.path), { recursive: true })
const isCloned = await stat(path.join(ref.path, ".git")).catch(() => null)
if (!isCloned) {
log.info("cloning reference", { url: ref.url, branch: ref.branch })
const args = ["clone", "--depth", "1"]
if (ref.branch) {
args.push("--branch", ref.branch)
}
args.push(ref.url, ref.path)
const result = await git(args, { cwd: Global.Path.reference })
if (result.exitCode !== 0) {
log.error("failed to clone", { url: ref.url, stderr: result.stderr.toString() })
return false
}
return true
}
log.info("fetching reference", { url: ref.url })
const fetchResult = await git(["fetch"], { cwd: ref.path })
if (fetchResult.exitCode !== 0) {
log.warn("failed to fetch, using cached", { url: ref.url })
return true
}
if (ref.branch) {
const checkoutResult = await git(["checkout", ref.branch], { cwd: ref.path })
if (checkoutResult.exitCode !== 0) {
log.warn("failed to checkout branch, using current", { url: ref.url, branch: ref.branch })
}
}
return true
}
export async function ensureFresh(ref: Info): Promise<Info | null> {
if (await isStale(ref)) {
const success = await fetch(ref)
if (!success && ref.type === "git") {
const exists = await stat(ref.path).catch(() => null)
if (!exists) return null
}
}
return ref
}
export async function list(): Promise<Info[]> {
const cfg = await Config.get()
const urls = cfg.references ?? []
return urls.map(parse)
}
export async function directories(): Promise<string[]> {
const refs = await list()
const fresh = await Promise.all(refs.map(ensureFresh))
return fresh.filter(Boolean).map((r) => r!.path)
}
}

View File

@@ -53,15 +53,15 @@ export const SessionRoutes = lazy(() =>
),
async (c) => {
const query = c.req.valid("query")
const term = query.search?.toLowerCase()
const sessions: Session.Info[] = []
for await (const session of Session.list()) {
if (query.directory !== undefined && session.directory !== query.directory) continue
if (query.roots && session.parentID) continue
if (query.start !== undefined && session.time.updated < query.start) continue
if (term !== undefined && !session.title.toLowerCase().includes(term)) continue
for await (const session of Session.list({
directory: query.directory,
roots: query.roots,
start: query.start,
search: query.search,
limit: query.limit,
})) {
sessions.push(session)
if (query.limit !== undefined && sessions.length >= query.limit) break
}
return c.json(sessions)
},

View File

@@ -10,7 +10,7 @@ import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
import { Installation } from "../installation"
import { Database, NotFoundError, eq, and, or, like } from "../storage/db"
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like } from "../storage/db"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { Storage } from "@/storage/storage"
import { Log } from "../util/log"
@@ -505,20 +505,38 @@ export namespace Session {
},
)
export function* list() {
export function* list(input?: {
directory?: string
roots?: boolean
start?: number
search?: string
limit?: number
}) {
const project = Instance.project
const rel = path.relative(Instance.worktree, Instance.directory)
const suffix = path.sep + rel
const conditions = [eq(SessionTable.project_id, project.id)]
if (input?.directory) {
conditions.push(eq(SessionTable.directory, input.directory))
}
if (input?.roots) {
conditions.push(isNull(SessionTable.parent_id))
}
if (input?.start) {
conditions.push(gte(SessionTable.time_updated, input.start))
}
if (input?.search) {
conditions.push(like(SessionTable.title, `%${input.search}%`))
}
const limit = input?.limit ?? 100
const rows = Database.use((db) =>
db
.select()
.from(SessionTable)
.where(
and(
eq(SessionTable.project_id, project.id),
or(eq(SessionTable.directory, Instance.directory), like(SessionTable.directory, `%${suffix}`)),
),
)
.where(and(...conditions))
.orderBy(desc(SessionTable.time_updated))
.limit(limit)
.all(),
)
for (const row of rows) {

View File

@@ -25,6 +25,7 @@ export const NotFoundError = NamedError.create(
const log = Log.create({ service: "db" })
export namespace Database {
export const Path = path.join(Global.Path.data, "opencode.db")
type Schema = typeof schema
export type Transaction = SQLiteTransaction<"sync", void, Schema>
@@ -74,6 +75,7 @@ export namespace Database {
sqlite.run("PRAGMA busy_timeout = 5000")
sqlite.run("PRAGMA cache_size = -64000")
sqlite.run("PRAGMA foreign_keys = ON")
sqlite.run("PRAGMA wal_checkpoint(PASSIVE)")
const db = drizzle({ client: sqlite, schema })

View File

@@ -152,6 +152,7 @@ export namespace JsonMigration {
sqlite.exec("BEGIN TRANSACTION")
// Migrate projects first (no FK deps)
// Derive all IDs from file paths, not JSON content
const projectIds = new Set<string>()
const projectValues = [] as any[]
for (let i = 0; i < projectFiles.length; i += batchSize) {
@@ -161,13 +162,10 @@ export namespace JsonMigration {
for (let j = 0; j < batch.length; j++) {
const data = batch[j]
if (!data) continue
if (!data?.id) {
errs.push(`project missing id: ${projectFiles[i + j]}`)
continue
}
projectIds.add(data.id)
const id = path.basename(projectFiles[i + j], ".json")
projectIds.add(id)
projectValues.push({
id: data.id,
id,
worktree: data.worktree ?? "/",
vcs: data.vcs,
name: data.name ?? undefined,
@@ -186,6 +184,9 @@ export namespace JsonMigration {
log.info("migrated projects", { count: stats.projects, duration: Math.round(performance.now() - start) })
// Migrate sessions (depends on projects)
// Derive all IDs from directory/file paths, not JSON content, since earlier
// migrations may have moved sessions to new directories without updating the JSON
const sessionProjects = sessionFiles.map((file) => path.basename(path.dirname(file)))
const sessionIds = new Set<string>()
const sessionValues = [] as any[]
for (let i = 0; i < sessionFiles.length; i += batchSize) {
@@ -195,18 +196,16 @@ export namespace JsonMigration {
for (let j = 0; j < batch.length; j++) {
const data = batch[j]
if (!data) continue
if (!data?.id || !data?.projectID) {
errs.push(`session missing id or projectID: ${sessionFiles[i + j]}`)
continue
}
if (!projectIds.has(data.projectID)) {
const id = path.basename(sessionFiles[i + j], ".json")
const projectID = sessionProjects[i + j]
if (!projectIds.has(projectID)) {
orphans.sessions++
continue
}
sessionIds.add(data.id)
sessionIds.add(id)
sessionValues.push({
id: data.id,
project_id: data.projectID,
id,
project_id: projectID,
parent_id: data.parentID ?? null,
slug: data.slug ?? "",
directory: data.directory ?? "",
@@ -253,11 +252,7 @@ export namespace JsonMigration {
const data = batch[j]
if (!data) continue
const file = allMessageFiles[i + j]
const id = data.id ?? path.basename(file, ".json")
if (!id) {
errs.push(`message missing id: ${file}`)
continue
}
const id = path.basename(file, ".json")
const sessionID = allMessageSessions[i + j]
messageSessions.set(id, sessionID)
const rest = data
@@ -287,12 +282,8 @@ export namespace JsonMigration {
const data = batch[j]
if (!data) continue
const file = partFiles[i + j]
const id = data.id ?? path.basename(file, ".json")
const messageID = data.messageID ?? path.basename(path.dirname(file))
if (!id || !messageID) {
errs.push(`part missing id/messageID/sessionID: ${file}`)
continue
}
const id = path.basename(file, ".json")
const messageID = path.basename(path.dirname(file))
const sessionID = messageSessions.get(messageID)
if (!sessionID) {
errs.push(`part missing message session: ${file}`)

View File

@@ -40,7 +40,7 @@ interface McpSearchResponse {
export const WebSearchTool = Tool.define("websearch", async () => {
return {
get description() {
return DESCRIPTION.replace("{{date}}", new Date().toISOString().slice(0, 10))
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
},
parameters: z.object({
query: z.string().describe("Websearch query"),

View File

@@ -10,5 +10,5 @@ Usage notes:
- Configurable context length for optimal LLM integration
- Domain filtering and advanced search options available
Today's date is {{date}}. You MUST use this year when searching for recent information or current events
- Example: If today is 2025-07-15 and the user asks for "latest AI news", search for "AI news 2025", NOT "AI news 2024"
The current year is {{year}}. You MUST use this year when searching for recent information or current events
- Example: If the current year is 2026 and the user asks for "latest AI news", search for "AI news 2026", NOT "AI news 2025"

View File

@@ -175,6 +175,204 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
})
})
describe("ProviderTransform.options - gateway", () => {
const sessionID = "test-session-123"
const createModel = (id: string) =>
({
id,
providerID: "vercel",
api: {
id,
url: "https://ai-gateway.vercel.sh/v3/ai",
npm: "@ai-sdk/gateway",
},
name: id,
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0.001,
output: 0.002,
cache: { read: 0.0001, write: 0.0002 },
},
limit: {
context: 200_000,
output: 8192,
},
status: "active",
options: {},
headers: {},
release_date: "2024-01-01",
}) as any
test("puts gateway defaults under gateway key", () => {
const model = createModel("anthropic/claude-sonnet-4")
const result = ProviderTransform.options({ model, sessionID, providerOptions: {} })
expect(result).toEqual({
gateway: {
caching: "auto",
},
})
})
})
describe("ProviderTransform.providerOptions", () => {
const createModel = (overrides: Partial<any> = {}) =>
({
id: "test/test-model",
providerID: "test",
api: {
id: "test-model",
url: "https://api.test.com",
npm: "@ai-sdk/openai",
},
name: "Test Model",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0.001,
output: 0.002,
cache: { read: 0.0001, write: 0.0002 },
},
limit: {
context: 200_000,
output: 64_000,
},
status: "active",
options: {},
headers: {},
release_date: "2024-01-01",
...overrides,
}) as any
test("uses sdk key for non-gateway models", () => {
const model = createModel({
providerID: "my-bedrock",
api: {
id: "anthropic.claude-sonnet-4",
url: "https://bedrock.aws",
npm: "@ai-sdk/amazon-bedrock",
},
})
expect(ProviderTransform.providerOptions(model, { cachePoint: { type: "default" } })).toEqual({
bedrock: { cachePoint: { type: "default" } },
})
})
test("uses gateway model provider slug for gateway models", () => {
const model = createModel({
providerID: "vercel",
api: {
id: "anthropic/claude-sonnet-4",
url: "https://ai-gateway.vercel.sh/v3/ai",
npm: "@ai-sdk/gateway",
},
})
expect(ProviderTransform.providerOptions(model, { thinking: { type: "enabled", budgetTokens: 12_000 } })).toEqual({
anthropic: { thinking: { type: "enabled", budgetTokens: 12_000 } },
})
})
test("falls back to gateway key when gateway api id is unscoped", () => {
const model = createModel({
id: "anthropic/claude-sonnet-4",
providerID: "vercel",
api: {
id: "claude-sonnet-4",
url: "https://ai-gateway.vercel.sh/v3/ai",
npm: "@ai-sdk/gateway",
},
})
expect(ProviderTransform.providerOptions(model, { thinking: { type: "enabled", budgetTokens: 12_000 } })).toEqual({
gateway: { thinking: { type: "enabled", budgetTokens: 12_000 } },
})
})
test("splits gateway routing options from provider-specific options", () => {
const model = createModel({
providerID: "vercel",
api: {
id: "anthropic/claude-sonnet-4",
url: "https://ai-gateway.vercel.sh/v3/ai",
npm: "@ai-sdk/gateway",
},
})
expect(
ProviderTransform.providerOptions(model, {
gateway: { order: ["vertex", "anthropic"] },
thinking: { type: "enabled", budgetTokens: 12_000 },
}),
).toEqual({
gateway: { order: ["vertex", "anthropic"] },
anthropic: { thinking: { type: "enabled", budgetTokens: 12_000 } },
} as any)
})
test("falls back to gateway key when model id has no provider slug", () => {
const model = createModel({
id: "claude-sonnet-4",
providerID: "vercel",
api: {
id: "claude-sonnet-4",
url: "https://ai-gateway.vercel.sh/v3/ai",
npm: "@ai-sdk/gateway",
},
})
expect(ProviderTransform.providerOptions(model, { reasoningEffort: "high" })).toEqual({
gateway: { reasoningEffort: "high" },
})
})
test("maps amazon slug to bedrock for provider options", () => {
const model = createModel({
providerID: "vercel",
api: {
id: "amazon/nova-2-lite",
url: "https://ai-gateway.vercel.sh/v3/ai",
npm: "@ai-sdk/gateway",
},
})
expect(ProviderTransform.providerOptions(model, { reasoningConfig: { type: "enabled" } })).toEqual({
bedrock: { reasoningConfig: { type: "enabled" } },
})
})
test("uses groq slug for groq models", () => {
const model = createModel({
providerID: "vercel",
api: {
id: "groq/llama-3.3-70b-versatile",
url: "https://ai-gateway.vercel.sh/v3/ai",
npm: "@ai-sdk/gateway",
},
})
expect(ProviderTransform.providerOptions(model, { reasoningFormat: "parsed" })).toEqual({
groq: { reasoningFormat: "parsed" },
})
})
})
describe("ProviderTransform.schema - gemini array items", () => {
test("adds missing items for array properties", () => {
const geminiModel = {
@@ -1232,6 +1430,105 @@ describe("ProviderTransform.message - claude w/bedrock custom inference profile"
})
})
describe("ProviderTransform.message - cache control on gateway", () => {
const createModel = (overrides: Partial<any> = {}) =>
({
id: "anthropic/claude-sonnet-4",
providerID: "vercel",
api: {
id: "anthropic/claude-sonnet-4",
url: "https://ai-gateway.vercel.sh/v3/ai",
npm: "@ai-sdk/gateway",
},
name: "Claude Sonnet 4",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } },
limit: { context: 200_000, output: 8192 },
status: "active",
options: {},
headers: {},
...overrides,
}) as any
test("gateway does not set cache control for anthropic models", () => {
const model = createModel()
const msgs = [
{
role: "system",
content: [{ type: "text", text: "You are a helpful assistant" }],
},
{
role: "user",
content: "Hello",
},
] as any[]
const result = ProviderTransform.message(msgs, model, {}) as any[]
expect(result[0].content[0].providerOptions).toBeUndefined()
expect(result[0].providerOptions).toBeUndefined()
})
test("non-gateway anthropic keeps existing cache control behavior", () => {
const model = createModel({
providerID: "anthropic",
api: {
id: "claude-sonnet-4",
url: "https://api.anthropic.com",
npm: "@ai-sdk/anthropic",
},
})
const msgs = [
{
role: "system",
content: "You are a helpful assistant",
},
{
role: "user",
content: "Hello",
},
] as any[]
const result = ProviderTransform.message(msgs, model, {}) as any[]
expect(result[0].providerOptions).toEqual({
anthropic: {
cacheControl: {
type: "ephemeral",
},
},
openrouter: {
cacheControl: {
type: "ephemeral",
},
},
bedrock: {
cachePoint: {
type: "default",
},
},
openaiCompatible: {
cache_control: {
type: "ephemeral",
},
},
copilot: {
copilot_cache_control: {
type: "ephemeral",
},
},
})
})
})
describe("ProviderTransform.variants", () => {
const createMockModel = (overrides: Partial<any> = {}): any => ({
id: "test/test-model",
@@ -1408,6 +1705,32 @@ describe("ProviderTransform.variants", () => {
})
describe("@ai-sdk/gateway", () => {
test("anthropic models return anthropic thinking options", () => {
const model = createMockModel({
id: "anthropic/claude-sonnet-4",
providerID: "gateway",
api: {
id: "anthropic/claude-sonnet-4",
url: "https://gateway.ai",
npm: "@ai-sdk/gateway",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["high", "max"])
expect(result.high).toEqual({
thinking: {
type: "enabled",
budgetTokens: 16000,
},
})
expect(result.max).toEqual({
thinking: {
type: "enabled",
budgetTokens: 31999,
},
})
})
test("returns OPENAI_EFFORTS with reasoningEffort", () => {
const model = createMockModel({
id: "gateway/gateway-model",

View File

@@ -1,20 +1,17 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Instance } from "../../src/project/instance"
import { Server } from "../../src/server/server"
import { Session } from "../../src/session"
import { Log } from "../../src/util/log"
const projectRoot = path.join(__dirname, "../..")
Log.init({ print: false })
describe("session.list", () => {
describe("Session.list", () => {
test("filters by directory", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const app = Server.App()
const first = await Session.create({})
const otherDir = path.join(projectRoot, "..", "__session_list_other")
@@ -23,17 +20,71 @@ describe("session.list", () => {
fn: async () => Session.create({}),
})
const response = await app.request(`/session?directory=${encodeURIComponent(projectRoot)}`)
expect(response.status).toBe(200)
const body = (await response.json()) as unknown[]
const ids = body
.map((s) => (typeof s === "object" && s && "id" in s ? (s as { id: string }).id : undefined))
.filter((x): x is string => typeof x === "string")
const sessions = [...Session.list({ directory: projectRoot })]
const ids = sessions.map((s) => s.id)
expect(ids).toContain(first.id)
expect(ids).not.toContain(second.id)
},
})
})
test("filters root sessions", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const root = await Session.create({ title: "root-session" })
const child = await Session.create({ title: "child-session", parentID: root.id })
const sessions = [...Session.list({ roots: true })]
const ids = sessions.map((s) => s.id)
expect(ids).toContain(root.id)
expect(ids).not.toContain(child.id)
},
})
})
test("filters by start time", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const session = await Session.create({ title: "new-session" })
const futureStart = Date.now() + 86400000
const sessions = [...Session.list({ start: futureStart })]
expect(sessions.length).toBe(0)
},
})
})
test("filters by search term", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
await Session.create({ title: "unique-search-term-abc" })
await Session.create({ title: "other-session-xyz" })
const sessions = [...Session.list({ search: "unique-search" })]
const titles = sessions.map((s) => s.title)
expect(titles).toContain("unique-search-term-abc")
expect(titles).not.toContain("other-session-xyz")
},
})
})
test("respects limit parameter", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
await Session.create({ title: "session-1" })
await Session.create({ title: "session-2" })
await Session.create({ title: "session-3" })
const sessions = [...Session.list({ limit: 2 })]
expect(sessions.length).toBe(2)
},
})
})
})

View File

@@ -128,6 +128,28 @@ describe("JSON to SQLite migration", () => {
expect(projects[0].sandboxes).toEqual(["/test/sandbox"])
})
test("uses filename for project id when JSON has different value", async () => {
await Bun.write(
path.join(storageDir, "project", "proj_filename.json"),
JSON.stringify({
id: "proj_different_in_json", // Stale! Should be ignored
worktree: "/test/path",
vcs: "git",
name: "Test Project",
sandboxes: [],
}),
)
const stats = await JsonMigration.run(sqlite)
expect(stats?.projects).toBe(1)
const db = drizzle({ client: sqlite })
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe("proj_filename") // Uses filename, not JSON id
})
test("migrates project with commands", async () => {
await writeProject(storageDir, {
id: "proj_with_commands",
@@ -285,6 +307,74 @@ describe("JSON to SQLite migration", () => {
expect(parts[0].data).not.toHaveProperty("sessionID")
})
test("uses filename for message id when JSON has different value", async () => {
await writeProject(storageDir, {
id: "proj_test123abc",
worktree: "/",
time: { created: Date.now(), updated: Date.now() },
sandboxes: [],
})
await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
await Bun.write(
path.join(storageDir, "message", "ses_test456def", "msg_from_filename.json"),
JSON.stringify({
id: "msg_different_in_json", // Stale! Should be ignored
sessionID: "ses_test456def",
role: "user",
agent: "default",
time: { created: 1700000000000 },
}),
)
const stats = await JsonMigration.run(sqlite)
expect(stats?.messages).toBe(1)
const db = drizzle({ client: sqlite })
const messages = db.select().from(MessageTable).all()
expect(messages.length).toBe(1)
expect(messages[0].id).toBe("msg_from_filename") // Uses filename, not JSON id
expect(messages[0].session_id).toBe("ses_test456def")
})
test("uses paths for part id and messageID when JSON has different values", async () => {
await writeProject(storageDir, {
id: "proj_test123abc",
worktree: "/",
time: { created: Date.now(), updated: Date.now() },
sandboxes: [],
})
await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
await Bun.write(
path.join(storageDir, "message", "ses_test456def", "msg_realmsgid.json"),
JSON.stringify({
role: "user",
agent: "default",
time: { created: 1700000000000 },
}),
)
await Bun.write(
path.join(storageDir, "part", "msg_realmsgid", "prt_from_filename.json"),
JSON.stringify({
id: "prt_different_in_json", // Stale! Should be ignored
messageID: "msg_different_in_json", // Stale! Should be ignored
sessionID: "ses_test456def",
type: "text",
text: "Hello",
}),
)
const stats = await JsonMigration.run(sqlite)
expect(stats?.parts).toBe(1)
const db = drizzle({ client: sqlite })
const parts = db.select().from(PartTable).all()
expect(parts.length).toBe(1)
expect(parts[0].id).toBe("prt_from_filename") // Uses filename, not JSON id
expect(parts[0].message_id).toBe("msg_realmsgid") // Uses parent dir, not JSON messageID
})
test("skips orphaned sessions (no parent project)", async () => {
await Bun.write(
path.join(storageDir, "session", "proj_test123abc", "ses_orphan.json"),
@@ -304,6 +394,72 @@ describe("JSON to SQLite migration", () => {
expect(stats?.sessions).toBe(0)
})
test("uses directory path for projectID when JSON has stale value", async () => {
// Simulates the scenario where earlier migration moved sessions to new
// git-based project directories but didn't update the projectID field
const gitBasedProjectID = "abc123gitcommit"
await writeProject(storageDir, {
id: gitBasedProjectID,
worktree: "/test/path",
vcs: "git",
time: { created: Date.now(), updated: Date.now() },
sandboxes: [],
})
// Session is in the git-based directory but JSON still has old projectID
await writeSession(storageDir, gitBasedProjectID, {
id: "ses_migrated",
projectID: "old-project-name", // Stale! Should be ignored
slug: "migrated-session",
directory: "/test/path",
title: "Migrated Session",
version: "1.0.0",
time: { created: 1700000000000, updated: 1700000001000 },
})
const stats = await JsonMigration.run(sqlite)
expect(stats?.sessions).toBe(1)
const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe("ses_migrated")
expect(sessions[0].project_id).toBe(gitBasedProjectID) // Uses directory, not stale JSON
})
test("uses filename for session id when JSON has different value", async () => {
await writeProject(storageDir, {
id: "proj_test123abc",
worktree: "/test/path",
time: { created: Date.now(), updated: Date.now() },
sandboxes: [],
})
await Bun.write(
path.join(storageDir, "session", "proj_test123abc", "ses_from_filename.json"),
JSON.stringify({
id: "ses_different_in_json", // Stale! Should be ignored
projectID: "proj_test123abc",
slug: "test-session",
directory: "/test/path",
title: "Test Session",
version: "1.0.0",
time: { created: 1700000000000, updated: 1700000001000 },
}),
)
const stats = await JsonMigration.run(sqlite)
expect(stats?.sessions).toBe(1)
const db = drizzle({ client: sqlite })
const sessions = db.select().from(SessionTable).all()
expect(sessions.length).toBe(1)
expect(sessions[0].id).toBe("ses_from_filename") // Uses filename, not JSON id
expect(sessions[0].project_id).toBe("proj_test123abc")
})
test("is idempotent (running twice doesn't duplicate)", async () => {
await writeProject(storageDir, {
id: "proj_test123abc",
@@ -666,8 +822,11 @@ describe("JSON to SQLite migration", () => {
const stats = await JsonMigration.run(sqlite)
expect(stats.projects).toBe(1)
expect(stats.sessions).toBe(1)
// Projects: proj_test123abc (valid), proj_missing_id (now derives id from filename)
// Sessions: ses_test456def (valid), ses_missing_project (now uses dir path),
// ses_orphan (now uses dir path, ignores stale projectID)
expect(stats.projects).toBe(2)
expect(stats.sessions).toBe(3)
expect(stats.messages).toBe(1)
expect(stats.parts).toBe(1)
expect(stats.todos).toBe(1)
@@ -676,8 +835,8 @@ describe("JSON to SQLite migration", () => {
expect(stats.errors.length).toBeGreaterThanOrEqual(6)
const db = drizzle({ client: sqlite })
expect(db.select().from(ProjectTable).all().length).toBe(1)
expect(db.select().from(SessionTable).all().length).toBe(1)
expect(db.select().from(ProjectTable).all().length).toBe(2)
expect(db.select().from(SessionTable).all().length).toBe(3)
expect(db.select().from(MessageTable).all().length).toBe(1)
expect(db.select().from(PartTable).all().length).toBe(1)
expect(db.select().from(TodoTable).all().length).toBe(1)

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.2.0",
"version": "1.2.4",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.2.0",
"version": "1.2.4",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.2.0",
"version": "1.2.4",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.2.0",
"version": "1.2.4",
"type": "module",
"license": "MIT",
"exports": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.2.0",
"version": "1.2.4",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.2.0",
"version": "1.2.4",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View File

@@ -82,8 +82,10 @@ You can also access our models through the following API endpoints.
| Claude Opus 4.1 | claude-opus-4-1 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| 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.1 | minimax-m2.1 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| 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` |
@@ -117,7 +119,9 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| --------------------------------- | ------ | ------ | ----------- | ------------ |
| Big Pickle | Free | Free | Free | - |
| MiniMax M2.5 Free | Free | Free | Free | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | - |
| 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 Free | Free | Free | Free | - |

View File

@@ -0,0 +1,128 @@
diff --git a/dist/index.js b/dist/index.js
index f33510a50d11a2cb92a90ea70cc0ac84c89f29b9..e887a60352c0c08ab794b1e6821854dfeefd20cc 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -2110,7 +2110,12 @@ var OpenRouterChatLanguageModel = class {
if (reasoningStarted && !textStarted) {
controller.enqueue({
type: "reasoning-end",
- id: reasoningId || generateId()
+ id: reasoningId || generateId(),
+ providerMetadata: accumulatedReasoningDetails.length > 0 ? {
+ openrouter: {
+ reasoning_details: accumulatedReasoningDetails
+ }
+ } : undefined
});
reasoningStarted = false;
}
@@ -2307,7 +2312,12 @@ var OpenRouterChatLanguageModel = class {
if (reasoningStarted) {
controller.enqueue({
type: "reasoning-end",
- id: reasoningId || generateId()
+ id: reasoningId || generateId(),
+ providerMetadata: accumulatedReasoningDetails.length > 0 ? {
+ openrouter: {
+ reasoning_details: accumulatedReasoningDetails
+ }
+ } : undefined
});
}
if (textStarted) {
diff --git a/dist/index.mjs b/dist/index.mjs
index 8a688331b88b4af738ee4ca8062b5f24124d3d81..6310cb8b7c8d0a728d86e1eed09906c6b4c91ae2 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -2075,7 +2075,12 @@ var OpenRouterChatLanguageModel = class {
if (reasoningStarted && !textStarted) {
controller.enqueue({
type: "reasoning-end",
- id: reasoningId || generateId()
+ id: reasoningId || generateId(),
+ providerMetadata: accumulatedReasoningDetails.length > 0 ? {
+ openrouter: {
+ reasoning_details: accumulatedReasoningDetails
+ }
+ } : undefined
});
reasoningStarted = false;
}
@@ -2272,7 +2277,12 @@ var OpenRouterChatLanguageModel = class {
if (reasoningStarted) {
controller.enqueue({
type: "reasoning-end",
- id: reasoningId || generateId()
+ id: reasoningId || generateId(),
+ providerMetadata: accumulatedReasoningDetails.length > 0 ? {
+ openrouter: {
+ reasoning_details: accumulatedReasoningDetails
+ }
+ } : undefined
});
}
if (textStarted) {
diff --git a/dist/internal/index.js b/dist/internal/index.js
index d40fa66125941155ac13a4619503caba24d89f8a..8dd86d1b473f2fa31c1acd9881d72945b294a197 100644
--- a/dist/internal/index.js
+++ b/dist/internal/index.js
@@ -2064,7 +2064,12 @@ var OpenRouterChatLanguageModel = class {
if (reasoningStarted && !textStarted) {
controller.enqueue({
type: "reasoning-end",
- id: reasoningId || generateId()
+ id: reasoningId || generateId(),
+ providerMetadata: accumulatedReasoningDetails.length > 0 ? {
+ openrouter: {
+ reasoning_details: accumulatedReasoningDetails
+ }
+ } : undefined
});
reasoningStarted = false;
}
@@ -2261,7 +2266,12 @@ var OpenRouterChatLanguageModel = class {
if (reasoningStarted) {
controller.enqueue({
type: "reasoning-end",
- id: reasoningId || generateId()
+ id: reasoningId || generateId(),
+ providerMetadata: accumulatedReasoningDetails.length > 0 ? {
+ openrouter: {
+ reasoning_details: accumulatedReasoningDetails
+ }
+ } : undefined
});
}
if (textStarted) {
diff --git a/dist/internal/index.mjs b/dist/internal/index.mjs
index b0ed9d113549c5c55ea3b1e08abb3db6f92ae5a7..5695930a8e038facc071d58a4179a369a29be9c7 100644
--- a/dist/internal/index.mjs
+++ b/dist/internal/index.mjs
@@ -2030,7 +2030,12 @@ var OpenRouterChatLanguageModel = class {
if (reasoningStarted && !textStarted) {
controller.enqueue({
type: "reasoning-end",
- id: reasoningId || generateId()
+ id: reasoningId || generateId(),
+ providerMetadata: accumulatedReasoningDetails.length > 0 ? {
+ openrouter: {
+ reasoning_details: accumulatedReasoningDetails
+ }
+ } : undefined
});
reasoningStarted = false;
}
@@ -2227,7 +2232,12 @@ var OpenRouterChatLanguageModel = class {
if (reasoningStarted) {
controller.enqueue({
type: "reasoning-end",
- id: reasoningId || generateId()
+ id: reasoningId || generateId(),
+ providerMetadata: accumulatedReasoningDetails.length > 0 ? {
+ openrouter: {
+ reasoning_details: accumulatedReasoningDetails
+ }
+ } : undefined
});
}
if (textStarted) {

View File

@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.2.0",
"version": "1.2.4",
"publisher": "sst-dev",
"repository": {
"type": "git",