From 46b8308821ad2697cb145c92c23e851759d7d43d Mon Sep 17 00:00:00 2001
From: zxc123aa <252945849@qq.com>
Date: Sun, 5 Apr 2026 03:16:33 +0800
Subject: [PATCH 1/2] feat: tighten find skills search and add benchmark
---
.../src/components/dialog-default-skills.tsx | 9 +
.../app/src/components/dialog-find-skills.tsx | 713 +++++++++
packages/app/src/components/prompt-input.tsx | 29 +-
packages/app/src/context/global-sdk.tsx | 12 +-
packages/app/src/pages/layout.tsx | 7 +
packages/opencode/src/cli/cmd/debug/skill.ts | 65 +-
packages/opencode/src/config/config.ts | 1 +
packages/opencode/src/server/routes/skill.ts | 197 +++
packages/opencode/src/server/server.ts | 25 +-
packages/opencode/src/skill/benchmark.test.ts | 91 ++
packages/opencode/src/skill/benchmark.ts | 730 ++++++++++
packages/opencode/src/skill/catalog.test.ts | 222 +++
packages/opencode/src/skill/catalog.ts | 1280 +++++++++++++++++
packages/opencode/src/skill/search.test.ts | 139 ++
.../opencode/test/server/skill-routes.test.ts | 628 ++++++++
packages/sdk/js/src/v2/gen/sdk.gen.ts | 506 +++++--
packages/sdk/js/src/v2/gen/types.gen.ts | 358 ++++-
17 files changed, 4837 insertions(+), 175 deletions(-)
create mode 100644 packages/app/src/components/dialog-find-skills.tsx
create mode 100644 packages/opencode/src/server/routes/skill.ts
create mode 100644 packages/opencode/src/skill/benchmark.test.ts
create mode 100644 packages/opencode/src/skill/benchmark.ts
create mode 100644 packages/opencode/src/skill/catalog.test.ts
create mode 100644 packages/opencode/src/skill/catalog.ts
create mode 100644 packages/opencode/src/skill/search.test.ts
create mode 100644 packages/opencode/test/server/skill-routes.test.ts
diff --git a/packages/app/src/components/dialog-default-skills.tsx b/packages/app/src/components/dialog-default-skills.tsx
index 0745599a7f..39c8e0c8ae 100644
--- a/packages/app/src/components/dialog-default-skills.tsx
+++ b/packages/app/src/components/dialog-default-skills.tsx
@@ -15,6 +15,7 @@ import {
} from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useSDK } from "@/context/sdk"
+import { DialogFindSkills } from "@/components/dialog-find-skills"
type DefaultSkill = { name: string; description: string; content: string; enabled?: boolean }
@@ -73,6 +74,14 @@ export const DialogDefaultSkills: Component = () => {
title="默认 Skills"
action={
+
+ }
+ >
+
+
+ {
+ if (e.key !== "Enter" || e.isComposing) return
+ e.preventDefault()
+ search()
+ }}
+ placeholder="搜索技能,例如 auto updater"
+ class="flex-1"
+ autofocus
+ />
+
+
+ 搜索
+
+
+
+
+
+ {submitted()
+ ? semantic()
+ ? "正在补充语义扩展与外部查找"
+ : "提交搜索时会做语义扩展与外部查找"
+ : "当前显示已安装技能;点击检查更新获取最新状态"}
+
+
+ 查询:{submitted()}
+
+
+
+
+
+ 加载中...
+
+
+ {error()}
+
+
+
+ {submitted() ? "没有找到匹配的 Skills" : "暂无可显示的 Skills"}
+
+
+
+
+
0}>
+ {submitted() ? "推荐结果" : "已安装"}
+
+
{cards(list().main)}
+
0}>
+
+ setMore((value) => !value)}
+ >
+ 更多相关 ({list().more.length})
+
+
+
+
+
0 && more()}>
+ {cards(list().more, true)}
+
+
+
+
+
+
+ )
+}
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index cf1d6b80d3..2d28b9b1cf 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -61,6 +61,7 @@ import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { KnowledgeButton } from "@/components/knowledge-button"
import { DialogDefaultSkills } from "@/components/dialog-default-skills"
+import { DialogFindSkills } from "@/components/dialog-find-skills"
import { DialogWeChat } from "@/components/dialog-wechat"
import { status as wechatStatus } from "@/context/wechat"
import { DialogFeishu } from "@/components/dialog-feishu"
@@ -351,6 +352,7 @@ export const PromptInput: Component = (props) => {
t: (key, params) => language.t(key as Parameters[0], params as never),
}),
)
+ const [tools, setTools] = createSignal(false)
const historyComments = () => {
const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const))
@@ -1630,6 +1632,8 @@ export const PromptInput: Component = (props) => {
= (props) => {
trigger={}
class="p-1 flex flex-col gap-0.5 min-w-[120px]"
>
+
+ {
+ setTools(false)
+ requestAnimationFrame(() => dialog.show(() => ))
+ }}
+ aria-label="Find Skills"
+ >
+
+ Find Skills
+
+
dialog.show(() => )}
+ onClick={() => {
+ setTools(false)
+ requestAnimationFrame(() => dialog.show(() => ))
+ }}
aria-label="默认 Skills"
>
@@ -1659,7 +1681,10 @@ export const PromptInput: Component = (props) => {
variant="ghost"
size="normal"
class="w-full h-7 px-2 flex items-center gap-2 text-icon-weak justify-start"
- onClick={() => dialog.show(() => )}
+ onClick={() => {
+ setTools(false)
+ requestAnimationFrame(() => dialog.show(() => ))
+ }}
aria-label="微信连接"
>
{
+ const requestFetch = (() => {
if (!platform.fetch || !server.current) return
try {
const url = new URL(server.current.http.url)
@@ -43,7 +43,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const eventSdk = createSdkForServer({
signal: abort.signal,
- fetch: eventFetch,
+ fetch: requestFetch,
server: currentServer.http,
})
const emitter = createGlobalEmitter<{
@@ -145,7 +145,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: currentServer.http.url,
- fetch: eventFetch ? "platform" : "webview",
+ fetch: requestFetch ? "platform" : "webview",
error,
})
},
@@ -182,7 +182,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: currentServer.http.url,
- fetch: eventFetch ? "platform" : "webview",
+ fetch: requestFetch ? "platform" : "webview",
error,
})
}
@@ -217,7 +217,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const sdk = createSdkForServer({
server: server.current.http,
- fetch: platform.fetch,
+ fetch: requestFetch,
throwOnError: true,
})
addPreferenceMethods(
@@ -240,7 +240,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))
const c = createSdkForServer({
server: s.http,
- fetch: platform.fetch,
+ fetch: requestFetch,
...opts,
})
addPreferenceMethods(
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index a37d794d0c..6fd2f70381 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -59,6 +59,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
+import { DialogFindSkills } from "@/components/dialog-find-skills"
import { DialogNewProject } from "@/components/dialog-new-project"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { DebugBar } from "@/components/debug-bar"
@@ -1180,6 +1181,12 @@ export default function Layout(props: ParentProps) {
category: language.t("command.category.server"),
onSelect: () => openServer(),
},
+ {
+ id: "skills.find",
+ title: "Find Skills",
+ category: language.t("command.category.settings"),
+ onSelect: () => dialog.show(() => ),
+ },
{
id: "settings.open",
title: language.t("command.settings.open"),
diff --git a/packages/opencode/src/cli/cmd/debug/skill.ts b/packages/opencode/src/cli/cmd/debug/skill.ts
index ebe3df1808..fd7765cded 100644
--- a/packages/opencode/src/cli/cmd/debug/skill.ts
+++ b/packages/opencode/src/cli/cmd/debug/skill.ts
@@ -1,16 +1,65 @@
import { EOL } from "os"
import { Skill } from "../../../skill"
+import { run } from "../../../skill/benchmark"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const SkillCommand = cmd({
command: "skill",
- describe: "list all available skills",
- builder: (yargs) => yargs,
- async handler() {
- await bootstrap(process.cwd(), async () => {
- const skills = await Skill.all()
- process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
- })
- },
+ describe: "list skills and run search benchmarks",
+ builder: (yargs) =>
+ yargs
+ .command({
+ command: "$0",
+ describe: "list all available skills",
+ async handler() {
+ await bootstrap(process.cwd(), async () => {
+ const skills = await Skill.all()
+ process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
+ })
+ },
+ })
+ .command({
+ command: "benchmark",
+ describe: "benchmark search models for skill discovery",
+ builder: (yargs) =>
+ yargs
+ .option("model", {
+ type: "array",
+ string: true,
+ describe: "specific provider/model ids to benchmark",
+ })
+ .option("runs", {
+ type: "number",
+ default: 2,
+ describe: "number of repeated runs per query",
+ })
+ .option("mode", {
+ type: "string",
+ default: "rerank",
+ choices: ["rerank", "live", "both"],
+ describe: "benchmark fixed rerank fixtures, live search, or both",
+ })
+ .option("json", {
+ type: "boolean",
+ default: false,
+ describe: "print raw benchmark data as JSON",
+ }),
+ async handler(args) {
+ await bootstrap(process.cwd(), async () => {
+ const out = await run({
+ models: args.model?.map(String),
+ runs: args.runs,
+ mode: args.mode as "rerank" | "live" | "both",
+ })
+ if (args.json) {
+ process.stdout.write(JSON.stringify(out, null, 2) + EOL)
+ return
+ }
+ process.stdout.write(out.markdown + EOL)
+ })
+ },
+ })
+ .demandCommand(0, 0),
+ async handler() {},
})
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 7335fdcad2..485a8036e7 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -1081,6 +1081,7 @@ export namespace Config {
.optional()
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
model: ModelId.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
+ search_model: ModelId.describe("Model to use for skill search query expansion, reranking, and summaries").optional(),
small_model: ModelId.describe(
"Small model to use for tasks like title generation in the format of provider/model",
).optional(),
diff --git a/packages/opencode/src/server/routes/skill.ts b/packages/opencode/src/server/routes/skill.ts
new file mode 100644
index 0000000000..82f36d7067
--- /dev/null
+++ b/packages/opencode/src/server/routes/skill.ts
@@ -0,0 +1,197 @@
+import { Hono } from "hono"
+import { describeRoute, resolver, validator } from "hono-openapi"
+import z from "zod"
+import { Skill } from "../../skill"
+import {
+ Catalog,
+ DescribeInput,
+ DescribeResult,
+ InstallJob,
+ InstallInput,
+ Installed,
+ SearchInput,
+ SearchOutput,
+ UpdateInput,
+} from "../../skill/catalog"
+import { lazy } from "../../util/lazy"
+import { errors } from "../error"
+
+export const SkillRoutes = lazy(() =>
+ new Hono()
+ .get(
+ "/",
+ describeRoute({
+ summary: "List skills",
+ description: "Get a list of all available skills in the OpenCode system.",
+ operationId: "app.skills",
+ responses: {
+ 200: {
+ description: "List of skills",
+ content: {
+ "application/json": {
+ schema: resolver(Skill.Info.array()),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ return c.json(await Skill.all())
+ },
+ )
+ .post(
+ "/search",
+ describeRoute({
+ summary: "Search skills",
+ description: "Search registry and external skill sources for the current project.",
+ operationId: "skill.search",
+ responses: {
+ 200: {
+ description: "Search results",
+ content: {
+ "application/json": {
+ schema: resolver(SearchOutput),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator("json", SearchInput),
+ async (c) => {
+ return c.json(await Catalog.search(c.req.valid("json")))
+ },
+ )
+ .get(
+ "/installed",
+ describeRoute({
+ summary: "List installed skills",
+ description: "Get installed managed skills visible to the current project.",
+ operationId: "skill.installed",
+ responses: {
+ 200: {
+ description: "Installed skills",
+ content: {
+ "application/json": {
+ schema: resolver(Installed.array()),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ return c.json(await Catalog.installed())
+ },
+ )
+ .post(
+ "/describe",
+ describeRoute({
+ summary: "Describe skill",
+ description: "Generate a short Chinese summary for a search result card.",
+ operationId: "skill.describe",
+ responses: {
+ 200: {
+ description: "Skill summary",
+ content: {
+ "application/json": {
+ schema: resolver(DescribeResult),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator("json", DescribeInput),
+ async (c) => {
+ return c.json(await Catalog.describe(c.req.valid("json")))
+ },
+ )
+ .get(
+ "/check",
+ describeRoute({
+ summary: "Check skill updates",
+ description: "Get installed skills with update availability.",
+ operationId: "skill.check",
+ responses: {
+ 200: {
+ description: "Installed skills with update status",
+ content: {
+ "application/json": {
+ schema: resolver(Installed.array()),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ return c.json(await Catalog.check())
+ },
+ )
+ .get(
+ "/jobs",
+ describeRoute({
+ summary: "List skill install jobs",
+ description: "Get current and recent background install jobs for the current project.",
+ operationId: "skill.jobs",
+ responses: {
+ 200: {
+ description: "Install jobs",
+ content: {
+ "application/json": {
+ schema: resolver(InstallJob.array()),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ return c.json(await Catalog.jobs())
+ },
+ )
+ .post(
+ "/install",
+ describeRoute({
+ summary: "Install skill",
+ description: "Install a registry or external skill.",
+ operationId: "skill.install",
+ responses: {
+ 200: {
+ description: "Install result",
+ content: {
+ "application/json": {
+ schema: resolver(InstallJob),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator("json", InstallInput),
+ async (c) => {
+ return c.json(await Catalog.install(c.req.valid("json")))
+ },
+ )
+ .post(
+ "/update",
+ describeRoute({
+ summary: "Update skills",
+ description: "Update one or more installed managed skills.",
+ operationId: "skill.update",
+ responses: {
+ 200: {
+ description: "Update result",
+ content: {
+ "application/json": {
+ schema: resolver(z.object({ ok: z.boolean(), updated: z.array(z.string()) })),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator("json", UpdateInput),
+ async (c) => {
+ return c.json(await Catalog.update(c.req.valid("json")))
+ },
+ ),
+)
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 357be3ff29..14c601afdb 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -39,7 +39,6 @@ import { TuiRoutes } from "./routes/tui"
import { Instance } from "../project/instance"
import { Vcs } from "../project/vcs"
import { Agent } from "../agent/agent"
-import { Skill } from "../skill"
import { Auth } from "../auth"
import { Flag } from "../flag/flag"
import { Command } from "../command"
@@ -69,6 +68,7 @@ import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
import { KnowledgeRoutes } from "./routes/knowledge"
+import { SkillRoutes } from "./routes/skill"
import { WeChatRoutes } from "./routes/wechat"
import { FeishuRoutes } from "./routes/feishu"
import { ReadingModeRoutes } from "./routes/reading-mode"
@@ -288,6 +288,7 @@ export namespace Server {
.route("/config", ConfigRoutes())
.route("/experimental", ExperimentalRoutes())
.route("/session", SessionRoutes())
+ .route("/skill", SkillRoutes())
.route("/permission", PermissionRoutes())
.route("/question", QuestionRoutes())
.route("/provider", ProviderRoutes())
@@ -509,28 +510,6 @@ export namespace Server {
return c.json(modes)
},
)
- .get(
- "/skill",
- describeRoute({
- summary: "List skills",
- description: "Get a list of all available skills in the OpenCode system.",
- operationId: "app.skills",
- responses: {
- 200: {
- description: "List of skills",
- content: {
- "application/json": {
- schema: resolver(Skill.Info.array()),
- },
- },
- },
- },
- }),
- async (c) => {
- const skills = await Skill.all()
- return c.json(skills)
- },
- )
.get(
"/lsp",
describeRoute({
diff --git a/packages/opencode/src/skill/benchmark.test.ts b/packages/opencode/src/skill/benchmark.test.ts
new file mode 100644
index 0000000000..3cac7c9b56
--- /dev/null
+++ b/packages/opencode/src/skill/benchmark.test.ts
@@ -0,0 +1,91 @@
+import { describe, expect, test } from "bun:test"
+import { cases, rank, table } from "./benchmark"
+
+describe("rank", () => {
+ test("prefers precise main results over noisy main hits", () => {
+ const query = cases.find((item) => item.id === "paper-polish-zh")
+ expect(query).toBeDefined()
+ const result = rank(query!, {
+ main: ["paper-polish", "professional-proofreader", "video-editing"],
+ more: ["code-polish"],
+ latency_ms: 4200,
+ faithfulness: 0.66,
+ })
+ const clean = rank(query!, {
+ main: ["paper-polish", "professional-proofreader", "ai-proofreading"],
+ more: ["code-polish"],
+ latency_ms: 4200,
+ faithfulness: 1,
+ })
+ expect(clean.total).toBeGreaterThan(result.total)
+ expect(result.breakdown.must_not_penalty).toBeLessThan(clean.breakdown.must_not_penalty)
+ })
+
+ test("accepts edge matches in more without promoting them to main", () => {
+ const query = cases.find((item) => item.id === "paper-polish-zh")
+ expect(query).toBeDefined()
+ const result = rank(query!, {
+ main: ["paper-polish", "professional-proofreader"],
+ more: ["code-polish", "polish"],
+ latency_ms: 3800,
+ faithfulness: 1,
+ })
+ expect(result.breakdown.precision_main).toBeGreaterThan(30)
+ expect(result.breakdown.must_not_penalty).toBeGreaterThan(10)
+ })
+})
+
+describe("table", () => {
+ test("sorts models by total score descending", () => {
+ const out = table([
+ {
+ model: "opencode/gpt-5-nano",
+ total: 71,
+ breakdown: {
+ precision_main: 28,
+ must_not_penalty: 14,
+ recall_main: 12,
+ summary_faithfulness: 12,
+ latency: 3,
+ stability: 2,
+ },
+ },
+ {
+ model: "opencode/big-pickle",
+ total: 84,
+ breakdown: {
+ precision_main: 35,
+ must_not_penalty: 18,
+ recall_main: 14,
+ summary_faithfulness: 12,
+ latency: 3,
+ stability: 2,
+ },
+ },
+ ])
+ expect(out[0]?.model).toBe("opencode/big-pickle")
+ expect(out[1]?.model).toBe("opencode/gpt-5-nano")
+ })
+})
+
+describe("cases", () => {
+ test("covers mixed gui-style chinese and english queries", () => {
+ expect(cases.length).toBeGreaterThanOrEqual(12)
+ expect(cases.map((item) => item.id)).toEqual(
+ expect.arrayContaining([
+ "paper-polish-zh",
+ "humanizer-zh",
+ "updater-zh",
+ "paper-polish-en",
+ "proofread-en",
+ "humanizer-en",
+ "tool-search-zh",
+ "translate-zh",
+ "translate-paper-zh",
+ "translate-en",
+ "exact-updater",
+ "exact-humanizer-cn",
+ ]),
+ )
+ })
+})
diff --git a/packages/opencode/src/skill/benchmark.ts b/packages/opencode/src/skill/benchmark.ts
new file mode 100644
index 0000000000..c4d22d6e38
--- /dev/null
+++ b/packages/opencode/src/skill/benchmark.ts
@@ -0,0 +1,730 @@
+import { Catalog } from "./catalog"
+import { Provider } from "@/provider/provider"
+import { ModelID, ProviderID } from "@/provider/schema"
+
+const pool = {
+ "paper-polish": {
+ id: "eyh0602/skillshub@paper-polish",
+ name: "paper-polish",
+ source: "eyh0602/skillshub",
+ rank: "exact" as const,
+ body: "Polish and revise academic papers in LaTeX format. Use this skill when revising, polishing, or editing an existing manuscript for journal or conference submission.",
+ summary_source: "skill_md" as const,
+ terms: ["论文", "LaTeX", "学术", "润色"],
+ },
+ "professional-proofreader": {
+ id: "writer/skills@professional-proofreader",
+ name: "professional-proofreader",
+ source: "writer/skills",
+ rank: "semantic" as const,
+ body: "Proofread academic manuscripts, improve grammar, and polish English for publication-ready writing.",
+ summary_source: "skill_md" as const,
+ terms: ["校对", "英文", "润色", "稿件"],
+ },
+ "ai-proofreading": {
+ id: "ai/skills@ai-proofreading",
+ name: "ai-proofreading",
+ source: "ai/skills",
+ rank: "semantic" as const,
+ body: "Proofread English text, fix grammar, and improve wording for articles, reports, and drafts.",
+ summary_source: "skills_summary" as const,
+ terms: ["校对", "语法", "润色", "英文"],
+ },
+ "copy-editing": {
+ id: "editing/skills@copy-editing",
+ name: "copy-editing",
+ source: "editing/skills",
+ rank: "semantic" as const,
+ body: "Copy-edit English writing for grammar, tone, and clarity. Best for essays, reports, and editorial polish.",
+ summary_source: "skill_md" as const,
+ terms: ["校对", "语法", "措辞", "润色"],
+ },
+ "english-proofreading": {
+ id: "proof/skills@english-proofreading",
+ name: "english-proofreading",
+ source: "proof/skills",
+ rank: "semantic" as const,
+ body: "Proofread English-language writing, improve grammar, and tighten phrasing for professional communication.",
+ summary_source: "skill_md" as const,
+ terms: ["英文", "校对", "润色"],
+ },
+ "huashu-proofreading": {
+ id: "huashu/skills@huashu-proofreading",
+ name: "huashu-proofreading",
+ source: "huashu/skills",
+ rank: "semantic" as const,
+ body: "Proofread text and refine wording for polished written communication.",
+ summary_source: "skill_md" as const,
+ terms: ["校对", "润色", "措辞"],
+ },
+ "video-editing": {
+ id: "blitzreels/agent-skills@video-editing",
+ name: "video-editing",
+ source: "blitzreels/agent-skills",
+ rank: "semantic" as const,
+ body: "Edit short-form videos and multimedia clips for publishing workflows.",
+ summary_source: "skill_md" as const,
+ terms: ["视频", "多媒体"],
+ },
+ "ui-ux-polish": {
+ id: "oakoss/agent-skills@ui-ux-polish",
+ name: "ui-ux-polish",
+ source: "oakoss/agent-skills",
+ rank: "semantic" as const,
+ body: "Polish interface details, layout rhythm, and UX presentation for product surfaces.",
+ summary_source: "skill_md" as const,
+ terms: ["界面", "UX", "体验"],
+ },
+ "find-skills": {
+ id: "vercel-labs/skills@find-skills",
+ name: "find-skills",
+ source: "vercel-labs/skills",
+ rank: "semantic" as const,
+ body: "Discover and install specialized agent skills from the open ecosystem with the Skills CLI.",
+ summary_source: "skills_summary" as const,
+ terms: ["搜索", "发现", "安装", "技能"],
+ },
+ "code-polish": {
+ id: "paulrberg/agent-skills@code-polish",
+ name: "code-polish",
+ source: "paulrberg/agent-skills",
+ rank: "semantic" as const,
+ body: "Polish and refactor source code for readability, naming, and consistency.",
+ summary_source: "skill_md" as const,
+ terms: ["代码", "重构", "可读性"],
+ },
+ humanizer: {
+ id: "writer/skills@humanizer",
+ name: "humanizer",
+ source: "writer/skills",
+ rank: "exact" as const,
+ body: "Rewrite text so it sounds more natural, human, and less AI-generated.",
+ summary_source: "skill_md" as const,
+ terms: ["自然", "人类", "改写", "人味"],
+ },
+ "humanizer-cn": {
+ id: "writer/skills@humanizer-cn",
+ name: "humanizer-cn",
+ source: "writer/skills",
+ rank: "semantic" as const,
+ body: "让中文文本更自然、更像人类书写,降低 AI 痕迹。",
+ summary_source: "skill_md" as const,
+ terms: ["自然", "中文", "人类", "AI"],
+ },
+ "writing-humanizer": {
+ id: "writer/skills@writing-humanizer",
+ name: "writing-humanizer",
+ source: "writer/skills",
+ rank: "semantic" as const,
+ body: "Humanize drafted writing by adjusting tone, flow, and natural phrasing.",
+ summary_source: "skill_md" as const,
+ terms: ["自然", "语气", "改写", "人类"],
+ },
+ "writing-humanizer-zh": {
+ id: "writer/skills@writing-humanizer-zh",
+ name: "writing-humanizer-zh",
+ source: "writer/skills",
+ rank: "semantic" as const,
+ body: "将中文写作改得更自然、更口语化,减少 AI 风格。",
+ summary_source: "skill_md" as const,
+ terms: ["自然", "中文", "口语", "AI"],
+ },
+ "humanize-academic-writing": {
+ id: "writer/skills@humanize-academic-writing",
+ name: "humanize-academic-writing",
+ source: "writer/skills",
+ rank: "semantic" as const,
+ body: "Make academic writing sound more natural while preserving scholarly tone and structure.",
+ summary_source: "skill_md" as const,
+ terms: ["学术", "自然", "写作"],
+ },
+ copywriting: {
+ id: "writer/skills@copywriting",
+ name: "copywriting",
+ source: "writer/skills",
+ rank: "semantic" as const,
+ body: "Write or rewrite marketing copy for clearer positioning and stronger conversion.",
+ summary_source: "skill_md" as const,
+ terms: ["文案", "改写", "营销"],
+ },
+ "docs-translation": {
+ id: "translator/skills@docs-translation",
+ name: "docs-translation",
+ source: "translator/skills",
+ rank: "semantic" as const,
+ body: "Translate technical documentation, API guides, and product docs between Chinese and English.",
+ summary_source: "skill_md" as const,
+ terms: ["翻译", "技术", "文档", "中英"],
+ },
+ "paper-translation": {
+ id: "translator/skills@paper-translation",
+ name: "paper-translation",
+ source: "translator/skills",
+ rank: "semantic" as const,
+ body: "Translate academic manuscripts and research papers while preserving scholarly terminology and tone.",
+ summary_source: "skill_md" as const,
+ terms: ["翻译", "论文", "学术", "术语"],
+ },
+ "subtitle-translation": {
+ id: "media/skills@subtitle-translation",
+ name: "subtitle-translation",
+ source: "media/skills",
+ rank: "semantic" as const,
+ body: "Translate video subtitles and multilingual captions for short-form media workflows.",
+ summary_source: "skill_md" as const,
+ terms: ["翻译", "字幕", "视频", "多媒体"],
+ },
+ "manuscript-review": {
+ id: "review/skills@manuscript-review",
+ name: "manuscript-review",
+ source: "review/skills",
+ rank: "semantic" as const,
+ body: "Review manuscript structure, argument flow, and submission readiness for academic papers.",
+ summary_source: "skill_md" as const,
+ terms: ["稿件", "论文", "审阅", "学术"],
+ },
+ "writing-rewrite": {
+ id: "writer/skills@writing-rewrite",
+ name: "writing-rewrite",
+ source: "writer/skills",
+ rank: "semantic" as const,
+ body: "Rewrite drafts for smoother flow, more natural tone, and human-sounding prose.",
+ summary_source: "skill_md" as const,
+ terms: ["改写", "自然", "人类", "语气"],
+ },
+ "skills-updater": {
+ id: "yizhiyanhua-ai/skills-updater@skills-updater",
+ name: "skills-updater",
+ source: "yizhiyanhua-ai/skills-updater",
+ rank: "exact" as const,
+ body: "Check installed skills, detect available updates, and refresh them with the Skills CLI.",
+ summary_source: "skill_md" as const,
+ terms: ["更新", "检查", "技能", "刷新"],
+ },
+ "auto-updater": {
+ id: "skills.volces.com@auto-updater",
+ name: "auto-updater",
+ source: "skills.volces.com",
+ rank: "exact" as const,
+ body: "Automatically update installed skills and keep the local skill set in sync.",
+ summary_source: "skill_md" as const,
+ terms: ["自动", "更新", "同步"],
+ },
+ "playwright-cli": {
+ id: "microsoft/playwright-cli@playwright-cli",
+ name: "playwright-cli",
+ source: "microsoft/playwright-cli",
+ rank: "semantic" as const,
+ body: "Automate browser interactions, inspect pages, and run UI checks.",
+ summary_source: "skill_md" as const,
+ terms: ["浏览器", "自动化", "检查"],
+ },
+} as const
+
+function pickItems(ids: Array) {
+ return ids.map((id) => pool[id])
+}
+
+export const cases = [
+ {
+ id: "paper-polish-zh",
+ query: "找一下论文润色的skill",
+ must_have: ["paper-polish", "professional-proofreader"],
+ good_to_have: ["ai-proofreading", "copy-editing", "english-proofreading", "huashu-proofreading"],
+ must_not_have: ["video-editing", "ui-ux-polish", "find-skills"],
+ fixture: pickItems([
+ "paper-polish",
+ "professional-proofreader",
+ "ai-proofreading",
+ "copy-editing",
+ "english-proofreading",
+ "huashu-proofreading",
+ "video-editing",
+ "ui-ux-polish",
+ "find-skills",
+ "code-polish",
+ ]),
+ },
+ {
+ id: "humanizer-zh",
+ query: "找一下更有人味的skill",
+ must_have: ["humanizer", "humanizer-cn", "writing-humanizer", "writing-humanizer-zh"],
+ good_to_have: ["humanize-academic-writing", "copywriting"],
+ must_not_have: ["video-editing", "paper-polish", "find-skills"],
+ fixture: pickItems([
+ "humanizer",
+ "humanizer-cn",
+ "writing-humanizer",
+ "writing-humanizer-zh",
+ "humanize-academic-writing",
+ "copywriting",
+ "paper-polish",
+ "video-editing",
+ "find-skills",
+ ]),
+ },
+ {
+ id: "updater-zh",
+ query: "找一下自动更新的skill",
+ must_have: ["skills-updater", "auto-updater", "find-skills"],
+ good_to_have: ["playwright-cli"],
+ must_not_have: ["paper-polish", "video-editing", "humanizer"],
+ fixture: pickItems([
+ "skills-updater",
+ "auto-updater",
+ "find-skills",
+ "playwright-cli",
+ "paper-polish",
+ "video-editing",
+ "humanizer",
+ "code-polish",
+ ]),
+ },
+ {
+ id: "paper-polish-en",
+ query: "paper polish skill",
+ must_have: ["paper-polish", "professional-proofreader"],
+ good_to_have: ["ai-proofreading", "copy-editing"],
+ must_not_have: ["video-editing", "ui-ux-polish", "find-skills"],
+ fixture: pickItems([
+ "paper-polish",
+ "professional-proofreader",
+ "ai-proofreading",
+ "copy-editing",
+ "video-editing",
+ "ui-ux-polish",
+ "find-skills",
+ "code-polish",
+ ]),
+ },
+ {
+ id: "proofread-en",
+ query: "proofread manuscript",
+ must_have: ["professional-proofreader", "english-proofreading"],
+ good_to_have: ["ai-proofreading", "paper-polish", "copy-editing"],
+ must_not_have: ["video-editing", "find-skills", "ui-ux-polish"],
+ fixture: pickItems([
+ "professional-proofreader",
+ "english-proofreading",
+ "ai-proofreading",
+ "paper-polish",
+ "copy-editing",
+ "video-editing",
+ "find-skills",
+ "ui-ux-polish",
+ ]),
+ },
+ {
+ id: "exact-skill",
+ query: "ai-proofreading",
+ must_have: ["ai-proofreading"],
+ good_to_have: ["english-proofreading"],
+ must_not_have: ["video-editing", "find-skills"],
+ fixture: pickItems(["ai-proofreading", "english-proofreading", "video-editing", "find-skills"]),
+ },
+ {
+ id: "humanizer-en",
+ query: "make this writing sound more human",
+ must_have: ["humanizer", "writing-humanizer"],
+ good_to_have: ["writing-rewrite", "copywriting", "humanizer-cn"],
+ must_not_have: ["video-editing", "paper-polish", "find-skills"],
+ fixture: pickItems([
+ "humanizer",
+ "writing-humanizer",
+ "writing-rewrite",
+ "copywriting",
+ "humanizer-cn",
+ "paper-polish",
+ "video-editing",
+ "find-skills",
+ ]),
+ },
+ {
+ id: "tool-search-zh",
+ query: "找一下搜索和安装skill的工具",
+ must_have: ["find-skills"],
+ good_to_have: ["skills-updater", "auto-updater"],
+ must_not_have: ["paper-polish", "video-editing", "humanizer"],
+ fixture: pickItems([
+ "find-skills",
+ "skills-updater",
+ "auto-updater",
+ "playwright-cli",
+ "paper-polish",
+ "video-editing",
+ "humanizer",
+ ]),
+ },
+ {
+ id: "translate-zh",
+ query: "找个翻译技术文档的skill",
+ must_have: ["docs-translation"],
+ good_to_have: ["paper-translation"],
+ must_not_have: ["subtitle-translation", "video-editing", "find-skills"],
+ fixture: pickItems([
+ "docs-translation",
+ "paper-translation",
+ "subtitle-translation",
+ "video-editing",
+ "find-skills",
+ "copywriting",
+ ]),
+ },
+ {
+ id: "translate-paper-zh",
+ query: "找一下翻译论文的skill",
+ must_have: ["paper-translation"],
+ good_to_have: ["docs-translation", "manuscript-review"],
+ must_not_have: ["subtitle-translation", "video-editing", "find-skills"],
+ fixture: pickItems([
+ "paper-translation",
+ "docs-translation",
+ "manuscript-review",
+ "subtitle-translation",
+ "video-editing",
+ "find-skills",
+ ]),
+ },
+ {
+ id: "translate-en",
+ query: "translate technical docs",
+ must_have: ["docs-translation"],
+ good_to_have: ["paper-translation"],
+ must_not_have: ["subtitle-translation", "video-editing", "find-skills"],
+ fixture: pickItems([
+ "docs-translation",
+ "paper-translation",
+ "subtitle-translation",
+ "video-editing",
+ "find-skills",
+ "copywriting",
+ ]),
+ },
+ {
+ id: "exact-updater",
+ query: "auto-updater",
+ must_have: ["auto-updater"],
+ good_to_have: ["skills-updater", "find-skills"],
+ must_not_have: ["paper-polish", "humanizer"],
+ fixture: pickItems([
+ "auto-updater",
+ "skills-updater",
+ "find-skills",
+ "paper-polish",
+ "humanizer",
+ ]),
+ },
+ {
+ id: "exact-humanizer-cn",
+ query: "humanizer-cn",
+ must_have: ["humanizer-cn"],
+ good_to_have: ["writing-humanizer-zh", "humanizer"],
+ must_not_have: ["paper-polish", "find-skills", "video-editing"],
+ fixture: pickItems([
+ "humanizer-cn",
+ "writing-humanizer-zh",
+ "humanizer",
+ "paper-polish",
+ "find-skills",
+ "video-editing",
+ ]),
+ },
+] as const
+
+type Case = (typeof cases)[number]
+type Mode = "rerank" | "live" | "both"
+type Score = {
+ model: string
+ total: number
+ breakdown: {
+ precision_main: number
+ must_not_penalty: number
+ recall_main: number
+ summary_faithfulness: number
+ latency: number
+ stability: number
+ }
+}
+
+type Run = {
+ main: string[]
+ more: string[]
+ latency_ms: number
+ faithfulness: number
+}
+
+type Detail = {
+ id: string
+ query: string
+ run: Run[]
+ total: number
+ breakdown: Score["breakdown"]
+}
+
+type Row = Score & { avg_latency_ms: number }
+type Report = {
+ mode: Exclude
+ rows: Row[]
+ markdown: string
+ winner?: string
+ fail_examples: Array<{
+ model: string
+ case: string
+ main: string[]
+ more: string[]
+ total: number
+ }>
+}
+
+function overlap(left: string[], right: string[]) {
+ const a = new Set(left)
+ const b = new Set(right)
+ const both = [...a].filter((item) => b.has(item)).length
+ const size = new Set([...a, ...b]).size
+ return size ? both / size : 1
+}
+
+function points(value: number, max: number) {
+ return Math.max(0, Math.min(max, Math.round(value * max * 100) / 100))
+}
+
+function faith(input: Case, main: Array<{ name: string; summary_zh?: string }>) {
+ const terms = new Map(input.fixture.map((item) => [item.name, item.terms as readonly string[]]))
+ const good = new Set([...input.must_have, ...input.good_to_have])
+ const rows = main.filter((item) => good.has(item.name))
+ if (rows.length === 0) return 0
+ const hit = rows.filter((item) => {
+ const text = item.summary_zh ?? ""
+ const keys = terms.get(item.name) ?? []
+ return keys.some((key) => text.includes(key))
+ }).length
+ return hit / rows.length
+}
+
+export function rank(input: Case, run: Run) {
+ const good = new Set([...input.must_have, ...input.good_to_have])
+ const bad = new Set(input.must_not_have)
+ const main = new Set(run.main)
+ const more = new Set(run.more)
+ const hit = run.main.filter((item) => good.has(item)).length
+ const blocked = run.main.filter((item) => bad.has(item)).length
+ const need = input.must_have.filter((item) => main.has(item)).length
+ const extra = [...more].filter((item) => good.has(item)).length
+ const precision_main = points(run.main.length ? hit / run.main.length : 0, 40)
+ const must_not_penalty = points(run.main.length ? 1 - blocked / run.main.length : 1, 20)
+ const recall_main = points(input.must_have.length ? need / input.must_have.length : 1, 15)
+ const summary_faithfulness = points(run.faithfulness, 15)
+ const latency = run.latency_ms <= 4_000 ? 5 : run.latency_ms <= 7_000 ? 3 : run.latency_ms <= 10_000 ? 1 : 0
+ const stability = points(extra ? Math.min(1, 0.6 + extra / Math.max(1, input.good_to_have.length + input.must_have.length)) : 0.6, 5)
+ const total = precision_main + must_not_penalty + recall_main + summary_faithfulness + latency + stability
+ return {
+ total,
+ breakdown: {
+ precision_main,
+ must_not_penalty,
+ recall_main,
+ summary_faithfulness,
+ latency,
+ stability,
+ },
+ }
+}
+
+export function table(input: T[]) {
+ return input.toSorted((a, b) => b.total - a.total)
+}
+
+function parse(input: string) {
+ const idx = input.indexOf("/")
+ if (idx <= 0 || idx === input.length - 1) return
+ return {
+ providerID: ProviderID.make(input.slice(0, idx)),
+ modelID: ModelID.make(input.slice(idx + 1)),
+ }
+}
+
+function print(input: { providerID: ProviderID; modelID: ModelID }) {
+ return `${input.providerID}/${input.modelID}`
+}
+
+async function models() {
+ const providers = await Provider.list()
+ return Object.values(providers)
+ .filter((item) => item.id === "opencode")
+ .flatMap((item) => Object.values(item.models))
+ .map((item) => ({
+ providerID: item.providerID,
+ modelID: ModelID.make(item.id),
+ }))
+}
+
+function markdown(mode: Exclude, input: Row[]) {
+ return [
+ `## ${mode === "rerank" ? "Rerank Benchmark" : "Live Benchmark"}`,
+ "",
+ "| Rank | Model | Total | Precision | Must-Not | Recall | Faithful | Latency | Stability | Avg ms |",
+ "| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |",
+ ...input.map((item, idx) =>
+ [
+ `| ${idx + 1}`,
+ item.model,
+ item.total.toFixed(2),
+ item.breakdown.precision_main.toFixed(2),
+ item.breakdown.must_not_penalty.toFixed(2),
+ item.breakdown.recall_main.toFixed(2),
+ item.breakdown.summary_faithfulness.toFixed(2),
+ item.breakdown.latency.toFixed(2),
+ item.breakdown.stability.toFixed(2),
+ item.avg_latency_ms.toFixed(0),
+ "|",
+ ].join(" "),
+ ),
+ ].join("\n")
+}
+
+function fail(score: Array) {
+ return score
+ .flatMap((item) =>
+ item.detail
+ .filter(
+ (row) =>
+ row.total < 100 ||
+ row.breakdown.precision_main < 40 ||
+ row.breakdown.must_not_penalty < 20 ||
+ row.breakdown.recall_main < 15 ||
+ row.breakdown.summary_faithfulness < 15,
+ )
+ .map((row) => ({
+ model: item.model,
+ case: row.id,
+ main: row.run[0]?.main ?? [],
+ more: row.run[0]?.more ?? [],
+ total: row.total,
+ })),
+ )
+ .toSorted((a, b) => a.total - b.total)
+ .slice(0, 15)
+}
+
+async function probe(input: Case, model: { providerID: ProviderID; modelID: ModelID }, mode: Exclude) {
+ if (mode === "live") {
+ const out = await Catalog.search({ query: input.query, semantic: true }, model)
+ return {
+ main: out.main.map((row) => row.name),
+ more: out.more.map((row) => row.name),
+ latency_ms: out.meta.latency_ms ?? 0,
+ faithfulness: faith(input, out.main.map((row) => ({ name: row.name, summary_zh: row.summary_zh }))),
+ }
+ }
+
+ const out = await Catalog.bench(
+ {
+ query: input.query,
+ items: input.fixture.map((item) => ({
+ id: item.id,
+ name: item.name,
+ source: item.source,
+ rank: item.rank,
+ body: item.body,
+ summary_source: item.summary_source,
+ })),
+ },
+ model,
+ )
+ return {
+ main: out.main.map((row) => row.name),
+ more: out.more.map((row) => row.name),
+ latency_ms: out.meta.latency_ms ?? 0,
+ faithfulness: faith(input, out.main.map((row) => ({ name: row.name, summary_zh: row.summary_zh }))),
+ }
+}
+
+async function one(mode: Exclude, input?: { models?: string[]; runs?: number }) {
+ const list = (input?.models?.map(parse).filter((item): item is NonNullable => !!item) ?? await models())
+ .map((item) => ({ ...item, name: print(item) }))
+ const runs = Math.max(1, input?.runs ?? 2)
+ const score: Array = []
+
+ for (const model of list) {
+ const detail = []
+ let total = 0
+ let latency = 0
+ let stability = 0
+
+ for (const item of cases) {
+ const tries = []
+ for (let i = 0; i < runs; i++) {
+ tries.push(await probe(item, model, mode))
+ }
+ const base = tries[0]!
+ const ranked = rank(item, base)
+ const stable =
+ tries.length < 2
+ ? 5
+ : points(
+ tries
+ .slice(1)
+ .map((next) => overlap(base.main, next.main))
+ .reduce((acc, item) => acc + item, 0) / Math.max(1, tries.length - 1),
+ 5,
+ )
+ ranked.breakdown.stability = stable
+ ranked.total =
+ ranked.breakdown.precision_main +
+ ranked.breakdown.must_not_penalty +
+ ranked.breakdown.recall_main +
+ ranked.breakdown.summary_faithfulness +
+ ranked.breakdown.latency +
+ ranked.breakdown.stability
+ total += ranked.total
+ latency += tries.reduce((acc, item) => acc + item.latency_ms, 0) / tries.length
+ stability += stable
+ detail.push({
+ id: item.id,
+ query: item.query,
+ run: tries,
+ total: ranked.total,
+ breakdown: ranked.breakdown,
+ })
+ }
+
+ score.push({
+ model: model.name,
+ total: Math.round((total / cases.length) * 100) / 100,
+ avg_latency_ms: Math.round(latency / cases.length),
+ breakdown: {
+ precision_main: Math.round((detail.reduce((acc, item: any) => acc + item.breakdown.precision_main, 0) / cases.length) * 100) / 100,
+ must_not_penalty: Math.round((detail.reduce((acc, item: any) => acc + item.breakdown.must_not_penalty, 0) / cases.length) * 100) / 100,
+ recall_main: Math.round((detail.reduce((acc, item: any) => acc + item.breakdown.recall_main, 0) / cases.length) * 100) / 100,
+ summary_faithfulness: Math.round((detail.reduce((acc, item: any) => acc + item.breakdown.summary_faithfulness, 0) / cases.length) * 100) / 100,
+ latency: Math.round((detail.reduce((acc, item: any) => acc + item.breakdown.latency, 0) / cases.length) * 100) / 100,
+ stability: Math.round((stability / cases.length) * 100) / 100,
+ },
+ detail,
+ })
+ }
+
+ const rows = table(score)
+ return {
+ mode,
+ rows,
+ markdown: markdown(mode, rows),
+ winner: rows[0]?.model,
+ fail_examples: fail(score),
+ } satisfies Report
+}
+
+export async function run(input?: { models?: string[]; runs?: number; mode?: Mode }) {
+ const mode = input?.mode ?? "rerank"
+ if (mode === "both") {
+ const rerank = await one("rerank", input)
+ const live = await one("live", input)
+ return {
+ rerank,
+ live,
+ markdown: [rerank.markdown, "", live.markdown].join("\n"),
+ winner: rerank.winner,
+ }
+ }
+ return one(mode, input)
+}
diff --git a/packages/opencode/src/skill/catalog.test.ts b/packages/opencode/src/skill/catalog.test.ts
new file mode 100644
index 0000000000..58b6ae2ecf
--- /dev/null
+++ b/packages/opencode/src/skill/catalog.test.ts
@@ -0,0 +1,222 @@
+import { describe, expect, test } from "bun:test"
+import { brief, extractPage, limit, merge, parseCheck, parseFind, seed, splitPackage } from "./catalog"
+
+const FIND = `
+\u001b[38;5;102mInstall with\u001b[0m npx skills add
+
+\u001b[38;5;145myizhiyanhua-ai/skills-updater@skills-updater\u001b[0m \u001b[36m285 installs\u001b[0m
+\u001b[38;5;102m└ https://skills.sh/yizhiyanhua-ai/skills-updater/skills-updater\u001b[0m
+
+\u001b[38;5;145mskills.volces.com@auto-updater\u001b[0m \u001b[36m38 installs\u001b[0m
+\u001b[38;5;102m└ https://skills.sh/skills.volces.com/auto-updater\u001b[0m
+`
+
+const CHECK = `
+\u001b[38;5;145m5 update(s) available:\u001b[0m
+
+ \u001b[38;5;145m↑\u001b[0m find-skills
+ \u001b[38;5;102msource: vercel-labs/skills\u001b[0m
+ \u001b[38;5;145m↑\u001b[0m playwright-cli
+ \u001b[38;5;102msource: microsoft/playwright-cli\u001b[0m
+
+\u001b[38;5;102mCould not check 1 skill(s) (may need reinstall)\u001b[0m
+
+ \u001b[38;5;102m✗\u001b[0m humanizer-cn
+ \u001b[38;5;102msource: z0gsh1u/oh-my-writing-skill\u001b[0m
+`
+
+const SUMMARY = `
+Summary
+
+
+
Discover and install specialized agent skills from the open ecosystem when users need extended capabilities.
+
+ - Helps identify relevant skills by domain and task
+ - Integrates with the Skills CLI to search and install packages
+
+
+
+`
+
+const SKILL = `
+SKILL.md
+
+
Polishing and reviewing research papers in LaTeX
+
This skill helps revise and polish academic manuscripts with tracked changes.
+
When to Use This Skill
+
Use it when a user asks to refine a paper draft.
+
+`
+
+describe("splitPackage", () => {
+ test("splits package and skill names", () => {
+ expect(splitPackage("vercel-labs/skills@find-skills")).toEqual({
+ source: "vercel-labs/skills",
+ skill: "find-skills",
+ })
+ })
+
+ test("supports domain-like sources", () => {
+ expect(splitPackage("skills.volces.com@auto-updater")).toEqual({
+ source: "skills.volces.com",
+ skill: "auto-updater",
+ })
+ })
+})
+
+describe("parseFind", () => {
+ test("parses skills.sh search output", () => {
+ expect(parseFind(FIND)).toEqual([
+ {
+ package: "yizhiyanhua-ai/skills-updater@skills-updater",
+ installs: "285",
+ source: "yizhiyanhua-ai/skills-updater",
+ name: "skills-updater",
+ url: "https://skills.sh/yizhiyanhua-ai/skills-updater/skills-updater",
+ },
+ {
+ package: "skills.volces.com@auto-updater",
+ installs: "38",
+ source: "skills.volces.com",
+ name: "auto-updater",
+ url: "https://skills.sh/skills.volces.com/auto-updater",
+ },
+ ])
+ })
+})
+
+describe("parseCheck", () => {
+ test("parses update and failed check entries", () => {
+ expect(parseCheck(CHECK)).toEqual({
+ updates: {
+ "find-skills": "vercel-labs/skills",
+ "playwright-cli": "microsoft/playwright-cli",
+ },
+ failed: {
+ "humanizer-cn": "z0gsh1u/oh-my-writing-skill",
+ },
+ })
+ })
+})
+
+describe("extractPage", () => {
+ test("prefers summary section when present", () => {
+ expect(extractPage(SUMMARY)).toEqual({
+ text: expect.stringContaining("Discover and install specialized agent skills"),
+ source: "skills_summary",
+ })
+ })
+
+ test("falls back to SKILL.md content", () => {
+ expect(extractPage(SKILL)).toEqual({
+ text: expect.stringContaining("Polishing and reviewing research papers in LaTeX"),
+ source: "skill_md",
+ })
+ })
+})
+
+describe("brief", () => {
+ test("prefers video classification over generic editing", () => {
+ expect(
+ brief({
+ id: "blitzreels-video-editing",
+ name: "blitzreels-video-editing",
+ provider: "external",
+ source: "blitzreels/agent-skills",
+ }),
+ ).toContain("视频或多媒体")
+ })
+
+ test("treats copy-editing as proofreading instead of humanizer", () => {
+ const out = brief({
+ id: "copy-editing",
+ name: "copy-editing",
+ provider: "external",
+ source: "coreyhaines31/marketingskills",
+ })
+ expect(out).toContain("校对")
+ expect(out).not.toContain("真人")
+ })
+
+ test("uses conservative generic text for unknown skills", () => {
+ expect(
+ brief({
+ id: "glm-claude",
+ name: "glm-claude",
+ provider: "external",
+ source: "alchaincyf/glm-claude",
+ }),
+ ).toContain("当前只拿到了基础信息")
+ })
+})
+
+describe("merge", () => {
+ test("prefers exact local hits before semantic and external hits", () => {
+ expect(
+ merge("auto updater", [
+ {
+ id: "local-semantic",
+ name: "refresh-helper",
+ provider: "registry",
+ rank: "semantic",
+ installed: false,
+ },
+ {
+ id: "external-exact",
+ name: "auto-updater",
+ provider: "external",
+ rank: "exact",
+ installed: false,
+ },
+ {
+ id: "local-exact",
+ name: "auto updater",
+ provider: "registry",
+ rank: "exact",
+ installed: false,
+ },
+ ]),
+ ).toEqual(["local-exact", "external-exact", "local-semantic"])
+ })
+})
+
+describe("limit", () => {
+ test("falls back when work takes too long", async () => {
+ const start = Date.now()
+ const result = await limit(25, "fallback", () => new Promise(() => undefined))
+
+ expect(result).toBe("fallback")
+ expect(Date.now() - start).toBeLessThan(150)
+ })
+
+ test("returns resolved work before timeout", async () => {
+ const result = await limit(100, "fallback", async () => "done")
+ expect(result).toBe("done")
+ })
+})
+
+describe("seed", () => {
+ test("expands chinese polish intent into english keywords", () => {
+ const result = seed("找一下润色的skill")
+ expect(result).toContain("polish")
+ expect(result).toContain("proofread")
+ expect(result).toContain("editing")
+ expect(result).toContain("rewrite")
+ expect(result).toContain("humanizer")
+ })
+
+ test("expands chinese human-like writing intent", () => {
+ const result = seed("更有人味一点")
+ expect(result).toContain("humanizer")
+ expect(result).toContain("human-like")
+ expect(result).toContain("natural")
+ })
+
+ test("expands chinese updater intent", () => {
+ const result = seed("找一下自动更新的skill")
+ expect(result).toContain("update")
+ expect(result).toContain("updater")
+ expect(result).toContain("refresh")
+ expect(result).toContain("sync")
+ })
+})
diff --git a/packages/opencode/src/skill/catalog.ts b/packages/opencode/src/skill/catalog.ts
new file mode 100644
index 0000000000..cc86256a18
--- /dev/null
+++ b/packages/opencode/src/skill/catalog.ts
@@ -0,0 +1,1280 @@
+import fs from "fs/promises"
+import os from "os"
+import path from "path"
+import { generateObject, generateText } from "ai"
+import stripAnsi from "strip-ansi"
+import z from "zod"
+import { Filesystem } from "@/util/filesystem"
+import { Global } from "@/global"
+import { Process } from "@/util/process"
+import { Provider } from "@/provider/provider"
+import { Instance } from "@/project/instance"
+import { Config } from "@/config/config"
+import { ModelID, ProviderID } from "@/provider/schema"
+
+const Rank = z.enum(["exact", "semantic"])
+const Relevance = z.enum(["high", "medium", "low"])
+const Tier = z.enum(["main", "more"])
+const Scope = z.enum(["project", "global"])
+const ProviderKind = z.enum(["registry", "external"])
+const SummarySource = z.enum(["skills_summary", "skill_md"])
+const DescribeSource = z.enum(["skills_summary", "skill_md", "fallback"])
+const InstallStatus = z.enum(["queued", "running", "success", "error"])
+
+export const SearchResult = z.object({
+ id: z.string(),
+ provider: ProviderKind,
+ rank: Rank,
+ name: z.string(),
+ description: z.string().optional(),
+ installs: z.string().optional(),
+ url: z.string().optional(),
+ registry: z.string().optional(),
+ version: z.string().optional(),
+ package: z.string().optional(),
+ source: z.string().optional(),
+ installed: z.boolean().default(false),
+ scope: Scope.optional(),
+ update_available: z.boolean().optional(),
+ summary_zh: z.string().optional(),
+ summary_source: SummarySource.optional(),
+ relevance: Relevance.optional(),
+ tier: Tier.optional(),
+})
+export type SearchResult = z.infer
+
+export const SearchOutput = z.object({
+ main: SearchResult.array(),
+ more: SearchResult.array(),
+ meta: z.object({
+ model: z.string().optional(),
+ latency_ms: z.number().optional(),
+ }),
+})
+export type SearchOutput = z.infer
+
+type FindResult = {
+ package: string
+ installs: string
+ source: string
+ name: string
+ url: string
+}
+
+export const Installed = SearchResult.omit({ rank: true }).extend({
+ update_available: z.boolean().default(false),
+})
+export type Installed = z.infer
+
+export const SearchInput = z.object({
+ query: z.string(),
+ semantic: z.boolean().optional(),
+})
+
+export const DescribeInput = z.object({
+ id: z.string(),
+ name: z.string(),
+ provider: ProviderKind,
+ description: z.string().optional(),
+ url: z.string().optional(),
+ source: z.string().optional(),
+ registry: z.string().optional(),
+ package: z.string().optional(),
+})
+
+export const DescribeResult = z.object({
+ summary_zh: z.string(),
+ summary_source: DescribeSource.optional(),
+})
+
+export const InstallInput = z.discriminatedUnion("kind", [
+ z.object({
+ kind: z.literal("registry"),
+ registry: z.string(),
+ name: z.string(),
+ }),
+ z.object({
+ kind: z.literal("external"),
+ package: z.string(),
+ scope: Scope,
+ }),
+])
+
+export const UpdateInput = z.object({
+ names: z.array(z.string()).optional(),
+})
+
+export const InstallJob = z.object({
+ job_id: z.string(),
+ id: z.string(),
+ provider: ProviderKind,
+ name: z.string(),
+ registry: z.string().optional(),
+ package: z.string().optional(),
+ source: z.string().optional(),
+ scope: Scope.optional(),
+ status: InstallStatus,
+ message: z.string().optional(),
+ started_at: z.number().optional(),
+ finished_at: z.number().optional(),
+})
+
+const Entry = z.object({
+ name: z.string(),
+ description: z.string().optional().default(""),
+ version: z.string().optional(),
+ files: z.array(z.string()),
+ tags: z.array(z.string()).optional().default([]),
+ checksum: z.string().optional(),
+ homepage: z.string().optional(),
+ updated_at: z.string().optional(),
+})
+
+const Index = z.object({
+ skills: z.array(Entry),
+})
+
+const LockEntry = z.object({
+ registry: z.string(),
+ version: z.string().optional(),
+ checksum: z.string().optional(),
+ installed_at: z.number(),
+})
+
+const Lock = z.object({
+ version: z.literal(1),
+ skills: z.record(z.string(), LockEntry).default({}),
+})
+
+const ExternalLockEntry = z.object({
+ source: z.string(),
+ sourceType: z.string().optional(),
+ computedHash: z.string().optional(),
+})
+
+const ExternalLock = z.object({
+ version: z.number(),
+ skills: z.record(z.string(), ExternalLockEntry),
+})
+
+const Terms = z.object({
+ intent: z.string().optional().default(""),
+ phrases: z.array(z.string()).max(3).default([]),
+ keywords: z.array(z.string()).max(8).default([]),
+})
+
+const Review = z.object({
+ items: z
+ .array(
+ z.object({
+ id: z.string(),
+ relevance: Relevance,
+ summary_zh: z.string().trim().optional().default(""),
+ }),
+ )
+ .default([]),
+})
+
+type RegistryItem = z.infer & { registry: string; base: string }
+type LockItem = z.infer
+type LockState = { version: 1; skills: Record }
+type Text = Awaited>
+type Page = { text: string; source: z.infer }
+type Job = z.infer & { directory: string; work: () => Promise }
+type SearchModel = { providerID: ProviderID; modelID: ModelID }
+type Reviewed = {
+ id: string
+ relevance: z.infer
+ summary_zh?: string
+ summary_source?: z.infer
+}
+
+export const BenchInput = z.object({
+ query: z.string(),
+ items: z.array(
+ z.object({
+ id: z.string(),
+ name: z.string(),
+ source: z.string().optional(),
+ description: z.string().optional(),
+ rank: Rank.default("semantic"),
+ body: z.string().optional(),
+ summary_source: SummarySource.optional(),
+ }),
+ ),
+})
+
+const REGISTRY_MS = 1_500
+const CLI_MS = 2_000
+const FIND_MS = 4_500
+const PAGE_MS = 2_500
+const SUMMARY_TTL = 86_400_000
+const SEARCH_MODELS = [
+ "opencode/big-pickle",
+ "opencode/qwen3.6-plus-free",
+ "opencode/gpt-5-nano",
+ "opencode/nemotron-3-super-free",
+ "opencode/minimax-m2.5-free",
+] as const
+const memo = new Map }>()
+const pending = new Map>>()
+const pageMemo = new Map()
+const pagePending = new Map>()
+const slots = new Map void>; count: number }>()
+const task = new Map()
+const wait: string[] = []
+const list = new Map()
+let active = 0
+
+export function limit(ms: number, fallback: T, run: () => Promise) {
+ return Promise.race([
+ run().catch(() => fallback),
+ new Promise((resolve) => setTimeout(() => resolve(fallback), ms)),
+ ])
+}
+
+export async function exec(cmd: string[], ms = CLI_MS): Promise {
+ const abort = new AbortController()
+ const id = setTimeout(() => abort.abort(), ms)
+ return Process.text(cmd, {
+ cwd: Instance.worktree,
+ nothrow: true,
+ abort: abort.signal,
+ kill: "SIGKILL",
+ timeout: 0,
+ })
+ .catch(() => ({
+ code: 1,
+ stdout: Buffer.alloc(0),
+ stderr: Buffer.alloc(0),
+ text: "",
+ }))
+ .finally(() => clearTimeout(id))
+}
+
+function clean(input: string) {
+ return input.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim()
+}
+
+function decode(input: string) {
+ return input
+ .replace(/&/g, "&")
+ .replace(/</g, "<")
+ .replace(/>/g, ">")
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
+ .replace(/([0-9]+);/g, (_, num) => String.fromCodePoint(parseInt(num, 10)))
+}
+
+function strip(input: string) {
+ return decode(input)
+ .replace(/
/gi, "\n")
+ .replace(/<\/(p|div|li|h1|h2|h3|h4|ul|ol)>/gi, "\n")
+ .replace(/<[^>]+>/g, " ")
+ .replace(/\s+\n/g, "\n")
+ .replace(/\n{3,}/g, "\n\n")
+ .replace(/[ \t]{2,}/g, " ")
+ .trim()
+}
+
+function clip(input: string, max = 600) {
+ return input.length <= max ? input : `${input.slice(0, max - 1).trim()}…`
+}
+
+function prose(input: string, mark: string) {
+ const idx = input.indexOf(mark)
+ if (idx < 0) return
+ const body = input.slice(idx)
+ const match = /]*>([\s\S]*?)<\/div>/.exec(body)
+ if (!match) return
+ const text = clip(strip(match[1]))
+ if (!text) return
+ return text
+}
+
+export function extractPage(input: string): Page | undefined {
+ const summary = prose(input, "Summary
")
+ if (summary) return { text: summary, source: "skills_summary" }
+
+ const skill = prose(input, "SKILL.md")
+ if (skill) return { text: skill, source: "skill_md" }
+}
+
+function tokens(input: string) {
+ return clean(input).split(/\s+/).filter(Boolean)
+}
+
+async function gate(max: number, run: () => Promise) {
+ const slot = slots.get(max) ?? { queue: [], count: 0 }
+ slots.set(max, slot)
+ if (slot.count >= max) await new Promise((resolve) => slot.queue.push(resolve))
+ slot.count += 1
+ return run().finally(() => {
+ slot.count -= 1
+ slot.queue.shift()?.()
+ })
+}
+
+function put(job: Job) {
+ task.set(job.job_id, job)
+ const next = [job.job_id, ...(list.get(job.directory) ?? []).filter((item) => item !== job.job_id)].slice(0, 50)
+ list.set(job.directory, next)
+}
+
+function view(job: Job) {
+ return InstallJob.parse({
+ job_id: job.job_id,
+ id: job.id,
+ provider: job.provider,
+ name: job.name,
+ registry: job.registry,
+ package: job.package,
+ source: job.source,
+ scope: job.scope,
+ status: job.status,
+ message: job.message,
+ started_at: job.started_at,
+ finished_at: job.finished_at,
+ })
+}
+
+function step() {
+ while (active < 2) {
+ const id = wait.shift()
+ if (!id) return
+ const job = task.get(id)
+ if (!job) continue
+ job.status = "running"
+ job.started_at = Date.now()
+ put(job)
+ active += 1
+ void Instance.provide({
+ directory: job.directory,
+ fn: async () => {
+ await job.work()
+ },
+ })
+ .then(() => {
+ job.status = "success"
+ job.finished_at = Date.now()
+ job.message = undefined
+ put(job)
+ })
+ .catch((err) => {
+ job.status = "error"
+ job.finished_at = Date.now()
+ job.message = text(err)
+ put(job)
+ })
+ .finally(() => {
+ active -= 1
+ step()
+ })
+ }
+}
+
+function text(input: unknown) {
+ return input instanceof Error ? input.message : String(input)
+}
+
+function parseModel(input?: string) {
+ if (!input) return
+ const idx = input.indexOf("/")
+ if (idx <= 0 || idx === input.length - 1) return
+ return {
+ providerID: ProviderID.make(input.slice(0, idx)),
+ modelID: ModelID.make(input.slice(idx + 1)),
+ } satisfies SearchModel
+}
+
+function printModel(input?: SearchModel) {
+ if (!input) return
+ return `${input.providerID}/${input.modelID}`
+}
+
+async function pick(input?: SearchModel) {
+ if (input) return input
+ const cfg = await Config.get().catch(() => undefined)
+ const fromConfig = parseModel(cfg?.search_model)
+ if (fromConfig) return fromConfig
+ for (const item of SEARCH_MODELS) {
+ const next = parseModel(item)
+ if (!next) continue
+ const ok = await Provider.getModel(next.providerID, next.modelID).then(() => true).catch(() => false)
+ if (ok) return next
+ }
+ const fallback = await Provider.defaultModel().catch(() => undefined)
+ if (!fallback) return
+ return {
+ providerID: fallback.providerID,
+ modelID: fallback.modelID,
+ } satisfies SearchModel
+}
+
+async function language(input?: SearchModel) {
+ const model = await pick(input)
+ if (!model) return { model }
+ const resolved = await Provider.getModel(model.providerID, model.modelID).catch(() => undefined)
+ if (!resolved) return { model }
+ const out = await Provider.getLanguage(resolved).catch(() => undefined)
+ return { model, language: out }
+}
+
+export function seed(query: string) {
+ const map: Record = {
+ update: ["updater", "refresh", "sync", "maintenance", "check"],
+ auto: ["automatic", "automation"],
+ skill: ["skills", "plugin"],
+ install: ["add", "download", "setup"],
+ }
+ const words = tokens(query).flatMap((part) => map[part] ?? [])
+ const text = query.toLowerCase()
+ const alias: Array<[string, string[]]> = [
+ ["润色", ["polish", "proofread", "editing", "rewrite", "humanizer"]],
+ ["改写", ["rewrite", "editing", "polish"]],
+ ["改论文", ["paper", "polish", "latex", "proofread"]],
+ ["论文", ["paper", "latex", "manuscript"]],
+ ["更新", ["update", "updater", "refresh", "sync", "check"]],
+ ["自动更新", ["auto updater", "update", "updater", "refresh", "sync"]],
+ ["技能", ["skills", "find", "install"]],
+ ["skill", ["skills", "find", "install"]],
+ ["有人味", ["humanizer", "natural", "human-like", "writing"]],
+ ["人味", ["humanizer", "human-like", "natural"]],
+ ["写作", ["writing", "rewrite", "editing"]],
+ ["翻译", ["translate", "translation"]],
+ ["总结", ["summarize", "summary"]],
+ ]
+ return [
+ ...words,
+ ...alias.flatMap(([key, value]) => (text.includes(key) ? value : [])),
+ ]
+}
+
+function score(query: string, text: string) {
+ const q = clean(query)
+ const t = clean(text)
+ if (!q || !t) return undefined
+ if (t === q) return "exact" as const
+ if (t.includes(q)) return "exact" as const
+ return tokens(q).every((part) => t.includes(part)) ? ("semantic" as const) : undefined
+}
+
+function kind(input: string) {
+ const text = clean(input)
+ const out = new Set()
+ if (/\b(paper|latex|manuscript|submission|academic|journal|conference|scholarly)\b/.test(text)) out.add("paper")
+ if (/\b(proofread|proofreading|copy editing|copyediting|copy edit|grammar|editorial|reviewing|review)\b/.test(text)) out.add("proof")
+ if (/\b(human|humanizer|humanize|human like|humanlike|natural language|natural writing|rewrite|rewriting)\b/.test(text)) out.add("human")
+ if (/\b(video|media|multimedia|clip|audio|footage|render|subtitle|captions?)\b/.test(text)) out.add("media")
+ if (/\b(find|search|install|plugin|marketplace)\b|discover skills|skills cli/.test(text)) out.add("tool")
+ if (/\b(update|updater|refresh|sync|maintenance|auto update|autoupdate)\b|check updates?\b/.test(text)) out.add("updater")
+ if (/\b(ui|ux|interface)\b|design system/.test(text)) out.add("ui")
+ if (/\b(code|refactor|lint|format|coding)\b/.test(text)) out.add("code")
+ if (/\b(translate|translation)\b/.test(text)) out.add("translate")
+ return out
+}
+
+function intent(query: string) {
+ const want = kind([query, ...seed(query)].join(" "))
+ if (want.has("translate")) return "translate" as const
+ if (want.has("paper") || want.has("proof")) return "paper" as const
+ if (want.has("human")) return "human" as const
+ if (want.has("tool") || want.has("updater")) return "tool" as const
+}
+
+function blocked(query: string, item: SearchResult, body?: string) {
+ const want = intent(query)
+ if (!want) return false
+ const have = kind([item.name, item.source, item.registry, item.description, body].filter(Boolean).join(" "))
+ if (want === "paper") return have.has("media") || have.has("ui") || have.has("tool") || have.has("updater") || have.has("translate")
+ if (want === "human") return have.has("media") || have.has("ui") || have.has("tool") || have.has("updater")
+ if (want === "translate") return have.has("media") || have.has("ui") || have.has("tool") || have.has("updater")
+ return have.has("paper") || have.has("proof") || have.has("media") || have.has("ui") || have.has("human")
+}
+
+function relate(query: string, item: SearchResult, body?: string): z.infer {
+ const want = intent(query)
+ const have = kind([item.name, item.source, item.registry, item.description, body].filter(Boolean).join(" "))
+
+ if (want === "paper") {
+ if (have.has("paper") || have.has("proof")) return "high"
+ if (have.has("human") || have.has("code")) return "medium"
+ if (have.has("media") || have.has("tool") || have.has("ui") || have.has("translate")) return "low"
+ }
+
+ if (want === "human") {
+ if (have.has("human")) return "high"
+ if (have.has("proof") || have.has("paper")) return "medium"
+ if (have.has("media") || have.has("tool") || have.has("ui")) return "low"
+ }
+
+ if (want === "translate") {
+ if (have.has("translate")) return "high"
+ if (have.has("paper") || have.has("proof")) return "medium"
+ if (have.has("media") || have.has("tool") || have.has("ui")) return "low"
+ }
+
+ if (want === "tool") {
+ if (have.has("tool") || have.has("updater")) return "high"
+ if (have.has("code")) return "medium"
+ if (have.has("paper") || have.has("proof") || have.has("media") || have.has("ui") || have.has("human")) return "low"
+ }
+
+ if (item.rank === "exact") return "high"
+ if (item.rank === "semantic") return "medium"
+ return "low"
+}
+
+function level(
+ query: string,
+ item: SearchResult,
+ body?: string,
+ review?: z.infer,
+): z.infer {
+ const base = relate(query, item, body)
+ if (!review) return base
+ if (base === "high") return "high"
+ if (base === "low") return "low"
+ return review === "low" ? "low" : "medium"
+}
+
+function split(query: string, item: SearchResult, body?: string, relevance?: z.infer) {
+ if (blocked(query, item, body)) return
+ const rank = relevance ?? item.relevance ?? relate(query, item, body)
+ if (item.provider === "external" && !body) return item.rank === "exact" ? ("more" as const) : undefined
+ if (rank === "high") return "main" as const
+ if (rank === "medium") return "more" as const
+}
+
+async function refine(
+ query: string,
+ coarse: SearchResult[],
+ pages: Array<{ item: SearchResult; page?: Page }>,
+ model?: SearchModel,
+ start = Date.now(),
+) {
+ const order = new Set(merge(query, coarse))
+ const body = new Map(pages.map((item) => [item.item.id, item.page]))
+ const rated = await review(query, pages.filter((entry) => entry.page), model)
+ const map = new Map(rated.map((item) => [item.id, item]))
+ const refined = coarse.reduce((acc, item) => {
+ if (item.provider === "registry") {
+ const relevance = item.rank === "exact" ? "high" : "medium"
+ const tier = split(query, item, item.description, relevance)
+ if (!tier) return acc
+ acc.push({
+ ...item,
+ relevance,
+ tier,
+ })
+ return acc
+ }
+ const page = body.get(item.id)
+ const next = map.get(item.id)
+ const relevance = level(query, item, page?.text, next?.relevance)
+ const tier = split(query, item, page?.text, relevance)
+ if (!tier) return acc
+ acc.push({
+ ...item,
+ relevance,
+ summary_zh: next?.summary_zh,
+ summary_source: page?.source,
+ tier,
+ })
+ return acc
+ }, [])
+ const sorted = refined.toSorted((a, b) => {
+ const left = a.relevance === "high" ? 2 : a.relevance === "medium" ? 1 : 0
+ const right = b.relevance === "high" ? 2 : b.relevance === "medium" ? 1 : 0
+ if (left !== right) return right - left
+ return [...order].indexOf(a.id) - [...order].indexOf(b.id)
+ })
+ return {
+ main: sorted.filter((item) => item.tier === "main"),
+ more: sorted.filter((item) => item.tier === "more"),
+ meta: {
+ model: printModel(model),
+ latency_ms: Date.now() - start,
+ },
+ } satisfies SearchOutput
+}
+
+function rank(input: SearchResult) {
+ const exact = input.rank === "exact" ? 100 : 0
+ const local = input.provider === "registry" ? 20 : 0
+ const installed = input.installed ? 10 : 0
+ return exact + local + installed
+}
+
+function registryDir() {
+ return path.join(Instance.worktree, ".opencode", "skills")
+}
+
+function registryLockFile() {
+ return path.join(Instance.worktree, ".opencode", "skills-lock.json")
+}
+
+function externalDir(scope: z.infer) {
+ if (scope === "global") return path.join(Global.Path.home, ".agents", "skills")
+ return path.join(Instance.worktree, ".agents", "skills")
+}
+
+function externalLockFile() {
+ return path.join(Instance.worktree, "skills-lock.json")
+}
+
+function base(url: string) {
+ return url.endsWith("/") ? url : `${url}/`
+}
+
+async function readLock(): Promise {
+ const lock = await Filesystem.readJson(registryLockFile())
+ .then((x) => Lock.parse(x))
+ .catch(
+ () =>
+ ({
+ version: 1 as const,
+ skills: {},
+ }) satisfies LockState,
+ )
+ return {
+ version: 1,
+ skills: lock.skills as Record,
+ }
+}
+
+async function writeLock(lock: LockState) {
+ await Filesystem.writeJson(registryLockFile(), lock)
+}
+
+async function readExternalLock() {
+ return Filesystem.readJson(externalLockFile()).then((x) => ExternalLock.parse(x)).catch(() => undefined)
+}
+
+async function registry() {
+ const cfg = await Config.get()
+ const urls = cfg.skills?.urls ?? []
+ const list = await Promise.all(
+ urls.map(async (url) => {
+ const root = base(url)
+ const index = new URL("index.json", root).href
+ const data = await fetch(index, { signal: AbortSignal.timeout(REGISTRY_MS) })
+ .then((res) => (res.ok ? res.json() : Promise.reject(new Error(`Failed to fetch ${index}`))))
+ .then((json) => Index.parse(json))
+ .catch(() => undefined)
+ if (!data) return []
+ return data.skills
+ .filter((item) => item.files.includes("SKILL.md"))
+ .map((item) => ({ ...item, registry: url, base: root }))
+ }),
+ )
+ return list.flat()
+}
+
+async function installedGlobal() {
+ const dir = externalDir("global")
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => [])
+ return new Set(entries.filter((item) => item.isDirectory()).map((item) => item.name))
+}
+
+async function state() {
+ const [lock, ext, global] = await Promise.all([readLock(), readExternalLock(), installedGlobal()])
+ return { lock, ext, global }
+}
+
+async function semantic(query: string, model?: SearchModel) {
+ const fallback = {
+ intent: query,
+ phrases: [],
+ keywords: [...new Set(seed(query))].slice(0, 8),
+ }
+ return limit(1200, fallback, async () =>
+ {
+ const resolved = await language(model)
+ if (!resolved.language) return fallback
+
+ return generateObject({
+ model: resolved.language,
+ temperature: 0.2,
+ schema: Terms,
+ messages: [
+ {
+ role: "system",
+ content:
+ "Expand skill search queries into concise phrases and keywords. Prefer synonyms, related actions, and common package terms.",
+ },
+ {
+ role: "user",
+ content: `Query: ${query}`,
+ },
+ ],
+ }).then((x) => x.object)
+ },
+ )
+}
+
+async function find(query: string, ms = CLI_MS) {
+ const out = await exec(["npx", "-y", "skills", "find", query], ms)
+ return parseFind(out.text)
+}
+
+async function checkExternal() {
+ const out = await exec(["npx", "-y", "skills", "check"])
+ return parseCheck(out.text)
+}
+
+async function addExternal(input: { source: string; skill: string; scope: z.infer }) {
+ const cmd = [
+ "npx",
+ "-y",
+ "skills",
+ "add",
+ input.source,
+ "--skill",
+ input.skill,
+ "--agent",
+ "opencode",
+ "--yes",
+ "--copy",
+ ]
+ if (input.scope === "global") cmd.splice(4, 0, "-g")
+ await Process.run(cmd, {
+ cwd: Instance.worktree,
+ })
+}
+
+async function fetchSkill(item: RegistryItem) {
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-skill-"))
+ await Promise.all(
+ item.files.map(async (file) => {
+ const url = new URL(`${item.name}/${file}`, item.base).href
+ const body = await fetch(url)
+ .then((res) => (res.ok ? res.arrayBuffer() : Promise.reject(new Error(`Failed to fetch ${url}`))))
+ .then((buf) => new Uint8Array(buf))
+ await Filesystem.write(path.join(dir, file), body)
+ }),
+ )
+ return dir
+}
+
+async function addRegistry(item: RegistryItem) {
+ const dir = registryDir()
+ const dst = path.join(dir, item.name)
+ const lock = await readLock()
+ const current = lock.skills[item.name]
+ const exists = await Filesystem.isDir(dst)
+ if (exists && (!current || current.registry !== item.registry)) {
+ throw new Error(`Skill "${item.name}" already exists`)
+ }
+
+ const tmp = await fetchSkill(item)
+ await fs.rm(dst, { recursive: true, force: true }).catch(() => undefined)
+ await fs.mkdir(dir, { recursive: true })
+ await fs.cp(tmp, dst, { recursive: true })
+ await fs.rm(tmp, { recursive: true, force: true }).catch(() => undefined)
+
+ lock.skills[item.name] = {
+ registry: item.registry,
+ version: item.version,
+ checksum: item.checksum,
+ installed_at: Date.now(),
+ }
+ await writeLock(lock)
+}
+
+function entryText(item: RegistryItem) {
+ return [item.name, item.description, ...(item.tags ?? [])].join(" ")
+}
+
+function localResult(
+ query: string,
+ item: RegistryItem,
+ st: Awaited>,
+ extra: string[],
+): SearchResult | undefined {
+ const text = entryText(item)
+ const hit = [query, ...extra]
+ .map((term) => score(term, text))
+ .find((term): term is z.infer => !!term)
+ if (!hit) return
+ const installed = !!st.lock.skills[item.name]
+ return {
+ id: `${item.registry}#${item.name}`,
+ provider: "registry",
+ rank: hit,
+ name: item.name,
+ description: item.description,
+ registry: item.registry,
+ version: item.version,
+ installed,
+ scope: installed ? "project" : undefined,
+ }
+}
+
+function externalResult(
+ query: string,
+ item: FindResult,
+ st: Awaited>,
+ extra: string[],
+): SearchResult | undefined {
+ const text = [item.name, item.source].join(" ")
+ const hit = [query, ...extra]
+ .map((term) => score(term, text))
+ .find((term): term is z.infer => !!term)
+ if (!hit) return
+ const project = st.ext?.skills[item.name]
+ const global = st.global.has(item.name)
+ return {
+ id: item.package,
+ provider: "external",
+ rank: hit,
+ name: item.name,
+ installs: item.installs,
+ package: item.package,
+ source: item.source,
+ url: item.url,
+ installed: !!project || global,
+ scope: project ? "project" : global ? "global" : undefined,
+ }
+}
+
+async function page(key: string, url?: string) {
+ if (!url) return
+ const cached = pageMemo.get(key)
+ if (cached && Date.now() - cached.at < SUMMARY_TTL) return cached.result
+ const inflight = pagePending.get(key)
+ if (inflight) return inflight
+
+ const run = gate(4, async () =>
+ fetch(url, { signal: AbortSignal.timeout(PAGE_MS) })
+ .then((res) => (res.ok ? res.text() : Promise.reject(new Error(`Failed to fetch ${url}`))))
+ .then((html) => extractPage(html))
+ .catch(() => undefined),
+ ).finally(() => pagePending.delete(key))
+
+ pagePending.set(key, run)
+ const result = await run
+ pageMemo.set(key, { at: Date.now(), result })
+ return result
+}
+
+export function brief(input: z.input, body?: string) {
+ const all = clean([input.name, input.source, input.registry, input.description, body].filter(Boolean).join(" "))
+ const guess = ([
+ [/(video|media)/, "这个 skill 主要用于视频或多媒体内容的编辑与处理。"],
+ [/(paper|latex|manuscript|submission|review)/, "这个 skill 主要用于学术论文或 LaTeX 文稿的润色、修改和审阅。"],
+ [/(proofread|proofreading|copy editing|copyediting|copy edit|grammar)/, "这个 skill 主要用于英文文本的校对、语法修改和措辞润色。"],
+ [/(humanizer|human like|humanlike|natural language|natural writing|rewrite)/, "这个 skill 主要用于把文本改写得更自然、更像真人表达。"],
+ [/(find|search|install|skills|plugin)/, "用于搜索、发现并安装其他技能"],
+ [/(ui|ux)/, "用于界面细节优化与体验打磨"],
+ [/(code)/, "用于代码整理、优化或修订"],
+ ] as const)
+ .find(([rule]) => rule.test(all))
+ ?.[1]
+ const line = clip((body ?? input.description ?? "").replace(/\s+/g, " ").trim(), 120)
+ if (guess) return guess.startsWith("这个 skill") ? guess : `这个 skill 主要${guess}。`
+ if (line) return `这个 skill 主要围绕 ${line}`
+ if (input.source) return `这是 ${input.source} 提供的 ${input.name} skill。当前只拿到了基础信息,建议先点开详情后再决定。`
+ return `这是一个名为 ${input.name} 的 skill。当前只拿到了基础信息,建议先看详情。`
+}
+
+async function review(query: string, list: Array<{ item: SearchResult; page?: Page }>, model?: SearchModel) {
+ const fallback = list.map((entry) => ({
+ id: entry.item.id,
+ relevance: relate(query, entry.item, entry.page?.text),
+ summary_zh: entry.page?.text ? brief(entry.item, entry.page.text) : undefined,
+ summary_source: entry.page?.source,
+ })) satisfies Reviewed[]
+
+ if (list.length === 0) return fallback
+
+ const resolved = await language(model)
+ if (!resolved.language) return fallback
+ const lang = resolved.language
+
+ return limit(2_500, fallback, async () => {
+ const out = await generateObject({
+ model: lang,
+ temperature: 0.2,
+ schema: Review,
+ messages: [
+ {
+ role: "system",
+ content:
+ "你是一个技能市场检索重排器。给定用户查询和若干 skill 的真实内容,请为每个 skill 输出 high/medium/low 相关度,并用简体中文写一句不超过 40 字的简介。只能依据提供材料,不要猜测没有出现的功能。",
+ },
+ {
+ role: "user",
+ content: [
+ `查询:${query}`,
+ ...list.map((entry) =>
+ [
+ `ID: ${entry.item.id}`,
+ `Name: ${entry.item.name}`,
+ `Source: ${entry.item.source ?? entry.item.registry ?? "unknown"}`,
+ `Material: ${entry.page?.text ?? entry.item.description ?? ""}`,
+ ].join("\n"),
+ ),
+ ].join("\n\n"),
+ },
+ ],
+ }).then((item) => item.object.items)
+ const map = new Map(out.map((item) => [item.id, item]))
+ return fallback.map((item) => {
+ const next = map.get(item.id)
+ if (!next) return item
+ return {
+ ...item,
+ relevance: next.relevance,
+ summary_zh: next.summary_zh || item.summary_zh,
+ }
+ })
+ })
+}
+
+async function zh(input: z.input, body?: string, model?: SearchModel) {
+ const base = brief(input, body)
+ if (!body) return base
+
+ const resolved = await language(model)
+ if (!resolved.language) return base
+ const lang = resolved.language
+
+ return limit(1_500, base, async () =>
+ generateText({
+ model: lang,
+ temperature: 0.2,
+ maxOutputTokens: 120,
+ messages: [
+ {
+ role: "system",
+ content:
+ "你是一个技能市场助手。请基于给定材料,用简体中文写 1 到 2 句简介,说明这个 skill 适合做什么、何时使用。不要使用项目符号,不要虚构功能。",
+ },
+ {
+ role: "user",
+ content: `Skill: ${input.name}\nSource: ${input.source ?? input.registry ?? "unknown"}\nMaterial:\n${body}`,
+ },
+ ],
+ }).then((out) => out.text.trim() || base),
+ )
+}
+
+export function splitPackage(input: string) {
+ const idx = input.lastIndexOf("@")
+ if (idx <= 0 || idx === input.length - 1) throw new Error(`Invalid package: ${input}`)
+ return {
+ source: input.slice(0, idx),
+ skill: input.slice(idx + 1),
+ }
+}
+
+export function parseFind(input: string) {
+ const text = stripAnsi(input)
+ const lines = text.split(/\r?\n/)
+ const out: FindResult[] = []
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i].trim()
+ const next = lines[i + 1]?.trim()
+ const match = /^([^ ]+@[^ ]+)\s+([0-9.]+[KMB]?)\s+installs$/i.exec(line)
+ if (!match || !next?.startsWith("└ ")) continue
+ const info = splitPackage(match[1])
+ out.push({
+ package: match[1],
+ installs: match[2],
+ source: info.source,
+ name: info.skill,
+ url: next.replace(/^└\s+/, ""),
+ })
+ }
+ return out
+}
+
+export function parseCheck(input: string) {
+ const text = stripAnsi(input)
+ const lines = text.split(/\r?\n/)
+ const updates: Record = {}
+ const failed: Record = {}
+ let mode: "updates" | "failed" | undefined
+ let name = ""
+ for (const row of lines) {
+ const line = row.trim()
+ if (line.includes("update(s) available")) {
+ mode = "updates"
+ continue
+ }
+ if (line.startsWith("Could not check")) {
+ mode = "failed"
+ continue
+ }
+ if (line.startsWith("↑ ") || line.startsWith("✗ ")) {
+ name = line.slice(2).trim()
+ continue
+ }
+ if (!name || !line.startsWith("source: ")) continue
+ const src = line.slice("source: ".length).trim()
+ if (mode === "updates") updates[name] = src
+ if (mode === "failed") failed[name] = src
+ name = ""
+ }
+ return { updates, failed }
+}
+
+export function merge(query: string, list: Array>) {
+ return list
+ .toSorted((a, b) => {
+ const diff = rank(a as SearchResult) - rank(b as SearchResult)
+ if (diff !== 0) return -diff
+ const exact = clean(a.name) === clean(query) ? 1 : 0
+ const other = clean(b.name) === clean(query) ? 1 : 0
+ if (exact !== other) return other - exact
+ return a.name.localeCompare(b.name)
+ })
+ .map((item) => item.id)
+}
+
+export namespace Catalog {
+ async function current() {
+ const st = await state()
+ const external = Object.entries(st.ext?.skills ?? {}).map(([name, item]) => ({
+ id: `${item.source}@${name}`,
+ provider: "external" as const,
+ name,
+ package: `${item.source}@${name}`,
+ source: item.source,
+ installed: true,
+ scope: "project" as const,
+ update_available: false,
+ }))
+ const global = [...st.global]
+ .filter((name) => !st.ext?.skills[name])
+ .map((name) => ({
+ id: `global:${name}`,
+ provider: "external" as const,
+ name,
+ installed: true,
+ scope: "global" as const,
+ update_available: false,
+ }))
+ const registry = Object.entries(st.lock.skills).map(([name, item]) => ({
+ id: `${item.registry}#${name}`,
+ provider: "registry" as const,
+ name,
+ registry: item.registry,
+ version: item.version,
+ installed: true,
+ scope: "project" as const,
+ update_available: false,
+ }))
+ return [...registry, ...external, ...global]
+ }
+
+ export async function search(input: z.input, model?: SearchModel) {
+ const start = Date.now()
+ const params = SearchInput.parse(input)
+ const query = params.query.trim()
+ const current = await pick(model)
+ if (!query) return { main: [], more: [], meta: { model: printModel(current), latency_ms: Date.now() - start } }
+
+ const expanded =
+ params.semantic === false ? { intent: query, phrases: [], keywords: [] } : await semantic(query, current)
+ const extra = [...expanded.phrases, ...expanded.keywords].filter((item) => clean(item) !== clean(query))
+ const st = await state()
+ const local = await registry().then((items) =>
+ items
+ .map((item) => localResult(query, item, st, extra))
+ .filter((item): item is SearchResult => !!item),
+ )
+
+ const searches = [query, ...extra]
+ .filter((item, idx, arr): item is string => !!item && arr.indexOf(item) === idx)
+ .slice(0, 4)
+ const external = (
+ await Promise.all(searches.map((item) => find(item, params.semantic === false ? CLI_MS : FIND_MS)))
+ )
+ .flat()
+ .reduce((acc, item) => acc.set(item.package, item), new Map())
+ const extraResults = [...external.values()]
+ .map((item) => externalResult(query, item, st, extra))
+ .filter((item): item is SearchResult => !!item)
+
+ const all = [...local, ...extraResults]
+ const order = new Set(merge(query, all))
+ const coarse = all.toSorted((a, b) => [...order].indexOf(a.id) - [...order].indexOf(b.id))
+ if (params.semantic === false) {
+ return {
+ main: coarse.map((item) => ({ ...item, tier: "main" as const })),
+ more: [],
+ meta: {
+ model: printModel(current),
+ latency_ms: Date.now() - start,
+ },
+ } satisfies SearchOutput
+ }
+
+ const picked = coarse.filter((item) => item.provider === "external").slice(0, 12)
+ const pages = await Promise.all(
+ picked.map(async (item) => ({
+ item,
+ page: await page(item.url ?? item.id, item.url),
+ })),
+ )
+ return refine(query, coarse, pages, current, start)
+ }
+
+ export async function bench(input: z.input, model?: SearchModel) {
+ const start = Date.now()
+ const params = BenchInput.parse(input)
+ const current = await pick(model)
+ const coarse = params.items.map((item) => ({
+ id: item.id,
+ provider: "external" as const,
+ rank: item.rank,
+ name: item.name,
+ description: item.description,
+ source: item.source,
+ installed: false,
+ }))
+ const pages = params.items.map((item, idx) => ({
+ item: coarse[idx]!,
+ page: item.body ? { text: item.body, source: item.summary_source ?? "skill_md" } : undefined,
+ }))
+ return refine(params.query, coarse, pages, current, start)
+ }
+
+ export async function installed() {
+ return current()
+ }
+
+ export async function describe(input: z.input) {
+ const params = DescribeInput.parse(input)
+ const key = params.url ?? params.package ?? params.id
+ const cached = memo.get(key)
+ if (cached && Date.now() - cached.at < SUMMARY_TTL) return cached.result
+ const inflight = pending.get(key)
+ if (inflight) return inflight
+
+ const run = gate(2, async () => {
+ const current = await pick()
+ const content = await page(key, params.url)
+ const result = {
+ summary_zh: await zh(params, content?.text, current),
+ summary_source: content?.source ?? "fallback",
+ } satisfies z.infer
+ memo.set(key, { at: Date.now(), result })
+ return result
+ }).finally(() => pending.delete(key))
+
+ pending.set(key, run)
+ return run
+ }
+
+ export async function check() {
+ const base = await current()
+ const st = await state()
+ const registryItems = await registry()
+ const registryMap = new Map(registryItems.map((item) => [`${item.registry}#${item.name}`, item]))
+ const external = base.some((item) => item.provider === "external" && item.scope === "project" && item.source)
+ ? await checkExternal()
+ : { updates: {}, failed: {} }
+ return base.map((item) => {
+ if (item.provider === "registry" && item.registry) {
+ const next = registryMap.get(`${item.registry}#${item.name}`)
+ const prev = st.lock.skills[item.name]
+ const update = !!next && (next.version !== prev?.version || next.checksum !== prev?.checksum)
+ return { ...item, description: next?.description, update_available: update }
+ }
+ if (item.provider === "external" && item.scope === "project" && item.source) {
+ return {
+ ...item,
+ update_available: external.updates[item.name] === item.source,
+ }
+ }
+ return item
+ })
+ }
+
+ export async function jobs() {
+ const ids = list.get(Instance.directory) ?? []
+ return ids.flatMap((id) => {
+ const job = task.get(id)
+ return job ? [view(job)] : []
+ })
+ }
+
+ export async function install(input: z.input) {
+ const params = InstallInput.parse(input)
+ const directory = Instance.directory
+ const job =
+ params.kind === "registry"
+ ? ({
+ job_id: crypto.randomUUID(),
+ id: `${params.registry}#${params.name}`,
+ directory,
+ provider: "registry" as const,
+ name: params.name,
+ registry: params.registry,
+ status: "queued" as const,
+ work: async () => {
+ const item = await registry().then((items) =>
+ items.find((item) => item.registry === params.registry && item.name === params.name),
+ )
+ if (!item) throw new Error(`Skill "${params.name}" not found in registry`)
+ await addRegistry(item)
+ await Instance.dispose()
+ },
+ } satisfies Job)
+ : ((item) =>
+ ({
+ job_id: crypto.randomUUID(),
+ id: params.package,
+ directory,
+ provider: "external" as const,
+ name: item.skill,
+ package: params.package,
+ source: item.source,
+ scope: params.scope,
+ status: "queued" as const,
+ work: async () => {
+ await addExternal({
+ source: item.source,
+ skill: item.skill,
+ scope: params.scope,
+ })
+ await Instance.dispose()
+ },
+ } satisfies Job))(splitPackage(params.package))
+ put(job)
+ wait.push(job.job_id)
+ step()
+ return view(task.get(job.job_id)!)
+ }
+
+ export async function update(input: z.input) {
+ const params = UpdateInput.parse(input)
+ const names = new Set(params.names ?? [])
+ const status = await check()
+ const list = status.filter((item) => item.update_available && (names.size === 0 || names.has(item.name)))
+ const registryMap = await registry().then((items) => new Map(items.map((item) => [`${item.registry}#${item.name}`, item])))
+
+ for (const item of list) {
+ if (item.provider === "registry" && item.registry) {
+ const next = registryMap.get(`${item.registry}#${item.name}`)
+ if (!next) continue
+ await addRegistry(next)
+ continue
+ }
+ if (item.provider === "external" && item.scope === "project" && "source" in item && item.source) {
+ await addExternal({
+ source: item.source,
+ skill: item.name,
+ scope: "project",
+ })
+ }
+ }
+
+ if (list.length > 0) await Instance.dispose()
+ return { ok: true, updated: list.map((item) => item.name) }
+ }
+}
diff --git a/packages/opencode/src/skill/search.test.ts b/packages/opencode/src/skill/search.test.ts
new file mode 100644
index 0000000000..4da39106cb
--- /dev/null
+++ b/packages/opencode/src/skill/search.test.ts
@@ -0,0 +1,139 @@
+import { afterAll, describe, expect, mock, test } from "bun:test"
+import { ModelID, ProviderID } from "@/provider/schema"
+
+mock.module("ai", () => ({
+ generateObject: async (input: { messages?: Array<{ role: string; content: string }> }) => {
+ const text = input.messages?.find((item) => item.role === "user")?.content ?? ""
+ if (text.includes("make this writing sound more human")) {
+ return {
+ object: {
+ items: [
+ {
+ id: "writer/skills@humanizer",
+ relevance: "high",
+ summary_zh: "把文本改得更自然。",
+ },
+ {
+ id: "eyh0602/skillshub@paper-polish",
+ relevance: "high",
+ summary_zh: "润色论文。",
+ },
+ ],
+ },
+ }
+ }
+ return {
+ object: {
+ items: [],
+ },
+ }
+ },
+ generateText: async () => ({ text: "" }),
+}))
+
+mock.module("@/provider/provider", () => ({
+ Provider: {
+ getModel: async () => ({}),
+ getLanguage: async () => ({}),
+ defaultModel: async () => undefined,
+ list: async () => ({}),
+ },
+}))
+
+const { Catalog } = await import("./catalog")
+
+const model = {
+ providerID: ProviderID.make("mock"),
+ modelID: ModelID.make("mock"),
+}
+
+afterAll(() => {
+ mock.restore()
+})
+
+describe("Catalog.bench", () => {
+ test("keeps translation intent for translating papers", async () => {
+ const out = await Catalog.bench(
+ {
+ query: "找一下翻译论文的skill",
+ items: [
+ {
+ id: "translator/skills@paper-translation",
+ name: "paper-translation",
+ source: "translator/skills",
+ rank: "semantic",
+ body: "Translate academic manuscripts and research papers while preserving scholarly terminology.",
+ summary_source: "skill_md",
+ },
+ {
+ id: "translator/skills@docs-translation",
+ name: "docs-translation",
+ source: "translator/skills",
+ rank: "semantic",
+ body: "Translate technical documentation and product docs between Chinese and English.",
+ summary_source: "skill_md",
+ },
+ {
+ id: "review/skills@manuscript-review",
+ name: "manuscript-review",
+ source: "review/skills",
+ rank: "semantic",
+ body: "Review manuscript structure and submission readiness for academic papers.",
+ summary_source: "skill_md",
+ },
+ {
+ id: "media/skills@subtitle-translation",
+ name: "subtitle-translation",
+ source: "media/skills",
+ rank: "semantic",
+ body: "Translate subtitles and captions for short-form video projects.",
+ summary_source: "skill_md",
+ },
+ ],
+ },
+ model,
+ )
+
+ expect(out.main.map((item) => item.name)).toEqual(expect.arrayContaining(["paper-translation", "docs-translation"]))
+ expect(out.main.map((item) => item.name)).not.toContain("subtitle-translation")
+ })
+
+ test("does not let the model promote paper polish into humanizer main results", async () => {
+ const out = await Catalog.bench(
+ {
+ query: "make this writing sound more human",
+ items: [
+ {
+ id: "writer/skills@humanizer",
+ name: "humanizer",
+ source: "writer/skills",
+ rank: "exact",
+ body: "Rewrite text so it sounds more natural and less AI-generated.",
+ summary_source: "skill_md",
+ },
+ {
+ id: "writer/skills@writing-humanizer",
+ name: "writing-humanizer",
+ source: "writer/skills",
+ rank: "semantic",
+ body: "Humanize drafted writing by improving flow and natural phrasing.",
+ summary_source: "skill_md",
+ },
+ {
+ id: "eyh0602/skillshub@paper-polish",
+ name: "paper-polish",
+ source: "eyh0602/skillshub",
+ rank: "semantic",
+ body: "Polish and revise academic papers in LaTeX format.",
+ summary_source: "skill_md",
+ },
+ ],
+ },
+ model,
+ )
+
+ expect(out.main.map((item) => item.name)).toEqual(expect.arrayContaining(["humanizer", "writing-humanizer"]))
+ expect(out.main.map((item) => item.name)).not.toContain("paper-polish")
+ expect(out.more.map((item) => item.name)).toContain("paper-polish")
+ })
+})
diff --git a/packages/opencode/test/server/skill-routes.test.ts b/packages/opencode/test/server/skill-routes.test.ts
new file mode 100644
index 0000000000..c6e3793630
--- /dev/null
+++ b/packages/opencode/test/server/skill-routes.test.ts
@@ -0,0 +1,628 @@
+import { afterEach, describe, expect, spyOn, test } from "bun:test"
+import path from "path"
+import { Instance } from "../../src/project/instance"
+import { Process } from "../../src/util/process"
+import { Server } from "../../src/server/server"
+import { resetDatabase } from "../fixture/db"
+import { tmpdir } from "../fixture/fixture"
+
+const root = "https://skills.test/.well-known/skills/"
+const fixture = path.join(import.meta.dir, "../fixture/skills")
+const originalFetch = globalThis.fetch
+let textSpy: ReturnType | undefined
+let runSpy: ReturnType | undefined
+let disposeSpy: ReturnType | undefined
+
+function wait() {
+ let resolve = () => {}
+ const promise = new Promise((done) => {
+ resolve = done
+ })
+ return { promise, resolve }
+}
+
+afterEach(async () => {
+ await resetDatabase()
+ textSpy?.mockRestore()
+ runSpy?.mockRestore()
+ disposeSpy?.mockRestore()
+ textSpy = undefined
+ runSpy = undefined
+ disposeSpy = undefined
+ globalThis.fetch = originalFetch
+})
+
+describe("skill routes", () => {
+ test("searches registry results and installs a skill", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ skills: {
+ urls: [root],
+ },
+ },
+ })
+
+ globalThis.fetch = (async (input) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
+ if (!url.startsWith(root)) return new Response("Not Found", { status: 404 })
+ const file = url.slice(root.length)
+ const body = await Bun.file(path.join(fixture, file)).arrayBuffer()
+ return new Response(body, { status: 200 })
+ }) as typeof fetch
+ textSpy = spyOn(Process, "text").mockResolvedValue({
+ code: 0,
+ stdout: Buffer.alloc(0),
+ stderr: Buffer.alloc(0),
+ text: "",
+ })
+
+ const app = Server.Default()
+
+ const search = await app.request("/skill/search", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-opencode-directory": tmp.path,
+ },
+ body: JSON.stringify({
+ query: "cloudflare",
+ semantic: false,
+ }),
+ })
+
+ expect(search.status).toBe(200)
+ const found = await search.json()
+ expect(found.main).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ name: "cloudflare",
+ provider: "registry",
+ installed: false,
+ registry: root,
+ tier: "main",
+ }),
+ ]),
+ )
+ expect(found.more).toEqual([])
+
+ const installed = await app.request("/skill/installed", {
+ headers: {
+ "x-opencode-directory": tmp.path,
+ },
+ })
+
+ expect(installed.status).toBe(200)
+ expect(await installed.json()).toEqual([])
+
+ const check = await app.request("/skill/check", {
+ headers: {
+ "x-opencode-directory": tmp.path,
+ },
+ })
+
+ expect(check.status).toBe(200)
+ expect(await check.json()).toEqual([])
+ })
+
+ test("search returns quickly when external cli stalls", async () => {
+ await using tmp = await tmpdir({
+ config: {},
+ })
+
+ globalThis.fetch = originalFetch
+ textSpy = spyOn(Process, "text").mockImplementation(
+ (_cmd, opts) =>
+ new Promise((_, reject) => {
+ opts?.abort?.addEventListener("abort", () => reject(new Error("aborted")), {
+ once: true,
+ })
+ }),
+ )
+
+ const app = Server.Default()
+ const start = Date.now()
+ const search = await app.request("/skill/search", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-opencode-directory": tmp.path,
+ },
+ body: JSON.stringify({
+ query: "auto updater",
+ semantic: false,
+ }),
+ })
+
+ expect(search.status).toBe(200)
+ expect(Date.now() - start).toBeLessThan(3_000)
+ expect(await search.json()).toEqual({
+ main: [],
+ more: [],
+ meta: expect.objectContaining({
+ model: expect.any(String),
+ latency_ms: expect.any(Number),
+ }),
+ })
+ })
+
+ test("semantic search summarizes real skill content and drops low-relevance external hits", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ search_model: "opencode/qwen3.6-plus-free",
+ },
+ })
+
+ globalThis.fetch = (async (input) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
+ if (url === "https://skills.sh/eyh0602/skillshub/paper-polish") {
+ return new Response(
+ `
+ SKILL.md
+
+
Paper polish
+
Polish and revise academic papers in LaTeX format.
+
Use this skill when revising, polishing, or editing an existing manuscript.
+
+ `,
+ { status: 200 },
+ )
+ }
+ if (url === "https://skills.sh/blitzreels/agent-skills/video-editing") {
+ return new Response(
+ `
+ SKILL.md
+
+
Video editing
+
Edit short-form videos and multimedia clips for publishing workflows.
+
+ `,
+ { status: 200 },
+ )
+ }
+ if (url === "https://skills.sh/vercel-labs/skills/find-skills") {
+ return new Response(
+ `
+ Summary
+
+
Discover and install specialized agent skills from the open ecosystem.
+
+ `,
+ { status: 200 },
+ )
+ }
+ if (url === "https://skills.sh/paulrberg/agent-skills/code-polish") {
+ return new Response(
+ `
+ SKILL.md
+
+
Code polish
+
Polish and refactor source code for readability and consistency.
+
+ `,
+ { status: 200 },
+ )
+ }
+ return new Response("Not Found", { status: 404 })
+ }) as typeof fetch
+
+ textSpy = spyOn(Process, "text").mockImplementation(async (cmd) => {
+ const query = cmd.at(-1) ?? ""
+ if (!String(query).includes("论文")) {
+ return {
+ code: 0,
+ stdout: Buffer.alloc(0),
+ stderr: Buffer.alloc(0),
+ text: "",
+ }
+ }
+ return {
+ code: 0,
+ stdout: Buffer.alloc(0),
+ stderr: Buffer.alloc(0),
+ text: `
+ eyh0602/skillshub@paper-polish 120 installs
+ └ https://skills.sh/eyh0602/skillshub/paper-polish
+
+ blitzreels/agent-skills@video-editing 88 installs
+ └ https://skills.sh/blitzreels/agent-skills/video-editing
+
+ paulrberg/agent-skills@code-polish 162 installs
+ └ https://skills.sh/paulrberg/agent-skills/code-polish
+
+ oakoss/agent-skills@ui-ux-polish 64 installs
+ └ https://skills.sh/oakoss/agent-skills/ui-ux-polish
+
+ vercel-labs/skills@find-skills 999 installs
+ └ https://skills.sh/vercel-labs/skills/find-skills
+ `,
+ }
+ })
+
+ const app = Server.Default()
+ const search = await app.request("/skill/search", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-opencode-directory": tmp.path,
+ },
+ body: JSON.stringify({
+ query: "找一下论文润色的skill",
+ semantic: true,
+ }),
+ })
+
+ expect(search.status).toBe(200)
+ const body = await search.json()
+ expect(body.meta.model).toBe("opencode/qwen3.6-plus-free")
+ expect(body.main).toEqual([
+ expect.objectContaining({
+ id: "eyh0602/skillshub@paper-polish",
+ name: "paper-polish",
+ provider: "external",
+ relevance: "high",
+ summary_source: "skill_md",
+ summary_zh: expect.stringContaining("论文"),
+ tier: "main",
+ }),
+ ])
+ expect(body.main).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: "oakoss/agent-skills@ui-ux-polish",
+ }),
+ ]),
+ )
+ expect(body.more).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: "paulrberg/agent-skills@code-polish",
+ tier: "more",
+ }),
+ ]),
+ )
+ })
+
+ test("semantic search keeps exact external hits without正文 out of main", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ search_model: "opencode/qwen3.6-plus-free",
+ },
+ })
+
+ globalThis.fetch = (async (input) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
+ if (url === "https://skills.sh/eyh0602/skillshub/paper-polish") {
+ return new Response(
+ `
+ SKILL.md
+
+
Paper polish
+
Polish and revise academic papers in LaTeX format.
+
+ `,
+ { status: 200 },
+ )
+ }
+ return new Response("Not Found", { status: 404 })
+ }) as typeof fetch
+
+ textSpy = spyOn(Process, "text").mockResolvedValue({
+ code: 0,
+ stdout: Buffer.alloc(0),
+ stderr: Buffer.alloc(0),
+ text: `
+ eyh0602/skillshub@paper-polish 120 installs
+ └ https://skills.sh/eyh0602/skillshub/paper-polish
+
+ writer/skills@professional-proofreader 88 installs
+ └ https://skills.sh/writer/skills/professional-proofreader
+ `,
+ })
+
+ const app = Server.Default()
+ const search = await app.request("/skill/search", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-opencode-directory": tmp.path,
+ },
+ body: JSON.stringify({
+ query: "找一下论文润色的skill",
+ semantic: true,
+ }),
+ })
+
+ expect(search.status).toBe(200)
+ const body = await search.json()
+ expect(body.main).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: "eyh0602/skillshub@paper-polish",
+ tier: "main",
+ }),
+ ]),
+ )
+ expect(body.main).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: "writer/skills@professional-proofreader",
+ }),
+ ]),
+ )
+ expect(body.more).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ id: "writer/skills@professional-proofreader",
+ tier: "more",
+ }),
+ ]),
+ )
+ })
+
+ test("semantic updater search keeps updater tools in main and drops unrelated writing skills", async () => {
+ await using tmp = await tmpdir({
+ config: {
+ search_model: "opencode/qwen3.6-plus-free",
+ },
+ })
+
+ globalThis.fetch = (async (input) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
+ if (url === "https://skills.sh/skills.volces.com/auto-updater") {
+ return new Response(
+ `
+ SKILL.md
+
+
Auto updater
+
Automatically update installed skills and keep local skill sets in sync.
+
+ `,
+ { status: 200 },
+ )
+ }
+ if (url === "https://skills.sh/yizhiyanhua-ai/skills-updater/skills-updater") {
+ return new Response(
+ `
+ SKILL.md
+
+
Skills updater
+
Check installed skills, detect available updates, and refresh them with the Skills CLI.
+
+ `,
+ { status: 200 },
+ )
+ }
+ if (url === "https://skills.sh/vercel-labs/skills/find-skills") {
+ return new Response(
+ `
+ Summary
+
+
Discover and install specialized agent skills from the open ecosystem.
+
+ `,
+ { status: 200 },
+ )
+ }
+ if (url === "https://skills.sh/writer/skills/humanizer") {
+ return new Response(
+ `
+ SKILL.md
+
+
Humanizer
+
Rewrite text so it sounds more natural and human.
+
+ `,
+ { status: 200 },
+ )
+ }
+ if (url === "https://skills.sh/eyh0602/skillshub/paper-polish") {
+ return new Response(
+ `
+ SKILL.md
+
+
Paper polish
+
Polish and revise academic papers in LaTeX format.
+
+ `,
+ { status: 200 },
+ )
+ }
+ return new Response("Not Found", { status: 404 })
+ }) as typeof fetch
+
+ textSpy = spyOn(Process, "text").mockResolvedValue({
+ code: 0,
+ stdout: Buffer.alloc(0),
+ stderr: Buffer.alloc(0),
+ text: `
+ skills.volces.com@auto-updater 38 installs
+ └ https://skills.sh/skills.volces.com/auto-updater
+
+ yizhiyanhua-ai/skills-updater@skills-updater 285 installs
+ └ https://skills.sh/yizhiyanhua-ai/skills-updater/skills-updater
+
+ vercel-labs/skills@find-skills 999 installs
+ └ https://skills.sh/vercel-labs/skills/find-skills
+
+ writer/skills@humanizer 400 installs
+ └ https://skills.sh/writer/skills/humanizer
+
+ eyh0602/skillshub@paper-polish 120 installs
+ └ https://skills.sh/eyh0602/skillshub/paper-polish
+ `,
+ })
+
+ const app = Server.Default()
+ const search = await app.request("/skill/search", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-opencode-directory": tmp.path,
+ },
+ body: JSON.stringify({
+ query: "找一下自动更新的skill",
+ semantic: true,
+ }),
+ })
+
+ expect(search.status).toBe(200)
+ const body = await search.json()
+ expect(body.main).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: "skills.volces.com@auto-updater", tier: "main" }),
+ expect.objectContaining({ id: "yizhiyanhua-ai/skills-updater@skills-updater", tier: "main" }),
+ ]),
+ )
+ expect(body.main).not.toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: "writer/skills@humanizer" }),
+ expect.objectContaining({ id: "eyh0602/skillshub@paper-polish" }),
+ ]),
+ )
+ })
+
+ test("describe returns a zh summary for an external skill page", async () => {
+ await using tmp = await tmpdir({
+ config: {},
+ })
+
+ globalThis.fetch = (async (input) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url
+ if (url === "https://skills.sh/vercel-labs/skills/find-skills") {
+ return new Response(
+ `
+ Summary
+
+
Discover and install specialized agent skills from the open ecosystem when users need extended capabilities.
+
- Integrates with the Skills CLI.
+
+ `,
+ { status: 200 },
+ )
+ }
+ return new Response("Not Found", { status: 404 })
+ }) as typeof fetch
+
+ const app = Server.Default()
+ const res = await app.request("/skill/describe", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-opencode-directory": tmp.path,
+ },
+ body: JSON.stringify({
+ id: "vercel-labs/skills@find-skills",
+ name: "find-skills",
+ provider: "external",
+ source: "vercel-labs/skills",
+ url: "https://skills.sh/vercel-labs/skills/find-skills",
+ }),
+ })
+
+ expect(res.status).toBe(200)
+ expect(await res.json()).toEqual({
+ summary_zh: expect.any(String),
+ summary_source: "skills_summary",
+ })
+ })
+
+ test("install queues background jobs with max two running", async () => {
+ await using tmp = await tmpdir({
+ config: {},
+ })
+
+ disposeSpy = spyOn(Instance, "dispose").mockResolvedValue(undefined)
+ const a = wait()
+ const b = wait()
+ const c = wait()
+ let calls = 0
+ runSpy = spyOn(Process, "run").mockImplementation(async () => {
+ calls += 1
+ if (calls === 1) return a.promise.then(() => ({ code: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }))
+ if (calls === 2) return b.promise.then(() => ({ code: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }))
+ return c.promise.then(() => ({ code: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) }))
+ })
+
+ const app = Server.Default()
+ const one = await (await app.request("/skill/install", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-opencode-directory": tmp.path,
+ },
+ body: JSON.stringify({
+ kind: "external",
+ package: "one/repo@first",
+ scope: "project",
+ }),
+ })).json()
+ const two = await (await app.request("/skill/install", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-opencode-directory": tmp.path,
+ },
+ body: JSON.stringify({
+ kind: "external",
+ package: "two/repo@second",
+ scope: "project",
+ }),
+ })).json()
+ const three = await (await app.request("/skill/install", {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ "x-opencode-directory": tmp.path,
+ },
+ body: JSON.stringify({
+ kind: "external",
+ package: "three/repo@third",
+ scope: "project",
+ }),
+ })).json()
+
+ expect(one.status).toBe("running")
+ expect(two.status).toBe("running")
+ expect(three.status).toBe("queued")
+
+ const first = await (await app.request("/skill/jobs", {
+ headers: {
+ "x-opencode-directory": tmp.path,
+ },
+ })).json()
+ expect(first).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: "one/repo@first", status: "running" }),
+ expect.objectContaining({ id: "two/repo@second", status: "running" }),
+ expect.objectContaining({ id: "three/repo@third", status: "queued" }),
+ ]),
+ )
+
+ a.resolve()
+ const next = await new Promise(async (resolve, reject) => {
+ const end = Date.now() + 500
+ while (Date.now() < end) {
+ const jobs = await (await app.request("/skill/jobs", {
+ headers: {
+ "x-opencode-directory": tmp.path,
+ },
+ })).json()
+ if (jobs.some((item: { id: string; status: string }) => item.id === "one/repo@first" && item.status === "success")) {
+ resolve(jobs)
+ return
+ }
+ await new Promise((done) => setTimeout(done, 10))
+ }
+ reject(new Error("first install did not finish in time"))
+ })
+ expect(next).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: "one/repo@first", status: "success" }),
+ expect.objectContaining({ id: "three/repo@third", status: "running" }),
+ ]),
+ )
+
+ b.resolve()
+ c.resolve()
+ })
+})
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index a3ca9bef46..2578ab7e59 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -235,6 +235,17 @@ import type {
SessionUnshareResponses,
SessionUpdateErrors,
SessionUpdateResponses,
+ SkillCheckResponses,
+ SkillDescribeErrors,
+ SkillDescribeResponses,
+ SkillInstalledResponses,
+ SkillInstallErrors,
+ SkillInstallResponses,
+ SkillJobsResponses,
+ SkillSearchErrors,
+ SkillSearchResponses,
+ SkillUpdateErrors,
+ SkillUpdateResponses,
SubtaskPartInput,
TextPartInput,
ToolIdsErrors,
@@ -2868,6 +2879,379 @@ export class Permission extends HeyApiClient {
}
}
+export class App extends HeyApiClient {
+ /**
+ * List skills
+ *
+ * Get a list of all available skills in the OpenCode system.
+ */
+ public skills(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/skill",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Write log
+ *
+ * Write a log entry to the server logs with specified level and metadata.
+ */
+ public log(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ service?: string
+ level?: "debug" | "info" | "error" | "warn"
+ message?: string
+ extra?: {
+ [key: string]: unknown
+ }
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "service" },
+ { in: "body", key: "level" },
+ { in: "body", key: "message" },
+ { in: "body", key: "extra" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/log",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * List agents
+ *
+ * Get a list of all available AI agents in the OpenCode system.
+ */
+ public agents(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/agent",
+ ...options,
+ ...params,
+ })
+ }
+}
+
+export class Skill extends HeyApiClient {
+ /**
+ * Search skills
+ *
+ * Search registry and external skill sources for the current project.
+ */
+ public search(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ query?: string
+ semantic?: boolean
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "query" },
+ { in: "body", key: "semantic" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/skill/search",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * List installed skills
+ *
+ * Get installed managed skills visible to the current project.
+ */
+ public installed(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/skill/installed",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Describe skill
+ *
+ * Generate a short Chinese summary for a search result card.
+ */
+ public describe(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ id?: string
+ name?: string
+ provider?: "registry" | "external"
+ description?: string
+ url?: string
+ source?: string
+ registry?: string
+ package?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "id" },
+ { in: "body", key: "name" },
+ { in: "body", key: "provider" },
+ { in: "body", key: "description" },
+ { in: "body", key: "url" },
+ { in: "body", key: "source" },
+ { in: "body", key: "registry" },
+ { in: "body", key: "package" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/skill/describe",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Check skill updates
+ *
+ * Get installed skills with update availability.
+ */
+ public check(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/skill/check",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * List skill install jobs
+ *
+ * Get current and recent background install jobs for the current project.
+ */
+ public jobs(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/skill/jobs",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Install skill
+ *
+ * Install a registry or external skill.
+ */
+ public install(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ body?:
+ | {
+ kind: "registry"
+ registry: string
+ name: string
+ }
+ | {
+ kind: "external"
+ package: string
+ scope: "project" | "global"
+ }
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { key: "body", map: "body" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/skill/install",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Update skills
+ *
+ * Update one or more installed managed skills.
+ */
+ public update(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ names?: Array
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "names" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/skill/update",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+}
+
export class Question extends HeyApiClient {
/**
* List pending questions
@@ -5976,113 +6360,6 @@ export class Command extends HeyApiClient {
}
}
-export class App extends HeyApiClient {
- /**
- * Write log
- *
- * Write a log entry to the server logs with specified level and metadata.
- */
- public log(
- parameters?: {
- directory?: string
- workspace?: string
- service?: string
- level?: "debug" | "info" | "error" | "warn"
- message?: string
- extra?: {
- [key: string]: unknown
- }
- },
- options?: Options,
- ) {
- const params = buildClientParams(
- [parameters],
- [
- {
- args: [
- { in: "query", key: "directory" },
- { in: "query", key: "workspace" },
- { in: "body", key: "service" },
- { in: "body", key: "level" },
- { in: "body", key: "message" },
- { in: "body", key: "extra" },
- ],
- },
- ],
- )
- return (options?.client ?? this.client).post({
- url: "/log",
- ...options,
- ...params,
- headers: {
- "Content-Type": "application/json",
- ...options?.headers,
- ...params.headers,
- },
- })
- }
-
- /**
- * List agents
- *
- * Get a list of all available AI agents in the OpenCode system.
- */
- public agents(
- parameters?: {
- directory?: string
- workspace?: string
- },
- options?: Options,
- ) {
- const params = buildClientParams(
- [parameters],
- [
- {
- args: [
- { in: "query", key: "directory" },
- { in: "query", key: "workspace" },
- ],
- },
- ],
- )
- return (options?.client ?? this.client).get({
- url: "/agent",
- ...options,
- ...params,
- })
- }
-
- /**
- * List skills
- *
- * Get a list of all available skills in the OpenCode system.
- */
- public skills(
- parameters?: {
- directory?: string
- workspace?: string
- },
- options?: Options,
- ) {
- const params = buildClientParams(
- [parameters],
- [
- {
- args: [
- { in: "query", key: "directory" },
- { in: "query", key: "workspace" },
- ],
- },
- ],
- )
- return (options?.client ?? this.client).get({
- url: "/skill",
- ...options,
- ...params,
- })
- }
-}
-
export class Lsp extends HeyApiClient {
/**
* Get LSP status
@@ -6210,6 +6487,16 @@ export class OpencodeClient extends HeyApiClient {
return (this._permission ??= new Permission({ client: this.client }))
}
+ private _app?: App
+ get app(): App {
+ return (this._app ??= new App({ client: this.client }))
+ }
+
+ private _skill?: Skill
+ get skill(): Skill {
+ return (this._skill ??= new Skill({ client: this.client }))
+ }
+
private _question?: Question
get question(): Question {
return (this._question ??= new Question({ client: this.client }))
@@ -6280,11 +6567,6 @@ export class OpencodeClient extends HeyApiClient {
return (this._command ??= new Command({ client: this.client }))
}
- private _app?: App
- get app(): App {
- return (this._app ??= new App({ client: this.client }))
- }
-
private _lsp?: Lsp
get lsp(): Lsp {
return (this._lsp ??= new Lsp({ client: this.client }))
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 216c362146..cc79268b5d 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -1571,6 +1571,10 @@ export type Config = {
* Model to use in the format of provider/model, eg anthropic/claude-2
*/
model?: string
+ /**
+ * Model to use for skill search query expansion, reranking, and summaries
+ */
+ search_model?: string
/**
* Small model to use for tasks like title generation in the format of provider/model
*/
@@ -4310,6 +4314,336 @@ export type PermissionRespondResponses = {
export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses]
+export type AppSkillsData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/skill"
+}
+
+export type AppSkillsResponses = {
+ /**
+ * List of skills
+ */
+ 200: Array<{
+ name: string
+ description: string
+ location: string
+ content: string
+ }>
+}
+
+export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses]
+
+export type SkillSearchData = {
+ body?: {
+ query: string
+ semantic?: boolean
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/skill/search"
+}
+
+export type SkillSearchErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type SkillSearchError = SkillSearchErrors[keyof SkillSearchErrors]
+
+export type SkillSearchResponses = {
+ /**
+ * Search results
+ */
+ 200: {
+ main: Array<{
+ id: string
+ provider: "registry" | "external"
+ rank: "exact" | "semantic"
+ name: string
+ description?: string
+ installs?: string
+ url?: string
+ registry?: string
+ version?: string
+ package?: string
+ source?: string
+ installed?: boolean
+ scope?: "project" | "global"
+ update_available?: boolean
+ summary_zh?: string
+ summary_source?: "skills_summary" | "skill_md"
+ relevance?: "high" | "medium" | "low"
+ tier?: "main" | "more"
+ }>
+ more: Array<{
+ id: string
+ provider: "registry" | "external"
+ rank: "exact" | "semantic"
+ name: string
+ description?: string
+ installs?: string
+ url?: string
+ registry?: string
+ version?: string
+ package?: string
+ source?: string
+ installed?: boolean
+ scope?: "project" | "global"
+ update_available?: boolean
+ summary_zh?: string
+ summary_source?: "skills_summary" | "skill_md"
+ relevance?: "high" | "medium" | "low"
+ tier?: "main" | "more"
+ }>
+ meta: {
+ model?: string
+ latency_ms?: number
+ }
+ }
+}
+
+export type SkillSearchResponse = SkillSearchResponses[keyof SkillSearchResponses]
+
+export type SkillInstalledData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/skill/installed"
+}
+
+export type SkillInstalledResponses = {
+ /**
+ * Installed skills
+ */
+ 200: Array<{
+ id: string
+ provider: "registry" | "external"
+ name: string
+ description?: string
+ installs?: string
+ url?: string
+ registry?: string
+ version?: string
+ package?: string
+ source?: string
+ installed?: boolean
+ scope?: "project" | "global"
+ update_available?: boolean
+ summary_zh?: string
+ summary_source?: "skills_summary" | "skill_md"
+ relevance?: "high" | "medium" | "low"
+ tier?: "main" | "more"
+ }>
+}
+
+export type SkillInstalledResponse = SkillInstalledResponses[keyof SkillInstalledResponses]
+
+export type SkillDescribeData = {
+ body?: {
+ id: string
+ name: string
+ provider: "registry" | "external"
+ description?: string
+ url?: string
+ source?: string
+ registry?: string
+ package?: string
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/skill/describe"
+}
+
+export type SkillDescribeErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type SkillDescribeError = SkillDescribeErrors[keyof SkillDescribeErrors]
+
+export type SkillDescribeResponses = {
+ /**
+ * Skill summary
+ */
+ 200: {
+ summary_zh: string
+ summary_source?: "skills_summary" | "skill_md" | "fallback"
+ }
+}
+
+export type SkillDescribeResponse = SkillDescribeResponses[keyof SkillDescribeResponses]
+
+export type SkillCheckData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/skill/check"
+}
+
+export type SkillCheckResponses = {
+ /**
+ * Installed skills with update status
+ */
+ 200: Array<{
+ id: string
+ provider: "registry" | "external"
+ name: string
+ description?: string
+ installs?: string
+ url?: string
+ registry?: string
+ version?: string
+ package?: string
+ source?: string
+ installed?: boolean
+ scope?: "project" | "global"
+ update_available?: boolean
+ summary_zh?: string
+ summary_source?: "skills_summary" | "skill_md"
+ relevance?: "high" | "medium" | "low"
+ tier?: "main" | "more"
+ }>
+}
+
+export type SkillCheckResponse = SkillCheckResponses[keyof SkillCheckResponses]
+
+export type SkillJobsData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/skill/jobs"
+}
+
+export type SkillJobsResponses = {
+ /**
+ * Install jobs
+ */
+ 200: Array<{
+ job_id: string
+ id: string
+ provider: "registry" | "external"
+ name: string
+ registry?: string
+ package?: string
+ source?: string
+ scope?: "project" | "global"
+ status: "queued" | "running" | "success" | "error"
+ message?: string
+ started_at?: number
+ finished_at?: number
+ }>
+}
+
+export type SkillJobsResponse = SkillJobsResponses[keyof SkillJobsResponses]
+
+export type SkillInstallData = {
+ body?:
+ | {
+ kind: "registry"
+ registry: string
+ name: string
+ }
+ | {
+ kind: "external"
+ package: string
+ scope: "project" | "global"
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/skill/install"
+}
+
+export type SkillInstallErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type SkillInstallError = SkillInstallErrors[keyof SkillInstallErrors]
+
+export type SkillInstallResponses = {
+ /**
+ * Install result
+ */
+ 200: {
+ job_id: string
+ id: string
+ provider: "registry" | "external"
+ name: string
+ registry?: string
+ package?: string
+ source?: string
+ scope?: "project" | "global"
+ status: "queued" | "running" | "success" | "error"
+ message?: string
+ started_at?: number
+ finished_at?: number
+ }
+}
+
+export type SkillInstallResponse = SkillInstallResponses[keyof SkillInstallResponses]
+
+export type SkillUpdateData = {
+ body?: {
+ names?: Array
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/skill/update"
+}
+
+export type SkillUpdateErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type SkillUpdateError = SkillUpdateErrors[keyof SkillUpdateErrors]
+
+export type SkillUpdateResponses = {
+ /**
+ * Update result
+ */
+ 200: {
+ ok: boolean
+ updated: Array
+ }
+}
+
+export type SkillUpdateResponse = SkillUpdateResponses[keyof SkillUpdateResponses]
+
export type PermissionReplyData = {
body?: {
reply: "once" | "always" | "reject"
@@ -7173,30 +7507,6 @@ export type AppAgentsResponses = {
export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses]
-export type AppSkillsData = {
- body?: never
- path?: never
- query?: {
- directory?: string
- workspace?: string
- }
- url: "/skill"
-}
-
-export type AppSkillsResponses = {
- /**
- * List of skills
- */
- 200: Array<{
- name: string
- description: string
- location: string
- content: string
- }>
-}
-
-export type AppSkillsResponse = AppSkillsResponses[keyof AppSkillsResponses]
-
export type LspStatusData = {
body?: never
path?: never
From 133e42479efc319ba3f95dff41417a086d4cd961 Mon Sep 17 00:00:00 2001
From: zxc123aa <252945849@qq.com>
Date: Sat, 11 Apr 2026 23:20:39 +0800
Subject: [PATCH 2/2] fix app sdk compatibility and expand skill benchmarks
---
packages/app/src/utils/server.test.ts | 63 ++
packages/app/src/utils/server.ts | 59 +-
packages/opencode/src/skill/benchmark.test.ts | 14 +-
packages/opencode/src/skill/benchmark.ts | 239 +++++
packages/opencode/src/skill/catalog.ts | 2 +-
packages/opencode/src/skill/search.test.ts | 218 ++---
packages/sdk/js/src/v2/gen/sdk.gen.ts | 882 ++++++++++++++++--
packages/sdk/js/src/v2/gen/types.gen.ts | 687 ++++++++++++++
8 files changed, 1938 insertions(+), 226 deletions(-)
create mode 100644 packages/app/src/utils/server.test.ts
diff --git a/packages/app/src/utils/server.test.ts b/packages/app/src/utils/server.test.ts
new file mode 100644
index 0000000000..220085e09a
--- /dev/null
+++ b/packages/app/src/utils/server.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test"
+import { addPreferenceMethods, type AppClient } from "./server"
+
+describe("addPreferenceMethods", () => {
+ test("preserves native session preference clients", () => {
+ const pref = {
+ get: async () => ({ data: null }),
+ set: async () => ({ data: null }),
+ } as unknown as AppClient["session"]["preference"]
+ const client = {
+ session: {},
+ } as unknown as AppClient
+
+ Object.defineProperty(client.session, "preference", {
+ configurable: true,
+ get: () => pref,
+ })
+
+ expect(() => addPreferenceMethods(client, "http://localhost:4096")).not.toThrow()
+ expect(client.session.preference).toBe(pref)
+ })
+
+ test("adds fallback session preference methods when missing", async () => {
+ const calls: Array<{ url: string; method: string; body?: string | null }> = []
+ const orig = globalThis.fetch
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ calls.push({
+ url: String(input),
+ method: init?.method ?? "GET",
+ body: typeof init?.body === "string" ? init.body : null,
+ })
+ return new Response(JSON.stringify({ sessionID: "s1" }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ })
+ }) as typeof globalThis.fetch
+
+ const client = {
+ session: {},
+ } as unknown as AppClient
+
+ try {
+ addPreferenceMethods(client, "http://localhost:4096")
+ await client.session.preference.get({ sessionID: "s1" })
+ await client.session.preference.set({ sessionID: "s1", approval: "auto" })
+ } finally {
+ globalThis.fetch = orig
+ }
+
+ expect(calls).toEqual([
+ {
+ url: "http://localhost:4096/session/s1/preference",
+ method: "GET",
+ body: null,
+ },
+ {
+ url: "http://localhost:4096/session/s1/preference",
+ method: "PUT",
+ body: JSON.stringify({ approval: "auto" }),
+ },
+ ])
+ })
+})
diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts
index dbee269f28..4ef797f2a0 100644
--- a/packages/app/src/utils/server.ts
+++ b/packages/app/src/utils/server.ts
@@ -3,6 +3,31 @@ import type { ServerConnection } from "@/context/server"
type Base = ReturnType
type Req = Promise<{ data?: T }>
+type PrefGet = { sessionID: string }
+type PrefSet = {
+ sessionID: string
+ agent?: string
+ model?: { providerID: string; modelID: string }
+ variant?: string
+ approval?: string
+ source?: string
+}
+type Pref = {
+ get(input: PrefGet): Req<{
+ sessionID: string
+ agent?: string
+ model?: { providerID: string; modelID: string }
+ variant?: string
+ approval?: string
+ } | null>
+ set(input: PrefSet): Req<{
+ sessionID: string
+ agent?: string
+ model?: { providerID: string; modelID: string }
+ variant?: string
+ approval?: string
+ } | null>
+}
type Skill = {
name: string
description: string
@@ -38,7 +63,7 @@ export type AppClient = Base & {
alreadyExists: boolean
}>
}
- session: Base["session"] & {
+ session: Omit & {
promptAsync(input: {
sessionID: string
directory?: string
@@ -59,29 +84,7 @@ export type AppClient = Base & {
knowledgeBase?: Kb
parts: unknown[]
}): Req
- preference: {
- get(input: { sessionID: string }): Req<{
- sessionID: string
- agent?: string
- model?: { providerID: string; modelID: string }
- variant?: string
- approval?: string
- } | null>
- set(input: {
- sessionID: string
- agent?: string
- model?: { providerID: string; modelID: string }
- variant?: string
- approval?: string
- source?: string
- }): Req<{
- sessionID: string
- agent?: string
- model?: { providerID: string; modelID: string }
- variant?: string
- approval?: string
- } | null>
- }
+ preference: Pref
}
}
@@ -105,14 +108,16 @@ export function createSdkForServer({
}
export function addPreferenceMethods(client: AppClient, baseUrl: string, auth?: Record): AppClient {
+ const session = client.session as { preference?: Pref }
+ if (Reflect.has(session, "preference")) return client
const headers: Record = { "Content-Type": "application/json", ...auth }
- client.session.preference = {
- async get(input) {
+ session.preference = {
+ async get(input: PrefGet) {
const resp = await fetch(`${baseUrl}/session/${input.sessionID}/preference`, { headers })
const data = await resp.json()
return { data }
},
- async set(input) {
+ async set(input: PrefSet) {
const { sessionID, ...body } = input
const resp = await fetch(`${baseUrl}/session/${sessionID}/preference`, {
method: "PUT",
diff --git a/packages/opencode/src/skill/benchmark.test.ts b/packages/opencode/src/skill/benchmark.test.ts
index 3cac7c9b56..ba7762c0ae 100644
--- a/packages/opencode/src/skill/benchmark.test.ts
+++ b/packages/opencode/src/skill/benchmark.test.ts
@@ -69,8 +69,8 @@ describe("table", () => {
})
describe("cases", () => {
- test("covers mixed gui-style chinese and english queries", () => {
- expect(cases.length).toBeGreaterThanOrEqual(12)
+ test("covers mixed chinese and english discovery intents across writing, automation, export, and slides", () => {
+ expect(cases.length).toBeGreaterThanOrEqual(22)
expect(cases.map((item) => item.id)).toEqual(
expect.arrayContaining([
"paper-polish-zh",
@@ -85,6 +85,16 @@ describe("cases", () => {
"translate-en",
"exact-updater",
"exact-humanizer-cn",
+ "browser-zh",
+ "browser-en",
+ "exact-playwright",
+ "pdf-zh",
+ "pdf-en",
+ "slides-zh",
+ "slides-en",
+ "pptx-en",
+ "paper-web-en",
+ "latex-en",
]),
)
})
diff --git a/packages/opencode/src/skill/benchmark.ts b/packages/opencode/src/skill/benchmark.ts
index c4d22d6e38..b3adcd616c 100644
--- a/packages/opencode/src/skill/benchmark.ts
+++ b/packages/opencode/src/skill/benchmark.ts
@@ -219,6 +219,87 @@ const pool = {
summary_source: "skill_md" as const,
terms: ["浏览器", "自动化", "检查"],
},
+ "md-to-pdf": {
+ id: "local/skills@md-to-pdf",
+ name: "md-to-pdf",
+ source: "local/skills",
+ rank: "semantic" as const,
+ body: "Convert markdown files to PDF using Chrome for shareable document output.",
+ summary_source: "skill_md" as const,
+ terms: ["markdown", "pdf", "导出", "打印"],
+ },
+ pandoc: {
+ id: "local/skills@pandoc",
+ name: "pandoc",
+ source: "local/skills",
+ rank: "semantic" as const,
+ body: "Convert documents between Markdown, DOCX, PDF, HTML, and LaTeX with pandoc.",
+ summary_source: "skill_md" as const,
+ terms: ["转换", "markdown", "pdf", "docx"],
+ },
+ "minimax-pdf": {
+ id: "local/skills@minimax-pdf",
+ name: "minimax-pdf",
+ source: "local/skills",
+ rank: "semantic" as const,
+ body: "Create polished PDFs, restyle documents, or fill PDF forms with a design system.",
+ summary_source: "skill_md" as const,
+ terms: ["pdf", "设计", "报告", "文档"],
+ },
+ revealjs: {
+ id: "local/skills@revealjs",
+ name: "revealjs",
+ source: "local/skills",
+ rank: "semantic" as const,
+ body: "Create polished reveal.js presentations with themes, layouts, notes, and custom styling.",
+ summary_source: "skill_md" as const,
+ terms: ["slides", "演示", "html", "reveal"],
+ },
+ "frontend-slides": {
+ id: "local/skills@frontend-slides",
+ name: "frontend-slides",
+ source: "local/skills",
+ rank: "semantic" as const,
+ body: "Build animation-rich HTML presentations and convert decks into web slides.",
+ summary_source: "skill_md" as const,
+ terms: ["slides", "动画", "网页", "演示"],
+ },
+ "pptx-generator": {
+ id: "local/skills@pptx-generator",
+ name: "pptx-generator",
+ source: "local/skills",
+ rank: "semantic" as const,
+ body: "Generate, edit, and read PowerPoint presentations and slide decks.",
+ summary_source: "skill_md" as const,
+ terms: ["ppt", "powerpoint", "幻灯片", "演示"],
+ },
+ "scientific-slides": {
+ id: "local/skills@scientific-slides",
+ name: "scientific-slides",
+ source: "local/skills",
+ rank: "semantic" as const,
+ body: "Build slide decks for research talks, conference presentations, and thesis defenses.",
+ summary_source: "skill_md" as const,
+ terms: ["科研", "slides", "演讲", "答辩"],
+ },
+ "paper-2-web": {
+ id: "local/skills@paper-2-web",
+ name: "paper-2-web",
+ source: "local/skills",
+ rank: "semantic" as const,
+ body: "Turn academic papers into interactive websites, videos, and conference posters.",
+ summary_source: "skill_md" as const,
+ terms: ["论文", "网站", "展示", "poster"],
+ },
+ "latex-paper-en": {
+ id: "local/skills@latex-paper-en",
+ name: "latex-paper-en",
+ source: "local/skills",
+ rank: "semantic" as const,
+ body: "Compile, lint, proofread, and improve English LaTeX papers for submission.",
+ summary_source: "skill_md" as const,
+ terms: ["latex", "英文", "论文", "投稿"],
+ },
} as const
function pickItems(ids: Array) {
@@ -429,6 +510,164 @@ export const cases = [
"video-editing",
]),
},
+ {
+ id: "browser-zh",
+ query: "找个能自动操作浏览器点页面的 skill",
+ must_have: ["playwright-cli"],
+ good_to_have: [],
+ must_not_have: ["paper-polish", "md-to-pdf", "revealjs"],
+ fixture: pickItems([
+ "playwright-cli",
+ "find-skills",
+ "paper-polish",
+ "md-to-pdf",
+ "revealjs",
+ "code-polish",
+ ]),
+ },
+ {
+ id: "browser-en",
+ query: "browser automation cli",
+ must_have: ["playwright-cli"],
+ good_to_have: [],
+ must_not_have: ["paper-polish", "pptx-generator", "find-skills"],
+ fixture: pickItems([
+ "playwright-cli",
+ "find-skills",
+ "paper-polish",
+ "pptx-generator",
+ "md-to-pdf",
+ "code-polish",
+ ]),
+ },
+ {
+ id: "exact-playwright",
+ query: "playwright-cli",
+ must_have: ["playwright-cli"],
+ good_to_have: [],
+ must_not_have: ["paper-polish", "md-to-pdf", "find-skills"],
+ fixture: pickItems([
+ "playwright-cli",
+ "find-skills",
+ "paper-polish",
+ "md-to-pdf",
+ "pptx-generator",
+ ]),
+ },
+ {
+ id: "pdf-zh",
+ query: "把 markdown 导出成 pdf 的 skill",
+ must_have: ["md-to-pdf"],
+ good_to_have: ["pandoc", "minimax-pdf"],
+ must_not_have: ["revealjs", "pptx-generator", "find-skills"],
+ fixture: pickItems([
+ "md-to-pdf",
+ "pandoc",
+ "minimax-pdf",
+ "revealjs",
+ "pptx-generator",
+ "find-skills",
+ "playwright-cli",
+ ]),
+ },
+ {
+ id: "pdf-en",
+ query: "convert markdown to pdf",
+ must_have: ["md-to-pdf"],
+ good_to_have: ["pandoc", "minimax-pdf"],
+ must_not_have: ["revealjs", "pptx-generator", "find-skills"],
+ fixture: pickItems([
+ "md-to-pdf",
+ "pandoc",
+ "minimax-pdf",
+ "revealjs",
+ "pptx-generator",
+ "find-skills",
+ "playwright-cli",
+ ]),
+ },
+ {
+ id: "slides-zh",
+ query: "找个做 html 幻灯片的 skill",
+ must_have: ["frontend-slides", "revealjs"],
+ good_to_have: ["scientific-slides", "pptx-generator"],
+ must_not_have: ["md-to-pdf", "paper-polish", "find-skills"],
+ fixture: pickItems([
+ "frontend-slides",
+ "revealjs",
+ "scientific-slides",
+ "pptx-generator",
+ "paper-2-web",
+ "md-to-pdf",
+ "paper-polish",
+ "find-skills",
+ ]),
+ },
+ {
+ id: "slides-en",
+ query: "build interactive slides",
+ must_have: ["frontend-slides", "revealjs"],
+ good_to_have: ["scientific-slides", "pptx-generator"],
+ must_not_have: ["md-to-pdf", "paper-polish", "find-skills"],
+ fixture: pickItems([
+ "frontend-slides",
+ "revealjs",
+ "scientific-slides",
+ "pptx-generator",
+ "paper-2-web",
+ "md-to-pdf",
+ "paper-polish",
+ "find-skills",
+ ]),
+ },
+ {
+ id: "pptx-en",
+ query: "generate powerpoint deck",
+ must_have: ["pptx-generator"],
+ good_to_have: ["scientific-slides", "frontend-slides"],
+ must_not_have: ["md-to-pdf", "paper-polish", "find-skills"],
+ fixture: pickItems([
+ "pptx-generator",
+ "scientific-slides",
+ "frontend-slides",
+ "revealjs",
+ "md-to-pdf",
+ "paper-polish",
+ "find-skills",
+ ]),
+ },
+ {
+ id: "paper-web-en",
+ query: "turn a paper into a website",
+ must_have: ["paper-2-web"],
+ good_to_have: ["frontend-slides", "revealjs"],
+ must_not_have: ["md-to-pdf", "humanizer", "find-skills"],
+ fixture: pickItems([
+ "paper-2-web",
+ "frontend-slides",
+ "revealjs",
+ "scientific-slides",
+ "md-to-pdf",
+ "humanizer",
+ "find-skills",
+ ]),
+ },
+ {
+ id: "latex-en",
+ query: "improve an english latex paper",
+ must_have: ["latex-paper-en"],
+ good_to_have: ["paper-polish", "professional-proofreader"],
+ must_not_have: ["md-to-pdf", "revealjs", "find-skills"],
+ fixture: pickItems([
+ "latex-paper-en",
+ "paper-polish",
+ "professional-proofreader",
+ "ai-proofreading",
+ "md-to-pdf",
+ "revealjs",
+ "find-skills",
+ ]),
+ },
] as const
type Case = (typeof cases)[number]
diff --git a/packages/opencode/src/skill/catalog.ts b/packages/opencode/src/skill/catalog.ts
index cc86256a18..48a688ba3a 100644
--- a/packages/opencode/src/skill/catalog.ts
+++ b/packages/opencode/src/skill/catalog.ts
@@ -539,7 +539,7 @@ function level(
return review === "low" ? "low" : "medium"
}
-function split(query: string, item: SearchResult, body?: string, relevance?: z.infer) {
+export function split(query: string, item: SearchResult, body?: string, relevance?: z.infer) {
if (blocked(query, item, body)) return
const rank = relevance ?? item.relevance ?? relate(query, item, body)
if (item.provider === "external" && !body) return item.rank === "exact" ? ("more" as const) : undefined
diff --git a/packages/opencode/src/skill/search.test.ts b/packages/opencode/src/skill/search.test.ts
index 4da39106cb..10cea861b7 100644
--- a/packages/opencode/src/skill/search.test.ts
+++ b/packages/opencode/src/skill/search.test.ts
@@ -1,139 +1,99 @@
-import { afterAll, describe, expect, mock, test } from "bun:test"
-import { ModelID, ProviderID } from "@/provider/schema"
+import { describe, expect, test } from "bun:test"
+import { split, type SearchResult } from "./catalog"
-mock.module("ai", () => ({
- generateObject: async (input: { messages?: Array<{ role: string; content: string }> }) => {
- const text = input.messages?.find((item) => item.role === "user")?.content ?? ""
- if (text.includes("make this writing sound more human")) {
- return {
- object: {
- items: [
- {
- id: "writer/skills@humanizer",
- relevance: "high",
- summary_zh: "把文本改得更自然。",
- },
- {
- id: "eyh0602/skillshub@paper-polish",
- relevance: "high",
- summary_zh: "润色论文。",
- },
- ],
- },
- }
- }
- return {
- object: {
- items: [],
- },
- }
- },
- generateText: async () => ({ text: "" }),
-}))
-
-mock.module("@/provider/provider", () => ({
- Provider: {
- getModel: async () => ({}),
- getLanguage: async () => ({}),
- defaultModel: async () => undefined,
- list: async () => ({}),
- },
-}))
-
-const { Catalog } = await import("./catalog")
-
-const model = {
- providerID: ProviderID.make("mock"),
- modelID: ModelID.make("mock"),
+function item(input: Partial & Pick): SearchResult {
+ return {
+ installed: false,
+ ...input,
+ }
}
-afterAll(() => {
- mock.restore()
-})
+describe("split", () => {
+ test("keeps paper translation skills in main for translation queries", () => {
+ expect(
+ split(
+ "找一下翻译论文的skill",
+ item({
+ id: "translator/skills@paper-translation",
+ name: "paper-translation",
+ provider: "external",
+ rank: "semantic",
+ source: "translator/skills",
+ }),
+ "Translate academic manuscripts and research papers while preserving scholarly terminology.",
+ ),
+ ).toBe("main")
-describe("Catalog.bench", () => {
- test("keeps translation intent for translating papers", async () => {
- const out = await Catalog.bench(
- {
- query: "找一下翻译论文的skill",
- items: [
- {
- id: "translator/skills@paper-translation",
- name: "paper-translation",
- source: "translator/skills",
- rank: "semantic",
- body: "Translate academic manuscripts and research papers while preserving scholarly terminology.",
- summary_source: "skill_md",
- },
- {
- id: "translator/skills@docs-translation",
- name: "docs-translation",
- source: "translator/skills",
- rank: "semantic",
- body: "Translate technical documentation and product docs between Chinese and English.",
- summary_source: "skill_md",
- },
- {
- id: "review/skills@manuscript-review",
- name: "manuscript-review",
- source: "review/skills",
- rank: "semantic",
- body: "Review manuscript structure and submission readiness for academic papers.",
- summary_source: "skill_md",
- },
- {
- id: "media/skills@subtitle-translation",
- name: "subtitle-translation",
- source: "media/skills",
- rank: "semantic",
- body: "Translate subtitles and captions for short-form video projects.",
- summary_source: "skill_md",
- },
- ],
- },
- model,
- )
+ expect(
+ split(
+ "找一下翻译论文的skill",
+ item({
+ id: "translator/skills@docs-translation",
+ name: "docs-translation",
+ provider: "external",
+ rank: "semantic",
+ source: "translator/skills",
+ }),
+ "Translate technical documentation and product docs between Chinese and English.",
+ ),
+ ).toBe("main")
- expect(out.main.map((item) => item.name)).toEqual(expect.arrayContaining(["paper-translation", "docs-translation"]))
- expect(out.main.map((item) => item.name)).not.toContain("subtitle-translation")
+ expect(
+ split(
+ "找一下翻译论文的skill",
+ item({
+ id: "media/skills@subtitle-translation",
+ name: "subtitle-translation",
+ provider: "external",
+ rank: "semantic",
+ source: "media/skills",
+ }),
+ "Translate subtitles and captions for short-form video projects.",
+ ),
+ ).toBeUndefined()
})
- test("does not let the model promote paper polish into humanizer main results", async () => {
- const out = await Catalog.bench(
- {
- query: "make this writing sound more human",
- items: [
- {
- id: "writer/skills@humanizer",
- name: "humanizer",
- source: "writer/skills",
- rank: "exact",
- body: "Rewrite text so it sounds more natural and less AI-generated.",
- summary_source: "skill_md",
- },
- {
- id: "writer/skills@writing-humanizer",
- name: "writing-humanizer",
- source: "writer/skills",
- rank: "semantic",
- body: "Humanize drafted writing by improving flow and natural phrasing.",
- summary_source: "skill_md",
- },
- {
- id: "eyh0602/skillshub@paper-polish",
- name: "paper-polish",
- source: "eyh0602/skillshub",
- rank: "semantic",
- body: "Polish and revise academic papers in LaTeX format.",
- summary_source: "skill_md",
- },
- ],
- },
- model,
- )
+ test("keeps paper polish out of humanizer main results", () => {
+ expect(
+ split(
+ "make this writing sound more human",
+ item({
+ id: "writer/skills@humanizer",
+ name: "humanizer",
+ provider: "external",
+ rank: "exact",
+ source: "writer/skills",
+ }),
+ "Rewrite text so it sounds more natural and less AI-generated.",
+ ),
+ ).toBe("main")
+
+ expect(
+ split(
+ "make this writing sound more human",
+ item({
+ id: "writer/skills@writing-humanizer",
+ name: "writing-humanizer",
+ provider: "external",
+ rank: "semantic",
+ source: "writer/skills",
+ }),
+ "Humanize drafted writing by improving flow and natural phrasing.",
+ ),
+ ).toBe("main")
- expect(out.main.map((item) => item.name)).toEqual(expect.arrayContaining(["humanizer", "writing-humanizer"]))
- expect(out.main.map((item) => item.name)).not.toContain("paper-polish")
- expect(out.more.map((item) => item.name)).toContain("paper-polish")
+ expect(
+ split(
+ "make this writing sound more human",
+ item({
+ id: "eyh0602/skillshub@paper-polish",
+ name: "paper-polish",
+ provider: "external",
+ rank: "semantic",
+ source: "eyh0602/skillshub",
+ }),
+ "Polish and revise academic papers in LaTeX format.",
+ ),
+ ).toBe("more")
})
})
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 2578ab7e59..60d85e7b98 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -24,6 +24,10 @@ import type {
ConfigSkillsToggleResponses,
ConfigUpdateErrors,
ConfigUpdateResponses,
+ DatabaseLegacyMergeResponses,
+ DatabaseLegacyMergeStateResetResponses,
+ DatabaseLegacyMergeStateResponses,
+ DatabaseLegacyStatusResponses,
EventSubscribeResponses,
EventTuiCommandExecute,
EventTuiPromptAppend,
@@ -36,6 +40,11 @@ import type {
ExperimentalWorkspaceListResponses,
ExperimentalWorkspaceRemoveErrors,
ExperimentalWorkspaceRemoveResponses,
+ FeishuEventsResponses,
+ FeishuSessionClearResponses,
+ FeishuStartResponses,
+ FeishuStatusResponses,
+ FeishuStopResponses,
FileActiveTasksResponses,
FileAddToGitignoreErrors,
FileAddToGitignoreResponses,
@@ -47,6 +56,8 @@ import type {
FileDeleteResponses,
FileDownloadErrors,
FileDownloadResponses,
+ FileEnsureDirectoryErrors,
+ FileEnsureDirectoryResponses,
FileListResponses,
FileOpenErrors,
FileOpenInExplorerErrors,
@@ -97,6 +108,13 @@ import type {
GlobalSyncEventSubscribeResponses,
GlobalUpgradeErrors,
GlobalUpgradeResponses,
+ GlobalWebUpdateCheckErrors,
+ GlobalWebUpdateCheckResponses,
+ GlobalWebUpdateCurrentResponses,
+ GlobalWebUpdateDownloadErrors,
+ GlobalWebUpdateDownloadResponses,
+ GlobalWebUpdateInstallErrors,
+ GlobalWebUpdateInstallResponses,
InstanceDisposeResponses,
KnowledgeConfigGetResponses,
KnowledgeConfigSetResponses,
@@ -184,10 +202,16 @@ import type {
ReadingModeAnnotationsGetResponses,
ReadingModeAnnotationsUpdateErrors,
ReadingModeAnnotationsUpdateResponses,
+ ReadingModePagePdfErrors,
+ ReadingModePagePdfResponses,
+ ReadingModePageTextErrors,
+ ReadingModePageTextResponses,
ReadingModePdfGetErrors,
ReadingModePdfGetResponses,
ReadingModeSessionCreateErrors,
ReadingModeSessionCreateResponses,
+ ReadingModeSessionFromFileErrors,
+ ReadingModeSessionFromFileResponses,
ReadingModeSessionUpdateErrors,
ReadingModeSessionUpdateResponses,
SessionAbortErrors,
@@ -213,6 +237,10 @@ import type {
SessionMessageResponses,
SessionMessagesErrors,
SessionMessagesResponses,
+ SessionPreferenceGetErrors,
+ SessionPreferenceGetResponses,
+ SessionPreferenceSetErrors,
+ SessionPreferenceSetResponses,
SessionPromptAsyncErrors,
SessionPromptAsyncResponses,
SessionPromptErrors,
@@ -272,6 +300,7 @@ import type {
VcsDiffResponses,
VcsGetResponses,
WechatEventsResponses,
+ WechatPingResponses,
WechatSessionClearResponses,
WechatStartResponses,
WechatStatusResponses,
@@ -388,6 +417,111 @@ export class Proxy extends HeyApiClient {
}
}
+export class WebUpdate extends HeyApiClient {
+ /**
+ * Get current web version
+ *
+ * Read the current web app version from local update state.
+ */
+ public current(options?: Options) {
+ return (options?.client ?? this.client).get({
+ url: "/global/web-update/current",
+ ...options,
+ })
+ }
+
+ /**
+ * Check web update
+ *
+ * Check for available web application updates by fetching remote version metadata.
+ */
+ public check(options?: Options) {
+ return (options?.client ?? this.client).get<
+ GlobalWebUpdateCheckResponses,
+ GlobalWebUpdateCheckErrors,
+ ThrowOnError
+ >({ url: "/global/web-update/check", ...options })
+ }
+
+ /**
+ * Download web update script
+ *
+ * Download the update/install script for the specified OS and version.
+ */
+ public download(
+ parameters?: {
+ os?: "darwin" | "linux" | "windows"
+ version?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "body", key: "os" },
+ { in: "body", key: "version" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post<
+ GlobalWebUpdateDownloadResponses,
+ GlobalWebUpdateDownloadErrors,
+ ThrowOnError
+ >({
+ url: "/global/web-update/download",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Execute web update script
+ *
+ * Execute the previously downloaded update script to install the new version.
+ */
+ public install(
+ parameters?: {
+ os?: "darwin" | "linux" | "windows"
+ version?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "body", key: "os" },
+ { in: "body", key: "version" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post<
+ GlobalWebUpdateInstallResponses,
+ GlobalWebUpdateInstallErrors,
+ ThrowOnError
+ >({
+ url: "/global/web-update/install",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+}
+
export class SyncEvent extends HeyApiClient {
/**
* Subscribe to global sync events
@@ -541,6 +675,11 @@ export class Global extends HeyApiClient {
return (this._proxy ??= new Proxy({ client: this.client }))
}
+ private _webUpdate?: WebUpdate
+ get webUpdate(): WebUpdate {
+ return (this._webUpdate ??= new WebUpdate({ client: this.client }))
+ }
+
private _syncEvent?: SyncEvent
get syncEvent(): SyncEvent {
return (this._syncEvent ??= new SyncEvent({ client: this.client }))
@@ -1728,6 +1867,98 @@ export class Worktree extends HeyApiClient {
}
}
+export class Preference extends HeyApiClient {
+ /**
+ * Get session preference
+ *
+ * Retrieve the current preference for a session from in-memory store.
+ */
+ public get(
+ parameters: {
+ sessionID: string
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "sessionID" },
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get<
+ SessionPreferenceGetResponses,
+ SessionPreferenceGetErrors,
+ ThrowOnError
+ >({
+ url: "/session/{sessionID}/preference",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Set session preference
+ *
+ * Set preference for a session. Stored in memory and broadcast via SSE.
+ */
+ public set(
+ parameters: {
+ sessionID: string
+ directory?: string
+ workspace?: string
+ agent?: string
+ model?: {
+ providerID: string
+ modelID: string
+ }
+ variant?: string
+ approval?: "auto" | "ask"
+ source?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "sessionID" },
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "agent" },
+ { in: "body", key: "model" },
+ { in: "body", key: "variant" },
+ { in: "body", key: "approval" },
+ { in: "body", key: "source" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).put<
+ SessionPreferenceSetResponses,
+ SessionPreferenceSetErrors,
+ ThrowOnError
+ >({
+ url: "/session/{sessionID}/preference",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+}
+
export class Session2 extends HeyApiClient {
/**
* List sessions
@@ -2684,6 +2915,11 @@ export class Session2 extends HeyApiClient {
...params,
})
}
+
+ private _preference?: Preference
+ get preference(): Preference {
+ return (this._preference ??= new Preference({ client: this.client }))
+ }
}
export class Part extends HeyApiClient {
@@ -3548,13 +3784,13 @@ export class Provider extends HeyApiClient {
}
}
-export class File extends HeyApiClient {
+export class State extends HeyApiClient {
/**
- * List active conversion/translation tasks
+ * Reset merge state
*
- * Returns all currently running PDF conversion and markdown translation tasks, allowing the frontend to restore progress bars after page reload.
+ * Mark copy completion state as consumed so restart will not re-show the completion toast.
*/
- public activeTasks(
+ public reset(
parameters?: {
directory?: string
workspace?: string
@@ -3572,23 +3808,24 @@ export class File extends HeyApiClient {
},
],
)
- return (options?.client ?? this.client).get({
- url: "/file/active-tasks",
+ return (options?.client ?? this.client).post({
+ url: "/database/legacy/merge/state/reset",
...options,
...params,
})
}
+}
+export class Merge extends HeyApiClient {
/**
- * Check directory
+ * Get merge state
*
- * Check whether a path exists and points to a directory.
+ * Get legacy database copy state.
*/
- public checkDirectory(
- parameters: {
+ public state(
+ parameters?: {
directory?: string
workspace?: string
- path: string
},
options?: Options,
) {
@@ -3599,28 +3836,33 @@ export class File extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
- { in: "query", key: "path" },
],
},
],
)
- return (options?.client ?? this.client).get({
- url: "/file/check-directory",
+ return (options?.client ?? this.client).get({
+ url: "/database/legacy/merge/state",
...options,
...params,
})
}
+ private _state?: State
+ get state2(): State {
+ return (this._state ??= new State({ client: this.client }))
+ }
+}
+
+export class Legacy extends HeyApiClient {
/**
- * Delete file or directory
+ * Scan legacy databases
*
- * Delete a file or directory at the specified path within the project.
+ * Scan current data directory and report whether the latest legacy database should be copied.
*/
- public delete(
- parameters: {
+ public status(
+ parameters?: {
directory?: string
workspace?: string
- path: string
},
options?: Options,
) {
@@ -3631,28 +3873,26 @@ export class File extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
- { in: "query", key: "path" },
],
},
],
)
- return (options?.client ?? this.client).delete({
- url: "/file",
+ return (options?.client ?? this.client).get({
+ url: "/database/legacy/status",
...options,
...params,
})
}
/**
- * List files
+ * Copy latest legacy database
*
- * List files and directories in a specified path.
+ * Copy the latest legacy database into aether-prod.db when the target database is missing.
*/
- public list(
- parameters: {
+ public merge(
+ parameters?: {
directory?: string
workspace?: string
- path: string
},
options?: Options,
) {
@@ -3663,29 +3903,40 @@ export class File extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
- { in: "query", key: "path" },
],
},
],
)
- return (options?.client ?? this.client).get({
- url: "/file",
+ return (options?.client ?? this.client).post({
+ url: "/database/legacy/merge",
...options,
...params,
})
}
+ private _merge?: Merge
+ get merge2(): Merge {
+ return (this._merge ??= new Merge({ client: this.client }))
+ }
+}
+
+export class Database extends HeyApiClient {
+ private _legacy?: Legacy
+ get legacy(): Legacy {
+ return (this._legacy ??= new Legacy({ client: this.client }))
+ }
+}
+
+export class File extends HeyApiClient {
/**
- * Rename file or directory
+ * List active conversion/translation tasks
*
- * Rename a file or directory within the project.
+ * Returns all currently running PDF conversion and markdown translation tasks, allowing the frontend to restore progress bars after page reload.
*/
- public rename(
+ public activeTasks(
parameters?: {
directory?: string
workspace?: string
- path?: string
- name?: string
},
options?: Options,
) {
@@ -3696,35 +3947,27 @@ export class File extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
- { in: "body", key: "path" },
- { in: "body", key: "name" },
],
},
],
)
- return (options?.client ?? this.client).patch({
- url: "/file",
+ return (options?.client ?? this.client).get({
+ url: "/file/active-tasks",
...options,
...params,
- headers: {
- "Content-Type": "application/json",
- ...options?.headers,
- ...params.headers,
- },
})
}
/**
- * Create file or directory
+ * Check directory
*
- * Create a new file or directory at the specified path within the project.
+ * Check whether a path exists and points to a directory.
*/
- public create(
- parameters?: {
+ public checkDirectory(
+ parameters: {
directory?: string
workspace?: string
- path?: string
- type?: "file" | "directory"
+ path: string
},
options?: Options,
) {
@@ -3735,30 +3978,24 @@ export class File extends HeyApiClient {
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
- { in: "body", key: "path" },
- { in: "body", key: "type" },
+ { in: "query", key: "path" },
],
},
],
)
- return (options?.client ?? this.client).post({
- url: "/file",
+ return (options?.client ?? this.client).get({
+ url: "/file/check-directory",
...options,
...params,
- headers: {
- "Content-Type": "application/json",
- ...options?.headers,
- ...params.headers,
- },
})
}
/**
- * Read file
+ * Delete file or directory
*
- * Read the content of a specified file.
+ * Delete a file or directory at the specified path within the project.
*/
- public read(
+ public delete(
parameters: {
directory?: string
workspace?: string
@@ -3778,8 +4015,150 @@ export class File extends HeyApiClient {
},
],
)
- return (options?.client ?? this.client).get({
- url: "/file/content",
+ return (options?.client ?? this.client).delete({
+ url: "/file",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * List files
+ *
+ * List files and directories in a specified path.
+ */
+ public list(
+ parameters: {
+ directory?: string
+ workspace?: string
+ path: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "path" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/file",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Rename file or directory
+ *
+ * Rename a file or directory within the project.
+ */
+ public rename(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ path?: string
+ name?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "path" },
+ { in: "body", key: "name" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).patch({
+ url: "/file",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Create file or directory
+ *
+ * Create a new file or directory at the specified path within the project.
+ */
+ public create(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ path?: string
+ type?: "file" | "directory"
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "path" },
+ { in: "body", key: "type" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/file",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Read file
+ *
+ * Read the content of a specified file.
+ */
+ public read(
+ parameters: {
+ directory?: string
+ workspace?: string
+ path: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "query", key: "path" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/file/content",
...options,
...params,
})
@@ -3888,6 +4267,45 @@ export class File extends HeyApiClient {
})
}
+ /**
+ * Ensure directory exists
+ *
+ * Create a directory at the given absolute path if it does not exist, including any intermediate directories.
+ */
+ public ensureDirectory(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ path?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "path" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post(
+ {
+ url: "/file/ensure-directory",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ },
+ )
+ }
+
/**
* Generate directory summaries
*
@@ -5925,6 +6343,36 @@ export class Wechat extends HeyApiClient {
})
}
+ /**
+ * Ping WeChat lease
+ *
+ * Renew the WeChat lock lease or detect if stolen by another client
+ */
+ public ping(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/wechat/ping",
+ ...options,
+ ...params,
+ })
+ }
+
/**
* Get WeChat status
*
@@ -5992,6 +6440,165 @@ export class Wechat extends HeyApiClient {
}
export class Session4 extends HeyApiClient {
+ /**
+ * Clear Feishu session
+ *
+ * Clear the saved Feishu configuration and session data
+ */
+ public clear(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).delete({
+ url: "/feishu/session",
+ ...options,
+ ...params,
+ })
+ }
+}
+
+export class Feishu extends HeyApiClient {
+ /**
+ * Start Feishu bridge
+ *
+ * Start the Feishu bridge service with WebSocket connection
+ */
+ public start(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/feishu/start",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Stop Feishu bridge
+ *
+ * Stop the Feishu bridge service
+ */
+ public stop(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/feishu/stop",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Get Feishu status
+ *
+ * Get the current Feishu bridge status
+ */
+ public status(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).get({
+ url: "/feishu/status",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Subscribe to Feishu events
+ *
+ * Get real-time Feishu events via SSE
+ */
+ public events(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).sse.get({
+ url: "/feishu/events",
+ ...options,
+ ...params,
+ })
+ }
+
+ private _session?: Session4
+ get session(): Session4 {
+ return (this._session ??= new Session4({ client: this.client }))
+ }
+}
+
+export class Session5 extends HeyApiClient {
/**
* Create reading mode session
*/
@@ -6024,6 +6631,55 @@ export class Session4 extends HeyApiClient {
})
}
+ /**
+ * Open reading mode from a workspace PDF file
+ */
+ public fromFile(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ path?: string
+ settings?: {
+ translatePrompt?: string
+ questionPrompt?: string
+ firstReadPrompt?: string
+ contextPageRange?: 0 | 1 | 2
+ autoFirstRead?: boolean
+ }
+ forceNew?: boolean
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "path" },
+ { in: "body", key: "settings" },
+ { in: "body", key: "forceNew" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post<
+ ReadingModeSessionFromFileResponses,
+ ReadingModeSessionFromFileErrors,
+ ThrowOnError
+ >({
+ url: "/reading-mode/session/from-file",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
/**
* Update reading mode session settings
*/
@@ -6040,6 +6696,7 @@ export class Session4 extends HeyApiClient {
autoFirstRead?: boolean
}
firstReadCompleted?: boolean
+ firstReadDismissed?: boolean
},
options?: Options,
) {
@@ -6053,6 +6710,7 @@ export class Session4 extends HeyApiClient {
{ in: "query", key: "workspace" },
{ in: "body", key: "settings" },
{ in: "body", key: "firstReadCompleted" },
+ { in: "body", key: "firstReadDismissed" },
],
},
],
@@ -6184,9 +6842,89 @@ export class Pdf extends HeyApiClient {
}
export class ReadingMode extends HeyApiClient {
- private _session?: Session4
- get session(): Session4 {
- return (this._session ??= new Session4({ client: this.client }))
+ /**
+ * Get extracted text for reading mode PDF pages
+ */
+ public pageText(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ sessionID?: string
+ startPage?: number
+ endPage?: number
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "sessionID" },
+ { in: "body", key: "startPage" },
+ { in: "body", key: "endPage" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post(
+ {
+ url: "/reading-mode/page-text",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ },
+ )
+ }
+
+ /**
+ * Get a ranged PDF subdocument for reading mode
+ */
+ public pagePdf(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ sessionID?: string
+ startPage?: number
+ endPage?: number
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { in: "body", key: "sessionID" },
+ { in: "body", key: "startPage" },
+ { in: "body", key: "endPage" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/reading-mode/page-pdf",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ private _session?: Session5
+ get session(): Session5 {
+ return (this._session ??= new Session5({ client: this.client }))
}
private _annotations?: Annotations
@@ -6507,6 +7245,11 @@ export class OpencodeClient extends HeyApiClient {
return (this._provider ??= new Provider({ client: this.client }))
}
+ private _database?: Database
+ get database(): Database {
+ return (this._database ??= new Database({ client: this.client }))
+ }
+
private _file?: File
get file(): File {
return (this._file ??= new File({ client: this.client }))
@@ -6542,6 +7285,11 @@ export class OpencodeClient extends HeyApiClient {
return (this._wechat ??= new Wechat({ client: this.client }))
}
+ private _feishu?: Feishu
+ get feishu(): Feishu {
+ return (this._feishu ??= new Feishu({ client: this.client }))
+ }
+
private _readingMode?: ReadingMode
get readingMode(): ReadingMode {
return (this._readingMode ??= new ReadingMode({ client: this.client }))
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index cc79268b5d..887ba2b817 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -362,6 +362,23 @@ export type EventCommandExecuted = {
}
}
+export type EventSessionPreferenceChanged = {
+ type: "session.preference.changed"
+ properties: {
+ sessionID: string
+ info: {
+ sessionID: string
+ agent?: string
+ model?: {
+ providerID: string
+ modelID: string
+ }
+ variant?: string
+ approval?: "auto" | "ask"
+ }
+ }
+}
+
export type FileDiff = {
file: string
before: string
@@ -563,6 +580,29 @@ export type EventWechatError = {
}
}
+export type EventFeishuStatus = {
+ type: "feishu.status"
+ properties: {
+ status: "idle" | "starting" | "connected" | "error"
+ message?: string
+ }
+}
+
+export type EventFeishuConnected = {
+ type: "feishu.connected"
+ properties: {
+ appId: string
+ }
+}
+
+export type EventFeishuError = {
+ type: "feishu.error"
+ properties: {
+ code: string
+ message: string
+ }
+}
+
export type OutputFormatText = {
type: "text"
}
@@ -1059,6 +1099,7 @@ export type Event =
| EventMcpToolsChanged
| EventMcpBrowserOpenFailed
| EventCommandExecuted
+ | EventSessionPreferenceChanged
| EventSessionDiff
| EventSessionError
| EventVcsBranchUpdated
@@ -1074,6 +1115,9 @@ export type Event =
| EventWechatQrcode
| EventWechatConnected
| EventWechatError
+ | EventFeishuStatus
+ | EventFeishuConnected
+ | EventFeishuError
| EventMessageUpdated
| EventMessageRemoved
| EventMessagePartUpdated
@@ -2271,6 +2315,24 @@ export type GlobalHealthResponses = {
export type GlobalHealthResponse = GlobalHealthResponses[keyof GlobalHealthResponses]
+export type GlobalWebUpdateCurrentData = {
+ body?: never
+ path?: never
+ query?: never
+ url: "/global/web-update/current"
+}
+
+export type GlobalWebUpdateCurrentResponses = {
+ /**
+ * Current local web version
+ */
+ 200: {
+ currentVersion: string
+ }
+}
+
+export type GlobalWebUpdateCurrentResponse = GlobalWebUpdateCurrentResponses[keyof GlobalWebUpdateCurrentResponses]
+
export type GlobalPingData = {
body?: {
id: string
@@ -2391,6 +2453,108 @@ export type GlobalDisposeResponses = {
export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses]
+export type GlobalWebUpdateCheckData = {
+ body?: never
+ path?: never
+ query?: never
+ url: "/global/web-update/check"
+}
+
+export type GlobalWebUpdateCheckErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type GlobalWebUpdateCheckError = GlobalWebUpdateCheckErrors[keyof GlobalWebUpdateCheckErrors]
+
+export type GlobalWebUpdateCheckResponses = {
+ /**
+ * Version check result
+ */
+ 200: {
+ currentVersion: string
+ remoteVersion: string
+ updateAvailable: boolean
+ downloaded: boolean
+ checkError?: string
+ }
+}
+
+export type GlobalWebUpdateCheckResponse = GlobalWebUpdateCheckResponses[keyof GlobalWebUpdateCheckResponses]
+
+export type GlobalWebUpdateDownloadData = {
+ body?: {
+ os: "darwin" | "linux" | "windows"
+ version: string
+ }
+ path?: never
+ query?: never
+ url: "/global/web-update/download"
+}
+
+export type GlobalWebUpdateDownloadErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type GlobalWebUpdateDownloadError = GlobalWebUpdateDownloadErrors[keyof GlobalWebUpdateDownloadErrors]
+
+export type GlobalWebUpdateDownloadResponses = {
+ /**
+ * Download result
+ */
+ 200:
+ | {
+ success: true
+ path: string
+ }
+ | {
+ success: false
+ error: string
+ }
+}
+
+export type GlobalWebUpdateDownloadResponse = GlobalWebUpdateDownloadResponses[keyof GlobalWebUpdateDownloadResponses]
+
+export type GlobalWebUpdateInstallData = {
+ body?: {
+ os: "darwin" | "linux" | "windows"
+ version?: string
+ }
+ path?: never
+ query?: never
+ url: "/global/web-update/install"
+}
+
+export type GlobalWebUpdateInstallErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type GlobalWebUpdateInstallError = GlobalWebUpdateInstallErrors[keyof GlobalWebUpdateInstallErrors]
+
+export type GlobalWebUpdateInstallResponses = {
+ /**
+ * Install result
+ */
+ 200:
+ | {
+ success: true
+ }
+ | {
+ success: false
+ error: string
+ }
+}
+
+export type GlobalWebUpdateInstallResponse = GlobalWebUpdateInstallResponses[keyof GlobalWebUpdateInstallResponses]
+
export type GlobalUpgradeData = {
body?: {
target?: string
@@ -4314,6 +4478,101 @@ export type PermissionRespondResponses = {
export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses]
+export type SessionPreferenceGetData = {
+ body?: never
+ path: {
+ sessionID: string
+ }
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/session/{sessionID}/preference"
+}
+
+export type SessionPreferenceGetErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type SessionPreferenceGetError = SessionPreferenceGetErrors[keyof SessionPreferenceGetErrors]
+
+export type SessionPreferenceGetResponses = {
+ /**
+ * Session preference
+ */
+ 200: {
+ sessionID: string
+ agent?: string
+ model?: {
+ providerID: string
+ modelID: string
+ }
+ variant?: string
+ approval?: "auto" | "ask"
+ } | null
+}
+
+export type SessionPreferenceGetResponse = SessionPreferenceGetResponses[keyof SessionPreferenceGetResponses]
+
+export type SessionPreferenceSetData = {
+ body?: {
+ agent?: string
+ model?: {
+ providerID: string
+ modelID: string
+ }
+ variant?: string
+ approval?: "auto" | "ask"
+ source?: string
+ }
+ path: {
+ sessionID: string
+ }
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/session/{sessionID}/preference"
+}
+
+export type SessionPreferenceSetErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type SessionPreferenceSetError = SessionPreferenceSetErrors[keyof SessionPreferenceSetErrors]
+
+export type SessionPreferenceSetResponses = {
+ /**
+ * Preference updated
+ */
+ 200: {
+ sessionID: string
+ agent?: string
+ model?: {
+ providerID: string
+ modelID: string
+ }
+ variant?: string
+ approval?: "auto" | "ask"
+ }
+}
+
+export type SessionPreferenceSetResponse = SessionPreferenceSetResponses[keyof SessionPreferenceSetResponses]
+
export type AppSkillsData = {
body?: never
path?: never
@@ -5022,6 +5281,145 @@ export type ProviderOauthCallbackResponses = {
export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses]
+export type DatabaseLegacyStatusData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/database/legacy/status"
+}
+
+export type DatabaseLegacyStatusResponses = {
+ /**
+ * Legacy database scan status
+ */
+ 200: {
+ directory: string
+ target: string
+ has_legacy: boolean
+ message: string
+ dismissed: boolean
+ should_merge: boolean
+ source_count: number
+ legacy_count: number
+ files: Array<{
+ name: string
+ path: string
+ channel: string
+ mtime: number
+ }>
+ naming: {
+ [key: string]: number
+ }
+ versions: {
+ [key: string]: number
+ }
+ }
+}
+
+export type DatabaseLegacyStatusResponse = DatabaseLegacyStatusResponses[keyof DatabaseLegacyStatusResponses]
+
+export type DatabaseLegacyMergeStateData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/database/legacy/merge/state"
+}
+
+export type DatabaseLegacyMergeStateResponses = {
+ /**
+ * Merge state
+ */
+ 200: {
+ state: "idle" | "running" | "done" | "error"
+ updated: number
+ error?: string
+ details?: Array
+ }
+}
+
+export type DatabaseLegacyMergeStateResponse =
+ DatabaseLegacyMergeStateResponses[keyof DatabaseLegacyMergeStateResponses]
+
+export type DatabaseLegacyMergeStateResetData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/database/legacy/merge/state/reset"
+}
+
+export type DatabaseLegacyMergeStateResetResponses = {
+ /**
+ * Reset merge state
+ */
+ 200: {
+ state: "idle" | "running" | "done" | "error"
+ updated: number
+ error?: string
+ details?: Array
+ }
+}
+
+export type DatabaseLegacyMergeStateResetResponse =
+ DatabaseLegacyMergeStateResetResponses[keyof DatabaseLegacyMergeStateResetResponses]
+
+export type DatabaseLegacyMergeData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/database/legacy/merge"
+}
+
+export type DatabaseLegacyMergeResponses = {
+ /**
+ * Copy result
+ */
+ 200: {
+ status: {
+ directory: string
+ target: string
+ has_legacy: boolean
+ message: string
+ dismissed: boolean
+ should_merge: boolean
+ source_count: number
+ legacy_count: number
+ files: Array<{
+ name: string
+ path: string
+ channel: string
+ mtime: number
+ }>
+ naming: {
+ [key: string]: number
+ }
+ versions: {
+ [key: string]: number
+ }
+ }
+ mode: "noop" | "copy"
+ merge_state: {
+ state: "idle" | "running" | "done" | "error"
+ updated: number
+ error?: string
+ details?: Array
+ }
+ }
+}
+
+export type DatabaseLegacyMergeResponse = DatabaseLegacyMergeResponses[keyof DatabaseLegacyMergeResponses]
+
export type FileActiveTasksData = {
body?: never
path?: never
@@ -5404,6 +5802,39 @@ export type FileUploadResponses = {
export type FileUploadResponse = FileUploadResponses[keyof FileUploadResponses]
+export type FileEnsureDirectoryData = {
+ body?: {
+ path: string
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/file/ensure-directory"
+}
+
+export type FileEnsureDirectoryErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type FileEnsureDirectoryError = FileEnsureDirectoryErrors[keyof FileEnsureDirectoryErrors]
+
+export type FileEnsureDirectoryResponses = {
+ /**
+ * Directory ensured
+ */
+ 200: {
+ ok: boolean
+ path: string
+ }
+}
+
+export type FileEnsureDirectoryResponse = FileEnsureDirectoryResponses[keyof FileEnsureDirectoryResponses]
+
export type FileSummarizeData = {
body?: {
directory?: string
@@ -7102,6 +7533,28 @@ export type WechatStopResponses = {
export type WechatStopResponse = WechatStopResponses[keyof WechatStopResponses]
+export type WechatPingData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/wechat/ping"
+}
+
+export type WechatPingResponses = {
+ /**
+ * Ping result
+ */
+ 200: {
+ ok: boolean
+ stolen: boolean
+ }
+}
+
+export type WechatPingResponse = WechatPingResponses[keyof WechatPingResponses]
+
export type WechatStatusData = {
body?: never
path?: never
@@ -7170,6 +7623,117 @@ export type WechatSessionClearResponses = {
export type WechatSessionClearResponse = WechatSessionClearResponses[keyof WechatSessionClearResponses]
+export type FeishuStartData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/feishu/start"
+}
+
+export type FeishuStartResponses = {
+ /**
+ * Bridge started
+ */
+ 200: {
+ success: boolean
+ code?: string
+ message?: string
+ status?: string
+ appId?: string
+ }
+}
+
+export type FeishuStartResponse = FeishuStartResponses[keyof FeishuStartResponses]
+
+export type FeishuStopData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/feishu/stop"
+}
+
+export type FeishuStopResponses = {
+ /**
+ * Bridge stopped
+ */
+ 200: {
+ success: boolean
+ }
+}
+
+export type FeishuStopResponse = FeishuStopResponses[keyof FeishuStopResponses]
+
+export type FeishuStatusData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/feishu/status"
+}
+
+export type FeishuStatusResponses = {
+ /**
+ * Status
+ */
+ 200: {
+ status: "idle" | "starting" | "connected" | "error"
+ appId: string | null
+ hasConfig: boolean
+ error: {
+ code: string
+ message: string
+ } | null
+ }
+}
+
+export type FeishuStatusResponse = FeishuStatusResponses[keyof FeishuStatusResponses]
+
+export type FeishuEventsData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/feishu/events"
+}
+
+export type FeishuEventsResponses = {
+ /**
+ * Event stream
+ */
+ 200: unknown
+}
+
+export type FeishuSessionClearData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/feishu/session"
+}
+
+export type FeishuSessionClearResponses = {
+ /**
+ * Session cleared
+ */
+ 200: {
+ success: boolean
+ }
+}
+
+export type FeishuSessionClearResponse = FeishuSessionClearResponses[keyof FeishuSessionClearResponses]
+
export type ReadingModeSessionCreateData = {
body?: never
path?: never
@@ -7199,6 +7763,129 @@ export type ReadingModeSessionCreateResponses = {
export type ReadingModeSessionCreateResponse =
ReadingModeSessionCreateResponses[keyof ReadingModeSessionCreateResponses]
+export type ReadingModeSessionFromFileData = {
+ body?: {
+ path: string
+ settings?: {
+ translatePrompt?: string
+ questionPrompt?: string
+ firstReadPrompt?: string
+ contextPageRange?: 0 | 1 | 2
+ autoFirstRead?: boolean
+ }
+ forceNew?: boolean
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/reading-mode/session/from-file"
+}
+
+export type ReadingModeSessionFromFileErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type ReadingModeSessionFromFileError = ReadingModeSessionFromFileErrors[keyof ReadingModeSessionFromFileErrors]
+
+export type ReadingModeSessionFromFileResponses = {
+ /**
+ * Existing or newly created reading mode session
+ */
+ 200: {
+ action: "existing" | "created"
+ session: Session
+ }
+}
+
+export type ReadingModeSessionFromFileResponse =
+ ReadingModeSessionFromFileResponses[keyof ReadingModeSessionFromFileResponses]
+
+export type ReadingModePageTextData = {
+ body?: {
+ sessionID: string
+ startPage: number
+ endPage: number
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/reading-mode/page-text"
+}
+
+export type ReadingModePageTextErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type ReadingModePageTextError = ReadingModePageTextErrors[keyof ReadingModePageTextErrors]
+
+export type ReadingModePageTextResponses = {
+ /**
+ * Extracted page text
+ */
+ 200: {
+ pageCount: number
+ pages: Array<{
+ pageNumber: number
+ text: string
+ }>
+ combinedText: string
+ }
+}
+
+export type ReadingModePageTextResponse = ReadingModePageTextResponses[keyof ReadingModePageTextResponses]
+
+export type ReadingModePagePdfData = {
+ body?: {
+ sessionID: string
+ startPage: number
+ endPage: number
+ }
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/reading-mode/page-pdf"
+}
+
+export type ReadingModePagePdfErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type ReadingModePagePdfError = ReadingModePagePdfErrors[keyof ReadingModePagePdfErrors]
+
+export type ReadingModePagePdfResponses = {
+ /**
+ * PDF binary for the requested page range
+ */
+ 200: unknown
+}
+
export type ReadingModeAnnotationsGetData = {
body?: never
path?: never