diff --git a/canvas-agent/package-lock.json b/canvas-agent/package-lock.json index 7735810..64cd446 100644 --- a/canvas-agent/package-lock.json +++ b/canvas-agent/package-lock.json @@ -11,8 +11,10 @@ "@modelcontextprotocol/sdk": "^1.12.1", "@openai/codex": "0.146.0", "express": "^5.1.0", + "gray-matter": "^4.0.3", "strip-ansi": "7.2.0", "winston": "^3.19.0", + "yaml": "^2.9.0", "zod": "^3.25.0" }, "bin": { @@ -371,6 +373,15 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/async": { "version": "3.2.6", "license": "MIT" @@ -643,6 +654,19 @@ "version": "1.0.3", "license": "MIT" }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/etag": { "version": "1.8.1", "license": "MIT", @@ -724,6 +748,18 @@ "express": ">= 4.11" } }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "license": "MIT" @@ -845,6 +881,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gray-matter": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz", + "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", + "license": "MIT", + "dependencies": { + "js-yaml": "^3.13.1", + "kind-of": "^6.0.2", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=6.0" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "license": "MIT", @@ -922,6 +973,15 @@ "node": ">= 0.10" } }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-promise": { "version": "4.0.0", "license": "MIT" @@ -947,6 +1007,19 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/js-yaml": { + "version": "3.15.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.1.tgz", + "integrity": "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==", + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "license": "MIT" @@ -955,6 +1028,15 @@ "version": "8.0.2", "license": "BSD-2-Clause" }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/kuler": { "version": "2.0.0", "license": "MIT" @@ -1206,6 +1288,19 @@ "version": "2.1.2", "license": "MIT" }, + "node_modules/section-matter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/send": { "version": "1.2.1", "license": "MIT", @@ -1332,6 +1427,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/stack-trace": { "version": "0.0.10", "license": "MIT", @@ -1368,6 +1469,15 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/text-hex": { "version": "1.0.0", "license": "MIT" @@ -1514,6 +1624,21 @@ "version": "1.0.2", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zod": { "version": "3.25.76", "license": "MIT", diff --git a/canvas-agent/package.json b/canvas-agent/package.json index 0ee9113..6946779 100644 --- a/canvas-agent/package.json +++ b/canvas-agent/package.json @@ -15,7 +15,7 @@ "scripts": { "dev": "tsx src/index.ts", "debug": "tsx src/index.ts --debug", - "test": "tsx --test src/canvas/session.test.ts src/agent/codex-client.test.ts src/agent/codex-history.test.ts", + "test": "tsx --test src/canvas/session.test.ts src/agent/codex-client.test.ts src/agent/codex-history.test.ts src/skills/store.test.ts", "build": "tsc -p tsconfig.json", "start": "node dist/index.js", "prepack": "npm run build" @@ -24,8 +24,10 @@ "@modelcontextprotocol/sdk": "^1.12.1", "@openai/codex": "0.146.0", "express": "^5.1.0", + "gray-matter": "^4.0.3", "strip-ansi": "7.2.0", "winston": "^3.19.0", + "yaml": "^2.9.0", "zod": "^3.25.0" }, "devDependencies": { diff --git a/canvas-agent/src/agent/codex-client.test.ts b/canvas-agent/src/agent/codex-client.test.ts index aa8144c..32406d4 100644 --- a/canvas-agent/src/agent/codex-client.test.ts +++ b/canvas-agent/src/agent/codex-client.test.ts @@ -57,6 +57,89 @@ test("中断请求只作用于当前运行线程", async () => { assert.equal(await interrupt, true); }); +test("Skill 列表与启用配置使用 app-server 原生协议", async () => { + const writes: Array> = []; + const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } }; + const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient; + const testClient = client as unknown as TestClient; + + const listing = client.listSkills("D:\\site", true); + const listRequest = writes.at(-1); + assert.deepEqual(listRequest, { id: 1, method: "skills/list", params: { cwds: ["D:\\site"], forceReload: true } }); + testClient.handle({ id: listRequest?.id, result: { data: [{ cwd: "D:\\site", skills: [], errors: [] }] } }); + assert.equal((await listing).data[0]?.cwd, "D:\\site"); + + const configuring = client.setSkillEnabled("D:\\site\\.agents\\skills\\demo\\SKILL.md", false); + const configRequest = writes.at(-1); + assert.deepEqual(configRequest, { id: 2, method: "skills/config/write", params: { path: "D:\\site\\.agents\\skills\\demo\\SKILL.md", enabled: false } }); + testClient.handle({ id: configRequest?.id, result: { effectiveEnabled: false } }); + assert.equal((await configuring).effectiveEnabled, false); +}); + +test("并发强制刷新同一工作空间时只扫描一次 Skill", async () => { + const writes: Array> = []; + const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } }; + const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient; + const testClient = client as unknown as TestClient; + + const first = client.listSkills("D:\\site", true); + const second = client.listSkills("D:\\site", true); + assert.equal(writes.length, 1); + testClient.handle({ id: writes[0].id, result: { data: [{ cwd: "D:\\site", skills: [], errors: [] }] } }); + assert.deepEqual(await Promise.all([first, second]), [ + { data: [{ cwd: "D:\\site", skills: [], errors: [] }] }, + { data: [{ cwd: "D:\\site", skills: [], errors: [] }] }, + ]); + + const next = client.listSkills("D:\\site", true); + assert.equal(writes.length, 2); + testClient.handle({ id: writes[1].id, result: { data: [] } }); + await next; +}); + +test("显式 Skill 同时使用文本标记和结构化输入传给 turn/start", async () => { + const writes: Array> = []; + const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } }; + const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient; + const testClient = client as unknown as TestClient; + const skill = { name: "demo-skill", path: "D:\\site\\.agents\\skills\\demo-skill\\SKILL.md" }; + + const running = client.startTurn("thread-1", "执行任务", [], "request", undefined, undefined, undefined, skill); + const request = writes.find((item) => item.method === "turn/start"); + assert.deepEqual((request?.params as { input?: unknown[] })?.input, [ + { type: "text", text: "$demo-skill 执行任务", text_elements: [] }, + { type: "skill", ...skill }, + ]); + testClient.handle({ id: request?.id, result: { turn: { id: "turn-1" } } }); + await new Promise((resolve) => setImmediate(resolve)); + testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } }); + await running; +}); + +test("显式 Skill 不重复已有的文本标记", async () => { + const writes: Array> = []; + const child = { stdin: { write: (line: string) => (writes.push(JSON.parse(line)), true) } }; + const client = Reflect.construct(CodexAppClient, [child, () => undefined, emptyEventHistory]) as CodexAppClient; + const testClient = client as unknown as TestClient; + const skill = { name: "demo-skill", path: "D:\\site\\.agents\\skills\\demo-skill\\SKILL.md" }; + + const running = client.startTurn("thread-1", "Use $demo-skill: 执行任务", [], "request", undefined, undefined, undefined, skill); + const request = writes.find((item) => item.method === "turn/start"); + assert.equal(((request?.params as { input?: Array<{ text?: string }> })?.input || [])[0]?.text, "Use $demo-skill: 执行任务"); + testClient.handle({ id: request?.id, result: { turn: { id: "turn-1" } } }); + await new Promise((resolve) => setImmediate(resolve)); + testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } }); + await running; +}); + +test("skills/changed 作为站点级事件单独广播", () => { + const events: Array<{ type: string; payload: unknown }> = []; + const child = { stdin: { write: () => true } }; + const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), emptyEventHistory]) as CodexAppClient; + (client as unknown as TestClient).handleNotification("skills/changed", {}); + assert.deepEqual(events, [{ type: "skills_changed", payload: {} }]); +}); + test("turn/started 早于 turn/start 响应时保持完整事件归属", async () => { const writes: Array> = []; const events: Array<{ type: string; payload: unknown }> = []; @@ -274,7 +357,7 @@ test("turn 完成通知会保存本轮输入与终态 turn", async () => { const client = Reflect.construct(CodexAppClient, [child, () => undefined, history]) as CodexAppClient; const testClient = client as unknown as TestClient; - const running = client.startTurn("thread-1", "执行 Get-Location", [], "request"); + const running = client.startTurn("thread-1", "$command-runner 执行 Get-Location", [], "request", undefined, undefined, undefined, undefined, "执行 Get-Location"); const request = writes.find((item) => item.method === "turn/start"); assert.ok(request); testClient.handle({ id: request.id, result: { turn: { id: "turn-1" } } }); @@ -282,7 +365,7 @@ test("turn 完成通知会保存本轮输入与终态 turn", async () => { testClient.handleNotification("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", status: "completed", durationMs: 120 } }); await running; - assert.deepEqual(persistedTurns, [{ threadId: "thread-1", turnId: "turn-1", turn: { id: "turn-1", status: "completed", durationMs: 120, input: "执行 Get-Location" } }]); + assert.deepEqual(persistedTurns, [{ threadId: "thread-1", turnId: "turn-1", turn: { id: "turn-1", status: "completed", durationMs: 120, input: "$command-runner 执行 Get-Location", messageText: "执行 Get-Location" } }]); }); test("turn 完成状态会等待补充历史落盘后再广播", async () => { @@ -328,7 +411,7 @@ test("app-server 在 turn 完成通知前退出时保存失败终态", async () const client = Reflect.construct(CodexAppClient, [child, (type: string, payload: unknown) => events.push({ type, payload }), history]) as CodexAppClient; const testClient = client as unknown as TestClient; - const running = client.startTurn("thread-1", "执行失败任务", [], "request"); + const running = client.startTurn("thread-1", "$failure-check 执行失败任务", [], "request", undefined, undefined, undefined, undefined, "执行失败任务"); testClient.handle({ id: 1, result: { turn: { id: "turn-1" } } }); await new Promise((resolve) => setImmediate(resolve)); testClient.failAll("Codex app-server exited: 1"); @@ -337,7 +420,7 @@ test("app-server 在 turn 完成通知前退出时保存失败终态", async () assert.deepEqual(persistedTurns, [{ threadId: "thread-1", turnId: "turn-1", - turn: { id: "turn-1", status: "failed", error: { message: "Codex app-server exited: 1" }, input: "执行失败任务" }, + turn: { id: "turn-1", status: "failed", error: { message: "Codex app-server exited: 1" }, input: "$failure-check 执行失败任务", messageText: "执行失败任务" }, }]); const completed = events.find((event) => event.type === "agent_event" && eventType(event.payload) === "turn.completed"); assert.equal((completed?.payload as { status?: string })?.status, "failed"); diff --git a/canvas-agent/src/agent/codex-client.ts b/canvas-agent/src/agent/codex-client.ts index 52ea528..9b95819 100644 --- a/canvas-agent/src/agent/codex-client.ts +++ b/canvas-agent/src/agent/codex-client.ts @@ -8,16 +8,16 @@ import { VERSION } from "../config.js"; import { logger } from "../utils/logger.js"; import { field, type JsonRecord } from "../utils/value.js"; import { codexEventHistory, type CodexEventHistory } from "./codex-event-history.js"; -import type { CodexNotificationParams, CodexPlanUpdate, CodexReasoningEffort, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexTurnInput } from "./codex-protocol.js"; +import type { CodexNotificationParams, CodexPlanUpdate, CodexReasoningEffort, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexSkillSelector, CodexTurnInput } from "./codex-protocol.js"; import type { AgentEmit, AgentPermissionMode } from "./types.js"; type AgentEvent = JsonRecord & { type: string; usage?: unknown }; type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void }; -type ActiveTurn = PendingRequest & { threadId: string; turnId: string; prompt: string }; +type ActiveTurn = PendingRequest & { threadId: string; turnId: string; prompt: string; messageText?: string }; type ItemDeltaParams = { threadId: string; turnId: string; itemId: string; delta: string; summaryIndex?: number }; type PendingDelta = { delta: string; itemType: string; params: ItemDeltaParams; timer: ReturnType }; type ApprovalRequest = { id: number; method: string; params: JsonRecord; decision?: string }; -type PendingTurnStart = { threadId: string; prompt: string; turnId?: string; onTurn?: (turnId: string) => void }; +type PendingTurnStart = { threadId: string; prompt: string; messageText?: string; turnId?: string; onTurn?: (turnId: string) => void }; const canvasAgentMcp = canvasAgentMcpCommand(); const require = createRequire(import.meta.url); @@ -50,6 +50,7 @@ export class CodexAppClient { private plansByTurn = new Map(); private approvalRequests = new Map(); private finalizingTurns = new Map>(); + private skillReloads = new Map>>(); private failing = false; /** 保存 app-server 子进程和事件出口。 */ @@ -123,6 +124,26 @@ export class CodexAppClient { return this.request("model/list", { limit: 100, includeHidden: false }); } + /** 查询指定工作空间可发现的 Codex Skills。 */ + listSkills(cwd: string, forceReload = false) { + if (!forceReload) return this.request("skills/list", { cwds: [cwd] }); + const key = process.platform === "win32" ? path.resolve(cwd).toLowerCase() : path.resolve(cwd); + const current = this.skillReloads.get(key); + if (current) return current; + const reload = this.request("skills/list", { cwds: [cwd], forceReload: true }); + this.skillReloads.set(key, reload); + const clear = () => { + if (this.skillReloads.get(key) === reload) this.skillReloads.delete(key); + }; + void reload.then(clear, clear); + return reload; + } + + /** 修改一个已发现 Skill 的启用状态。 */ + setSkillEnabled(path: string, enabled: boolean) { + return this.request("skills/config/write", { path, enabled }); + } + /** 返回指定线程在当前进程中收到的最新任务计划。 */ planUpdates(threadId: string) { return [...this.plansByTurn.values()].filter((item) => item.threadId === threadId); @@ -136,14 +157,14 @@ export class CodexAppClient { } /** 启动一个 Codex turn 并等待完成通知。 */ - async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, model?: string, effort?: CodexReasoningEffort, onTurn?: (turnId: string) => void) { + async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, model?: string, effort?: CodexReasoningEffort, onTurn?: (turnId: string) => void, skill?: CodexSkillSelector, messageText?: string) { this.currentThreadId = threadId; this.currentTurnId = ""; this.lastUsage = null; - const pendingStart: PendingTurnStart = { threadId, prompt, onTurn }; + const pendingStart: PendingTurnStart = { threadId, prompt, messageText, onTurn }; this.pendingTurnStart = pendingStart; try { - const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode), ...(model ? { model } : {}), ...(effort ? { effort } : {}) }); + const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images, skill), ...turnSettings(permissionMode), ...(model ? { model } : {}), ...(effort ? { effort } : {}) }); const turnId = turn.id; if (!turnId) throw new Error("Codex app-server 没有返回 turn id"); pendingStart.turnId = turnId; @@ -158,7 +179,7 @@ export class CodexAppClient { if (completed) throw completed; return; } - await new Promise((resolve, reject) => this.activeTurns.set(turnKey, { resolve, reject, threadId, turnId, prompt })); + await new Promise((resolve, reject) => this.activeTurns.set(turnKey, { resolve, reject, threadId, turnId, prompt, messageText })); } catch (error) { if (!this.currentTurnId) this.currentThreadId = ""; throw error; @@ -249,6 +270,10 @@ export class CodexAppClient { /** 转换并广播 app-server 通知。 */ private handleNotification(method: string, params: JsonRecord) { + if (method === "skills/changed") { + this.emit("skills_changed", {}); + return; + } if (method === "serverRequest/resolved") { const requestId = String(field(params, "requestId") || ""); const request = requestId ? this.approvalRequests.get(requestId) : undefined; @@ -347,8 +372,12 @@ export class CodexAppClient { const plan = this.plansByTurn.get(planKey); if (plan) this.plansByTurn.set(planKey, { ...plan, turnStatus: String(field(turn, "status") || "completed") }); if (threadId && turnId) { - const input = this.pendingTurnStart?.threadId === threadId ? this.pendingTurnStart.prompt : ""; - const turnRecord = { ...(turn && typeof turn === "object" && !Array.isArray(turn) ? turn as JsonRecord : { id: turnId, status: field(turn, "status") || "completed" }), ...(input ? { input } : {}) }; + const pendingStart = this.pendingTurnStart?.threadId === threadId && (!this.pendingTurnStart.turnId || this.pendingTurnStart.turnId === turnId) ? this.pendingTurnStart : undefined; + const turnRecord = { + ...(turn && typeof turn === "object" && !Array.isArray(turn) ? turn as JsonRecord : { id: turnId, status: field(turn, "status") || "completed" }), + ...(pendingStart?.prompt ? { input: pendingStart.prompt } : {}), + ...(pendingStart?.messageText ? { messageText: pendingStart.messageText } : {}), + }; turnPersistence = this.eventHistory.recordTurn({ threadId, turnId, turn: turnRecord }).catch((error) => logger.warn("Failed to persist Codex turn history", { threadId, turnId, error })); this.finalizingTurns.set(planKey, turnPersistence); } @@ -491,18 +520,18 @@ export class CodexAppClient { if (this.failing) return; this.failing = true; this.approvalRequests.forEach((request, requestId) => this.emit("codex_approval_resolved", { ...request.params, requestId, decision: request.decision || "cancel" })); - const failedTurns = new Map(); - this.activeTurns.forEach(({ threadId, turnId, prompt }, key) => { - if (!this.finalizingTurns.has(key)) failedTurns.set(key, { threadId, turnId, prompt }); + const failedTurns = new Map(); + this.activeTurns.forEach(({ threadId, turnId, prompt, messageText }, key) => { + if (!this.finalizingTurns.has(key)) failedTurns.set(key, { threadId, turnId, prompt, messageText }); }); const pendingStart = this.pendingTurnStart; if (pendingStart?.turnId) { const key = turnCacheKey(pendingStart.threadId, pendingStart.turnId); - if (!this.finalizingTurns.has(key) && !failedTurns.has(key)) failedTurns.set(key, { threadId: pendingStart.threadId, turnId: pendingStart.turnId, prompt: pendingStart.prompt }); + if (!this.finalizingTurns.has(key) && !failedTurns.has(key)) failedTurns.set(key, { threadId: pendingStart.threadId, turnId: pendingStart.turnId, prompt: pendingStart.prompt, messageText: pendingStart.messageText }); } const finalizing = [...this.finalizingTurns.values()]; - const persistence = [...failedTurns.values()].map(({ threadId, turnId, prompt }) => { - const turn = { id: turnId, status: "failed", error: { message }, ...(prompt ? { input: prompt } : {}) }; + const persistence = [...failedTurns.values()].map(({ threadId, turnId, prompt, messageText }) => { + const turn = { id: turnId, status: "failed", error: { message }, ...(prompt ? { input: prompt } : {}), ...(messageText ? { messageText } : {}) }; return this.eventHistory.recordTurn({ threadId, turnId, turn }).catch((historyError) => logger.warn("Failed to persist Codex turn failure", { threadId, turnId, error: historyError })); }); const error = reported || failedTurns.size || finalizing.length ? new CodexReportedError(message) : new Error(message); @@ -522,8 +551,8 @@ export class CodexAppClient { this.currentThreadId = ""; this.currentTurnId = ""; void Promise.all([...finalizing, ...persistence]).then(() => { - failedTurns.forEach(({ threadId, turnId, prompt }) => { - const turn = { id: turnId, status: "failed", error: { message }, ...(prompt ? { input: prompt } : {}) }; + failedTurns.forEach(({ threadId, turnId, prompt, messageText }) => { + const turn = { id: turnId, status: "failed", error: { message }, ...(prompt ? { input: prompt } : {}), ...(messageText ? { messageText } : {}) }; this.emit("agent_event", { agent: "codex", type: "turn.completed", status: "failed", error: { message }, thread_id: threadId, turn_id: turnId, turn }); this.emit("agent_done", { agent: "codex", status: "failed", error: { message }, thread_id: threadId, turn_id: turnId }); }); @@ -584,9 +613,18 @@ function turnSettings(permissionMode: AgentPermissionMode) { }; } -/** 将文本和本地图片转换为 Codex turn 输入。 */ -function codexInput(prompt: string, images: string[]): CodexTurnInput[] { - return [{ type: "text", text: prompt, text_elements: [] }, ...images.map((file) => ({ type: "localImage", path: file }))]; +/** 将文本、本地图片和显式 Skill 转换为 Codex turn 输入。 */ +function codexInput(prompt: string, images: string[], skill?: CodexSkillSelector): CodexTurnInput[] { + const text = skill && !mentionsSkill(prompt, skill.name) ? `$${skill.name} ${prompt}` : prompt; + return [ + { type: "text", text, text_elements: [] }, + ...images.map((file) => ({ type: "localImage", path: file })), + ...(skill ? [{ type: "skill", ...skill } as CodexTurnInput] : []), + ]; +} + +function mentionsSkill(prompt: string, name: string) { + return new RegExp(`\\$${name}(?![A-Za-z0-9_-]|:[A-Za-z0-9_-])`).test(prompt); } /** 将 app-server 通知转换为前端使用的 Agent 事件。 */ diff --git a/canvas-agent/src/agent/codex-history.test.ts b/canvas-agent/src/agent/codex-history.test.ts index 4584463..1fc8dc4 100644 --- a/canvas-agent/src/agent/codex-history.test.ts +++ b/canvas-agent/src/agent/codex-history.test.ts @@ -85,6 +85,30 @@ test("用户消息使用与实时消息一致的 turn 级稳定 ID", () => { assert.equal(messages[0].itemId, "synthetic:user"); }); +test("显式用户正文优先于 Codex 自动补入的 Skill 前缀", () => { + const messages = threadMessages({ id: "thread-1", turns: [{ id: "turn-1", status: "completed", items: [{ + id: "codex-user-1", + type: "userMessage", + content: [ + { type: "text", text: "$product-grid 生成产品图" }, + { type: "skill", name: "product-grid", path: "D:\\site\\.agents\\skills\\product-grid\\SKILL.md" }, + ], + }] }] }, [], { items: [], turns: [{ threadId: "thread-1", turnId: "turn-1", turn: { messageText: "生成产品图" } }] }); + assert.equal(messages[0].text, "生成产品图"); +}); + +test("没有显式用户正文时保留用户自行输入的 Skill 标记和正文", () => { + const messages = threadMessages({ id: "thread-1", turns: [{ id: "turn-1", status: "completed", items: [{ + id: "codex-user-1", + type: "userMessage", + content: [ + { type: "text", text: "$product-grid 生成产品图" }, + { type: "skill", name: "product-grid", path: "D:\\site\\.agents\\skills\\product-grid\\SKILL.md" }, + ], + }] }] }); + assert.equal(messages[0].text, "$product-grid 生成产品图"); +}); + test("Codex 历史省略命令时使用补充事件恢复完整命令卡片", () => { const messages = threadMessages({ id: "thread-1", diff --git a/canvas-agent/src/agent/codex-history.ts b/canvas-agent/src/agent/codex-history.ts index b6ade7b..7e5491d 100644 --- a/canvas-agent/src/agent/codex-history.ts +++ b/canvas-agent/src/agent/codex-history.ts @@ -62,8 +62,9 @@ export function threadMessages(thread: unknown, planUpdates: CodexPlanUpdate[] = let userAdded = false; let planAdded = false; let reasoningAdded = false; - const fallbackUserText = displayUserText(String(field(turn, "input") || "")); - if (fallbackUserText && !items.some((item) => field(item, "type") === "userMessage")) { + const explicitUserText = displayUserText(String(field(turn, "messageText") || "")); + const fallbackUserText = explicitUserText || displayUserText(String(field(turn, "input") || "")); + if (fallbackUserText && (explicitUserText || !items.some((item) => field(item, "type") === "userMessage"))) { push({ id: "synthetic:user", role: "user", text: fallbackUserText }); userAdded = true; if (planMessage) { @@ -458,7 +459,8 @@ function userFacingCodexError(message: string) { /** 提取用户输入条目中的文本与附件占位信息。 */ function userInputText(content: unknown) { - return arrayValue(content) + const items = arrayValue(content); + const text = items .map((item) => { const type = String(field(item, "type") || ""); if (type === "text") return String(field(item, "text") || ""); @@ -468,6 +470,7 @@ function userInputText(content: unknown) { }) .filter(Boolean) .join("\n"); + return text.trim(); } /** 移除用户消息中由旧流程拼接的 Agent 前置提示词。 */ diff --git a/canvas-agent/src/agent/codex-protocol.ts b/canvas-agent/src/agent/codex-protocol.ts index fbd7e84..4bb8019 100644 --- a/canvas-agent/src/agent/codex-protocol.ts +++ b/canvas-agent/src/agent/codex-protocol.ts @@ -17,9 +17,35 @@ export type CodexModel = JsonRecord & { isDefault?: boolean; }; +export type CodexSkillScope = "user" | "repo" | "system" | "admin"; +export type CodexSkillInterface = JsonRecord & { + displayName?: string | null; + shortDescription?: string | null; + iconSmall?: string | null; + iconLarge?: string | null; + iconSmallUrl?: string | null; + iconLargeUrl?: string | null; + brandColor?: string | null; + defaultPrompt?: string | null; +}; +export type CodexSkillMetadata = JsonRecord & { + name: string; + description: string; + shortDescription?: string | null; + interface?: CodexSkillInterface | null; + dependencies?: JsonRecord | null; + path: string; + scope: CodexSkillScope; + enabled: boolean; +}; +export type CodexSkillError = { path: string; message: string }; +export type CodexSkillsListEntry = { cwd: string; skills: CodexSkillMetadata[]; errors: CodexSkillError[] }; +export type CodexSkillSelector = { name: string; path: string }; + export type CodexTurnInput = | { type: "text"; text: string; text_elements: [] } - | { type: "localImage"; path: string }; + | { type: "localImage"; path: string } + | ({ type: "skill" } & CodexSkillSelector); type ThreadOptions = { approvalPolicy: "never" | "on-request"; @@ -67,6 +93,14 @@ type CodexRequestSpec = { params: { limit: number; includeHidden: boolean }; result: { data: CodexModel[]; nextCursor: string | null }; }; + "skills/list": { + params: { cwds: string[]; forceReload?: boolean }; + result: { data: CodexSkillsListEntry[] }; + }; + "skills/config/write": { + params: { path?: string | null; name?: string | null; enabled: boolean }; + result: { effectiveEnabled: boolean }; + }; "turn/start": { params: { threadId: string; input: CodexTurnInput[]; approvalPolicy: "never" | "on-request"; sandboxPolicy: { type: "workspaceWrite"; networkAccess: boolean } | { type: "dangerFullAccess" }; model?: string; effort?: CodexReasoningEffort }; result: { turn: CodexTurn }; @@ -101,6 +135,7 @@ type CodexNotificationSpec = { "item/commandExecution/outputDelta": { threadId: string; turnId: string; itemId: string; delta: string }; "thread/tokenUsage/updated": { threadId: string; turnId: string; tokenUsage: { last: TokenUsageBreakdown } }; "mcpServer/startupStatus/updated": CodexMcpStartupStatus; + "skills/changed": Record; error: { threadId: string; turnId: string; error: CodexTurnError; willRetry: boolean }; }; diff --git a/canvas-agent/src/agent/codex.ts b/canvas-agent/src/agent/codex.ts index 475ac64..0e49425 100644 --- a/canvas-agent/src/agent/codex.ts +++ b/canvas-agent/src/agent/codex.ts @@ -7,10 +7,17 @@ import { errorMessage, field } from "../utils/value.js"; import { CodexAppClient, CodexReportedError } from "./codex-client.js"; import { codexEventHistory } from "./codex-event-history.js"; import { settledTurnIds, summarizeCodexThread, threadMessages } from "./codex-history.js"; -import type { CodexReasoningEffort } from "./codex-protocol.js"; +import type { CodexReasoningEffort, CodexSkillMetadata, CodexSkillSelector, CodexSkillsListEntry } from "./codex-protocol.js"; import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js"; -type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; model?: string; effort?: CodexReasoningEffort; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void }; +type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; model?: string; effort?: CodexReasoningEffort; skill?: CodexSkillSelector; messageText?: string; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void }; + +export class CodexSkillLookupError extends Error { + override name = "CodexSkillLookupError"; + constructor(message: string, readonly statusCode: 400 | 404 | 409) { + super(message); + } +} let codexQueue: Promise = Promise.resolve(); let codexApp: CodexAppClient | null = null; @@ -75,6 +82,31 @@ export async function listCodexModels(emit: AgentEmit) { return await (await getCodexApp(emit)).listModels(); } +/** 查询当前工作空间的原生 Skill 列表。 */ +export async function listCodexSkills(emit: AgentEmit, cwd: string, forceReload = false): Promise { + const result = await (await getCodexApp(emit)).listSkills(cwd, forceReload); + return result.data.find((entry) => samePath(entry.cwd, cwd)) || { cwd, skills: [], errors: [] }; +} + +/** 从原生 Skill 列表中解析并校验浏览器提交的选择器。 */ +export async function resolveCodexSkill(emit: AgentEmit, cwd: string, selector: CodexSkillSelector, requireEnabled = false): Promise { + const name = String(selector?.name || ""); + const requestedPath = String(selector?.path || ""); + if (!name || !requestedPath || !path.isAbsolute(requestedPath)) throw new CodexSkillLookupError("Skill 选择无效", 400); + const { skills } = await listCodexSkills(emit, cwd, true); + const skill = skills.find((item) => item.name === name && samePath(item.path, requestedPath)); + if (!skill) throw new CodexSkillLookupError("找不到指定 Skill,请刷新列表后重试", 404); + if (requireEnabled && !skill.enabled) throw new CodexSkillLookupError("该 Skill 已停用,请先启用后再使用", 409); + return skill; +} + +/** 修改经过原生列表校验的 Skill 启用状态。 */ +export async function configureCodexSkill(emit: AgentEmit, cwd: string, selector: CodexSkillSelector, enabled: boolean) { + const skill = await resolveCodexSkill(emit, cwd, selector); + const result = await (await getCodexApp(emit)).setSkillEnabled(skill.path, enabled); + return { ...result, skill: { ...skill, enabled: result.effectiveEnabled } }; +} + /** 读取指定 Codex 线程及其聊天历史。 */ export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) { const app = await getCodexApp(emit); @@ -113,14 +145,14 @@ async function runCodexTurnNow(prompt: string, lifecycleEmit: AgentEmit, attachm let threadId = await ensureCodexThread(app, options, lifecycleEmit); options.onThread?.(threadId); try { - await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn); + await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn, options.skill, options.messageText); } catch (error) { if (!isRecoverableThreadError(error)) throw error; lifecycleEmit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` }); loadedThreadId = ""; threadId = await ensureCodexThread(app, { cwd: options.cwd }, lifecycleEmit); options.onThread?.(threadId); - await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn); + await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn, options.skill, options.messageText); } } catch (error) { logger.error("Codex turn failed", error); @@ -210,7 +242,13 @@ function assertThreadWorkspace(thread: unknown, cwd?: string) { /** 判断线程工作目录是否与当前工作空间一致。 */ function threadInWorkspace(thread: unknown, cwd: string) { const threadCwd = String(field(thread, "cwd") || ""); - return Boolean(threadCwd && path.resolve(threadCwd) === path.resolve(cwd)); + return Boolean(threadCwd && samePath(threadCwd, cwd)); +} + +/** 比较跨平台绝对路径;Windows 路径不区分大小写。 */ +function samePath(left: string, right: string) { + const normalize = (value: string) => process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value); + return normalize(left) === normalize(right); } /** 将图片附件写入临时文件供 Codex 读取。 */ diff --git a/canvas-agent/src/canvas/session.test.ts b/canvas-agent/src/canvas/session.test.ts index ea5fcb3..b77c85a 100644 --- a/canvas-agent/src/canvas/session.test.ts +++ b/canvas-agent/src/canvas/session.test.ts @@ -202,7 +202,7 @@ test("new clients receive the current Codex state and later updates", (t) => { t.after(() => client.close()); const hello = client.event("hello"); - assert.equal(field(hello, "protocolVersion"), 3); + assert.equal(field(hello, "protocolVersion"), 4); assert.deepEqual(field(hello, "workspace"), { activeThreadId: "thread-2" }); assert.deepEqual(field(hello, "codex"), { busy: true, threadId: "thread-2", turnId: "turn-1" }); assert.deepEqual(field(hello, "pendingApprovals"), [{ requestId: "approval-1", threadId: "thread-2" }]); diff --git a/canvas-agent/src/canvas/session.ts b/canvas-agent/src/canvas/session.ts index 1993994..45d8d98 100644 --- a/canvas-agent/src/canvas/session.ts +++ b/canvas-agent/src/canvas/session.ts @@ -12,7 +12,7 @@ type PendingRequest = { clientId: string; resolve: (value: unknown) => void; rej type TurnAttachment = { clientId: string; id: string; name: string; type: string; size: number; width: number; height: number; dataUrl: string }; type ReplayEvent = { type: string; payload: Record }; export type CodexState = { busy: boolean; threadId: string; turnId: string }; -export const AGENT_PROTOCOL_VERSION = 3; +export const AGENT_PROTOCOL_VERSION = 4; const SITE_TOOLS = new Set([ "site_navigate", diff --git a/canvas-agent/src/server/http.ts b/canvas-agent/src/server/http.ts index e813480..60aa3aa 100644 --- a/canvas-agent/src/server/http.ts +++ b/canvas-agent/src/server/http.ts @@ -4,13 +4,14 @@ import path from "node:path"; import express, { type NextFunction, type Request, type Response } from "express"; import { runClaudeTurn } from "../agent/claude.js"; -import { archiveCodexThread, interruptCodexTurn, listCodexModels, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread } from "../agent/codex.js"; -import type { CodexReasoningEffort } from "../agent/codex-protocol.js"; +import { archiveCodexThread, CodexSkillLookupError, configureCodexSkill, interruptCodexTurn, listCodexModels, listCodexSkills, listCodexThreads, readCodexThread, resolveCodexApproval, resolveCodexSkill, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread } from "../agent/codex.js"; +import type { CodexReasoningEffort, CodexSkillSelector } from "../agent/codex-protocol.js"; import type { AgentAttachment, AgentPermissionMode } from "../agent/types.js"; import { AGENT_PROTOCOL_VERSION, CanvasSession } from "../canvas/session.js"; import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "../config.js"; import { logger } from "../utils/logger.js"; import { checkVersions } from "../version-check.js"; +import { SkillStore, SkillStoreError } from "../skills/store.js"; /** 启动仅监听本机的 Canvas Agent HTTP 服务。 */ export function startHttpServer() { @@ -20,10 +21,15 @@ export function startHttpServer() { saveConfig(config); const session = new CanvasSession(); + const skillStore = new SkillStore(ensureSiteWorkspace(config).workspacePath); /** 将 Agent 事件广播到所属线程或全部网页。 */ const emit = (type: string, payload: unknown) => { - const scope = session.codexBusy ? session.codexEventScope : { threadId: "", turnId: "", sourceClientId: "" }; const value = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record : { value: payload }; + if (type === "skills_changed") { + session.emitAll(type, value); + return; + } + const scope = session.codexBusy ? session.codexEventScope : { threadId: "", turnId: "", sourceClientId: "" }; const threadId = String(value.threadId || value.thread_id || scope.threadId || ensureSiteWorkspace(config).activeThreadId || ""); const turnId = String(value.turnId || value.turn_id || scope.turnId || ""); const sourceClientId = String(value.sourceClientId || scope.sourceClientId || ""); @@ -129,6 +135,38 @@ export function startHttpServer() { res.json({ ok: true, workspace }); }); app.get("/agent/codex/models", route(async (_req, res) => res.json({ ok: true, ...(await listCodexModels(emit)) }))); + app.get("/agent/codex/skills", route(async (req, res) => { + const workspace = ensureSiteWorkspace(config); + const result = await listCodexSkills(emit, workspace.workspacePath, String(req.query.forceReload || "") === "1"); + res.json({ ok: true, data: result.skills.map((skill) => ({ ...skill, managed: skillStore.isManagedPath(skill.path) })), errors: result.errors }); + })); + app.get("/agent/codex/skills/:name", route(async (req, res) => { + res.json({ ok: true, data: await skillStore.get(routeParam(req.params.name)) }); + })); + app.post("/agent/codex/skills", route(async (req, res) => { + const data = await skillStore.create(req.body); + session.emitAll("skills_changed", { forceReload: true }); + res.status(201).json({ ok: true, data }); + })); + app.post("/agent/codex/skills/:name/enabled", route(async (req, res) => { + if (typeof req.body?.enabled !== "boolean") return res.status(400).json({ ok: false, error: "Skill 启用状态无效" }); + const workspace = ensureSiteWorkspace(config); + const selector = skillSelector(req.body); + if (selector.name !== routeParam(req.params.name)) return res.status(400).json({ ok: false, error: "Skill 选择无效" }); + const data = await configureCodexSkill(emit, workspace.workspacePath, selector, req.body.enabled); + session.emitAll("skills_changed", { forceReload: true }); + res.json({ ok: true, data }); + })); + app.post("/agent/codex/skills/:name/delete", route(async (req, res) => { + await skillStore.delete(routeParam(req.params.name), String(req.body?.expectedRevision || "")); + session.emitAll("skills_changed", { forceReload: true }); + res.json({ ok: true }); + })); + app.post("/agent/codex/skills/:name", route(async (req, res) => { + const data = await skillStore.update(routeParam(req.params.name), req.body); + session.emitAll("skills_changed", { forceReload: true }); + res.json({ ok: true, data }); + })); app.get("/agent/codex/threads", route(async (req, res) => { const workspace = ensureSiteWorkspace(config); const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") }); @@ -184,6 +222,7 @@ export function startHttpServer() { if (requestedThreadId !== activeThreadId) return res.status(409).json({ ok: false, error: "当前会话已在其他页面切换,请同步后重试" }); const model = String(req.body?.model || "") || undefined; const effort = reasoningEffort(req.body?.effort); + const skill = req.body?.skill === undefined ? undefined : await resolveCodexSkill(emit, workspace.workspacePath, skillSelector(req.body.skill), true); const messageId = String(req.body?.messageId || Date.now()); const messageText = String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`); let threadId = activeThreadId; @@ -224,6 +263,8 @@ export function startHttpServer() { permissionMode: permissionMode(req.body?.permissionMode), model, effort, + ...(skill ? { skill: { name: skill.name, path: skill.path } } : {}), + messageText, appEmit: emit, onStart: () => session.bindClient(clientId), onThread: (actualThreadId) => { @@ -293,6 +334,7 @@ export function startHttpServer() { app.use((_req, res) => res.status(404).json({ ok: false, error: "not found" })); app.use((error: Error, req: Request, res: Response, _next: NextFunction) => { logger.error("HTTP request failed", { method: req.method, path: req.path, error }); + if (error instanceof SkillStoreError || error instanceof CodexSkillLookupError) return void res.status(error.statusCode).json({ ok: false, error: error.message }); res.status(500).json({ ok: false, error: error.message }); }); @@ -327,6 +369,15 @@ function reasoningEffort(value: unknown): CodexReasoningEffort | undefined { return value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max" || value === "ultra" ? value : undefined; } +/** 读取浏览器提交的 Skill 选择器;真实路径随后必须通过原生列表校验。 */ +function skillSelector(value: unknown): CodexSkillSelector { + const selector = value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; + const name = typeof selector.name === "string" ? selector.name : ""; + const skillPath = typeof selector.path === "string" ? selector.path : ""; + if (!name || !skillPath) throw new CodexSkillLookupError("Skill 选择无效", 400); + return { name, path: skillPath }; +} + /** 使用当前操作系统的文件管理器定位本地文件。 */ function revealLocalFile(filePath: string, isDirectory: boolean) { const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open"; diff --git a/canvas-agent/src/skills/store.test.ts b/canvas-agent/src/skills/store.test.ts new file mode 100644 index 0000000..ce45bbd --- /dev/null +++ b/canvas-agent/src/skills/store.test.ts @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { SkillStore, SkillStoreError } from "./store.js"; + +test("创建、读取和更新画布专属 Skill", async (context) => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-")); + context.after(() => fs.rm(workspace, { recursive: true, force: true })); + const store = new SkillStore(workspace); + + const created = await store.create({ + name: "product-grid", + description: "生成商品九宫格", + instructions: "根据商品信息创建九宫格生成流程。", + interface: { + displayName: "产品九宫格生成", + shortDescription: "根据商品资料与参考图自动规划并生成产品图片九宫格流程", + defaultPrompt: "Use $product-grid to build a product image grid.", + }, + }); + assert.equal(created.managed, true); + assert.equal(created.interface?.displayName, "产品九宫格生成"); + assert.equal(created.revision.length, 64); + assert.equal(store.isManagedPath(created.path), true); + const openAi = await fs.readFile(path.join(path.dirname(created.path), "agents", "openai.yaml"), "utf8"); + assert.match(openAi, /^interface:/m); + assert.match(openAi, /display_name: "产品九宫格生成"/); + + const updated = await store.update("product-grid", { + description: "生成商品图片九宫格", + instructions: "先分析商品,再创建九宫格生成流程。", + interface: { displayName: "产品九宫格生成" }, + expectedRevision: created.revision, + }); + assert.equal(updated.description, "生成商品图片九宫格"); + assert.notEqual(updated.revision, created.revision); + assert.deepEqual(updated.interface, { displayName: "产品九宫格生成" }); +}); + +test("revision 不匹配时拒绝覆盖或删除", async (context) => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-")); + context.after(() => fs.rm(workspace, { recursive: true, force: true })); + const store = new SkillStore(workspace); + const created = await store.create({ name: "demo", description: "演示 Skill", instructions: "执行演示流程。" }); + + await assert.rejects(store.update("demo", { + description: "已过期的修改", + instructions: "不会写入。", + expectedRevision: "0".repeat(64), + }), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 409); + await assert.rejects(store.delete("demo", "0".repeat(64)), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 409); + assert.equal((await store.get("demo")).revision, created.revision); +}); + +test("清空界面字段时保留未由画布管理的 openai 元数据", async (context) => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-")); + context.after(() => fs.rm(workspace, { recursive: true, force: true })); + const store = new SkillStore(workspace); + const created = await store.create({ + name: "metadata-demo", + description: "演示界面元数据", + instructions: "执行演示流程。", + interface: { displayName: "演示", defaultPrompt: "Use $metadata-demo to run the demo." }, + }); + const openAiFile = path.join(path.dirname(created.path), "agents", "openai.yaml"); + await fs.writeFile(openAiFile, "interface:\n display_name: 演示\n default_prompt: Use $metadata-demo to run the demo.\n icon_small: ./assets/icon.png\n brand_color: '#336699'\n", "utf8"); + const current = await store.get("metadata-demo"); + + const updated = await store.update("metadata-demo", { + description: current.description, + instructions: current.instructions, + interface: null, + expectedRevision: current.revision, + }); + const openAi = await fs.readFile(openAiFile, "utf8"); + assert.equal(updated.interface, undefined); + assert.match(openAi, /icon_small:/); + assert.match(openAi, /brand_color:/); + assert.doesNotMatch(openAi, /display_name:|default_prompt:/); +}); + +test("名称、正文大小和默认提示词均经过校验", async (context) => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-")); + context.after(() => fs.rm(workspace, { recursive: true, force: true })); + const store = new SkillStore(workspace); + + await assert.rejects(store.create({ name: "Invalid_Name", description: "无效", instructions: "无效" }), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 400); + await assert.rejects(store.create({ name: "invalid-description", description: "包含 <标签>", instructions: "无效" }), /尖括号/); + await assert.rejects(store.create({ name: "too-large", description: "过大", instructions: "中".repeat(90_000) }), /256KiB/); + await assert.rejects(store.create({ + name: "prompt-check", + description: "校验默认提示词", + instructions: "执行流程。", + interface: { defaultPrompt: "Use this skill." }, + }), /\$prompt-check/); + await assert.rejects(store.create({ + name: "prompt-check", + description: "校验默认提示词边界", + instructions: "执行流程。", + interface: { defaultPrompt: "Use $prompt-check-extra instead." }, + }), /\$prompt-check/); + await assert.rejects(store.create({ + name: "prompt-check", + description: "校验命名空间提示词边界", + instructions: "执行流程。", + interface: { defaultPrompt: "Use $prompt-check:other instead." }, + }), /\$prompt-check/); + await assert.rejects(store.create({ + name: "short-description-check", + description: "校验卡片短说明长度", + instructions: "执行流程。", + interface: { shortDescription: "过短" }, + }), /不能少于 25 个字符/); + await assert.rejects(store.create({ + name: "prompt-check", + description: "校验默认提示词大小写", + instructions: "执行流程。", + interface: { defaultPrompt: "Use $PROMPT-CHECK instead." }, + }), /\$prompt-check/); + await assert.rejects(store.create({ + name: "invalid-interface", + description: "校验界面元数据", + instructions: "执行流程。", + interface: false as never, + }), /界面元数据无效/); +}); + +test("更新校验失败时不会提前改写 SKILL.md", async (context) => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-")); + context.after(() => fs.rm(workspace, { recursive: true, force: true })); + const store = new SkillStore(workspace); + const created = await store.create({ name: "safe-update", description: "保留原内容", instructions: "执行原流程。" }); + + await assert.rejects(store.update("safe-update", { + description: "不应写入", + instructions: "不应写入。", + interface: { defaultPrompt: "缺少 Skill 名称" }, + expectedRevision: created.revision, + }), /\$safe-update/); + assert.deepEqual(await store.get("safe-update"), created); +}); + +test("拒绝读取非对象格式的 openai.yaml", async (context) => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-")); + context.after(() => fs.rm(workspace, { recursive: true, force: true })); + const store = new SkillStore(workspace); + const created = await store.create({ name: "invalid-metadata", description: "校验元数据", instructions: "执行流程。" }); + const agentsDir = path.join(path.dirname(created.path), "agents"); + await fs.mkdir(agentsDir); + await fs.writeFile(path.join(agentsDir, "openai.yaml"), "- invalid\n", "utf8"); + + await assert.rejects(store.get("invalid-metadata"), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 409); +}); + +test("不完整更新请求返回校验错误而不是运行时异常", async (context) => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-")); + context.after(() => fs.rm(workspace, { recursive: true, force: true })); + const store = new SkillStore(workspace); + + await assert.rejects(store.update("demo", undefined as never), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 400); +}); + +test("空 interface 元数据读取为空", async (context) => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-")); + context.after(() => fs.rm(workspace, { recursive: true, force: true })); + const store = new SkillStore(workspace); + const created = await store.create({ name: "empty-interface", description: "空界面元数据", instructions: "执行流程。" }); + const agentsDir = path.join(path.dirname(created.path), "agents"); + await fs.mkdir(agentsDir); + await fs.writeFile(path.join(agentsDir, "openai.yaml"), "interface: {}\n", "utf8"); + + assert.equal((await store.get("empty-interface")).interface, undefined); +}); + +test("读取或修改不存在的 Skill 不会创建目录", async (context) => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-")); + context.after(() => fs.rm(workspace, { recursive: true, force: true })); + const store = new SkillStore(workspace); + const skillsRoot = path.join(workspace, ".agents", "skills"); + + await assert.rejects(store.get("missing"), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 404); + await assert.rejects(store.update("missing", { + description: "不存在", + instructions: "不会写入。", + expectedRevision: "0".repeat(64), + }), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 404); + await assert.rejects(store.delete("missing", "0".repeat(64)), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 404); + await assert.rejects(fs.access(skillsRoot)); +}); + +test("更新后只保留完整文件且不遗留临时文件", async (context) => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-")); + context.after(() => fs.rm(workspace, { recursive: true, force: true })); + const store = new SkillStore(workspace); + const created = await store.create({ + name: "atomic-update", + description: "验证原子更新", + instructions: "执行旧流程。", + interface: { displayName: "原子更新" }, + }); + + const updated = await store.update("atomic-update", { + description: "验证完整更新", + instructions: "执行新流程。", + interface: { displayName: "完整更新" }, + expectedRevision: created.revision, + }); + const skillDirectory = path.dirname(updated.path); + const skillText = await fs.readFile(updated.path, "utf8"); + const openAiText = await fs.readFile(path.join(skillDirectory, "agents", "openai.yaml"), "utf8"); + assert.match(skillText, /description: 验证完整更新/); + assert.match(skillText, /执行新流程/); + assert.match(openAiText, /display_name: "完整更新"/); + assert.deepEqual((await fs.readdir(skillDirectory)).filter((name) => name.endsWith(".tmp")), []); + assert.deepEqual((await fs.readdir(path.join(skillDirectory, "agents"))).filter((name) => name.endsWith(".tmp")), []); +}); + +test("拒绝读取非对象格式的 interface 元数据", async (context) => { + const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-skill-store-")); + context.after(() => fs.rm(workspace, { recursive: true, force: true })); + const store = new SkillStore(workspace); + const created = await store.create({ name: "invalid-interface", description: "校验界面元数据", instructions: "执行流程。" }); + const agentsDir = path.join(path.dirname(created.path), "agents"); + await fs.mkdir(agentsDir); + await fs.writeFile(path.join(agentsDir, "openai.yaml"), "interface: false\n", "utf8"); + + await assert.rejects(store.get("invalid-interface"), (error: unknown) => error instanceof SkillStoreError && error.statusCode === 409); +}); diff --git a/canvas-agent/src/skills/store.ts b/canvas-agent/src/skills/store.ts new file mode 100644 index 0000000..2310f67 --- /dev/null +++ b/canvas-agent/src/skills/store.ts @@ -0,0 +1,439 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import matter from "gray-matter"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; + +const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const MAX_NAME_LENGTH = 64; +const MAX_DESCRIPTION_LENGTH = 1024; +const MAX_INSTRUCTIONS_BYTES = 256 * 1024; +const MAX_DISPLAY_NAME_LENGTH = 64; +const MIN_SHORT_DESCRIPTION_LENGTH = 25; +const MAX_SHORT_DESCRIPTION_LENGTH = 64; +const MAX_DEFAULT_PROMPT_LENGTH = 1024; + +export type ManagedSkillInterface = { + displayName?: string; + shortDescription?: string; + defaultPrompt?: string; +}; + +export type ManagedSkillDetail = { + name: string; + description: string; + instructions: string; + interface?: ManagedSkillInterface; + path: string; + revision: string; + managed: true; +}; + +export type CreateManagedSkillInput = { + name: string; + description: string; + instructions: string; + interface?: ManagedSkillInterface | null; +}; + +export type UpdateManagedSkillInput = { + description: string; + instructions: string; + interface?: ManagedSkillInterface | null; + expectedRevision: string; +}; + +type SkillDocument = { + raw: string; + frontmatter: Record; + description: string; + instructions: string; +}; + +type OpenAiDocument = { raw: string; data: Record; interface?: ManagedSkillInterface }; + +export class SkillStoreError extends Error { + override name = "SkillStoreError"; + constructor(message: string, readonly statusCode: 400 | 404 | 409 | 500) { + super(message); + } +} + +/** 只管理站点工作空间下 `.agents/skills` 中的画布专属 Skill。 */ +export class SkillStore { + readonly workspacePath: string; + readonly skillsPath: string; + private writeQueue: Promise = Promise.resolve(); + + constructor(workspacePath: string) { + this.workspacePath = path.resolve(workspacePath); + this.skillsPath = path.join(this.workspacePath, ".agents", "skills"); + } + + /** 判断 Codex 返回的绝对路径是否属于本 Store 的标准 Skill 入口。 */ + isManagedPath(filePath: string) { + if (!path.isAbsolute(filePath)) return false; + const relative = path.relative(this.skillsPath, path.resolve(filePath)); + const segments = relative.split(path.sep); + return segments.length === 2 && validName(segments[0]) && segments[1].toLowerCase() === "skill.md"; + } + + /** 读取一个可编辑 Skill 的正文与界面元数据。 */ + async get(name: string): Promise { + try { + await this.writeQueue.catch(() => undefined); + const paths = await this.safeExistingPaths(name); + return await this.readDetail(name, paths.skillFile, paths.openAiFile); + } catch (error) { + throw storeError(error, "读取 Skill 失败"); + } + } + + /** 创建新的画布专属 Skill。 */ + create(input: CreateManagedSkillInput) { + return this.mutate(async () => { + const name = skillName(input?.name); + const description = skillDescription(input?.description); + const instructions = skillInstructions(input?.instructions); + const skillInterface = skillInterfaceValue(input?.interface, name); + await this.ensureRoot(); + const skillDir = path.join(this.skillsPath, name); + const existing = await lstatOptional(skillDir); + if (existing) { + if (existing.isSymbolicLink()) throw new SkillStoreError("Skill 目录不能是符号链接或目录联接", 400); + throw new SkillStoreError("同名 Skill 已存在", 409); + } + await fs.mkdir(skillDir); + try { + const skillFile = path.join(skillDir, "SKILL.md"); + await writeFileAtomic(skillFile, serializeSkill({ name, description }, instructions)); + const openAiFile = path.join(skillDir, "agents", "openai.yaml"); + if (skillInterface) await this.writeOpenAi(openAiFile, {}, skillInterface); + return await this.readDetail(name, skillFile, openAiFile); + } catch (error) { + await fs.rm(skillDir, { recursive: true, force: true }).catch(() => undefined); + throw error; + } + }); + } + + /** 通过 revision 防止覆盖已被其他窗口或外部编辑器修改的内容。 */ + update(nameValue: string, input: UpdateManagedSkillInput) { + return this.mutate(async () => { + const name = skillName(nameValue); + const description = skillDescription(input?.description); + const instructions = skillInstructions(input?.instructions); + const expectedRevision = expectedRevisionValue(input?.expectedRevision); + const interfaceInput = input?.interface; + const skillInterface = interfaceInput === undefined ? undefined : skillInterfaceValue(interfaceInput, name); + const paths = await this.safeExistingPaths(name); + const currentSkill = await readSkill(paths.skillFile, name); + const currentOpenAi = await readOpenAi(paths.openAiFile, name); + assertRevision(expectedRevision, revision(currentSkill.raw, currentOpenAi.raw)); + const frontmatter = { ...currentSkill.frontmatter, name, description }; + await writeFileAtomic(paths.skillFile, serializeSkill(frontmatter, instructions)); + if (interfaceInput !== undefined) { + try { + await this.writeOpenAi(paths.openAiFile, currentOpenAi.data, skillInterface); + } catch (error) { + if (!await restoreSkillFiles(paths.skillFile, currentSkill.raw, paths.openAiFile, currentOpenAi.raw)) { + throw new SkillStoreError("Skill 更新失败且无法完全恢复,请检查本地文件", 500); + } + throw error; + } + } + return await this.readDetail(name, paths.skillFile, paths.openAiFile); + }); + } + + /** 删除 revision 仍匹配的画布专属 Skill。 */ + delete(nameValue: string, expectedRevisionValueInput: string) { + return this.mutate(async () => { + const name = skillName(nameValue); + const expectedRevision = expectedRevisionValue(expectedRevisionValueInput); + const paths = await this.safeExistingPaths(name); + const currentSkill = await readSkill(paths.skillFile, name); + const currentOpenAi = await readOpenAi(paths.openAiFile, name); + assertRevision(expectedRevision, revision(currentSkill.raw, currentOpenAi.raw)); + await assertTreeHasNoLinks(paths.skillDir); + const realRoot = await fs.realpath(this.skillsPath); + const realSkill = await fs.realpath(paths.skillDir); + if (!inside(realRoot, realSkill)) throw new SkillStoreError("Skill 路径不安全", 400); + await fs.rm(realSkill, { recursive: true }); + }); + } + + private mutate(operation: () => Promise): Promise { + const result = this.writeQueue.catch(() => undefined).then(operation).catch((error) => { throw storeError(error, "修改 Skill 失败"); }); + this.writeQueue = result.catch(() => undefined); + return result; + } + + private async ensureRoot() { + const workspace = await lstatOptional(this.workspacePath); + if (!workspace) throw new SkillStoreError("站点工作空间不存在", 409); + if (workspace.isSymbolicLink()) throw new SkillStoreError("站点工作空间不能是符号链接或目录联接", 400); + if (!workspace.isDirectory()) throw new SkillStoreError("站点工作空间不是目录", 409); + const agentsPath = path.join(this.workspacePath, ".agents"); + await ensurePlainDirectory(agentsPath); + await ensurePlainDirectory(this.skillsPath); + const realWorkspace = await fs.realpath(this.workspacePath); + const realRoot = await fs.realpath(this.skillsPath); + if (!inside(realWorkspace, realRoot)) throw new SkillStoreError("Skill 根目录不安全", 400); + } + + private async safeExistingPaths(nameValue: string) { + const name = skillName(nameValue); + await this.assertExistingRoot(); + const skillDir = path.join(this.skillsPath, name); + const directory = await lstatOptional(skillDir); + if (!directory) throw new SkillStoreError("找不到指定 Skill", 404); + if (directory.isSymbolicLink() || !directory.isDirectory()) throw new SkillStoreError("Skill 目录不安全", 400); + const realRoot = await fs.realpath(this.skillsPath); + const realSkill = await fs.realpath(skillDir); + if (!inside(realRoot, realSkill)) throw new SkillStoreError("Skill 路径不安全", 400); + const skillFile = path.join(skillDir, "SKILL.md"); + const skillEntry = await lstatOptional(skillFile); + if (!skillEntry) throw new SkillStoreError("Skill 缺少 SKILL.md", 404); + if (skillEntry.isSymbolicLink() || !skillEntry.isFile()) throw new SkillStoreError("SKILL.md 路径不安全", 400); + const agentsDir = path.join(skillDir, "agents"); + const agentsEntry = await lstatOptional(agentsDir); + if (agentsEntry && (agentsEntry.isSymbolicLink() || !agentsEntry.isDirectory())) throw new SkillStoreError("Skill agents 目录不安全", 400); + const openAiFile = path.join(agentsDir, "openai.yaml"); + const openAiEntry = await lstatOptional(openAiFile); + if (openAiEntry && (openAiEntry.isSymbolicLink() || !openAiEntry.isFile())) throw new SkillStoreError("openai.yaml 路径不安全", 400); + return { skillDir, skillFile, openAiFile }; + } + + private async assertExistingRoot() { + const workspace = await lstatOptional(this.workspacePath); + if (!workspace) throw new SkillStoreError("找不到指定 Skill", 404); + if (workspace.isSymbolicLink()) throw new SkillStoreError("站点工作空间不能是符号链接或目录联接", 400); + if (!workspace.isDirectory()) throw new SkillStoreError("找不到指定 Skill", 404); + const agentsPath = path.join(this.workspacePath, ".agents"); + const agents = await lstatOptional(agentsPath); + const root = await lstatOptional(this.skillsPath); + if (!agents || !root) throw new SkillStoreError("找不到指定 Skill", 404); + if (agents.isSymbolicLink() || !agents.isDirectory() || root.isSymbolicLink() || !root.isDirectory()) throw new SkillStoreError("Skill 路径中存在符号链接或目录联接", 400); + const realWorkspace = await fs.realpath(this.workspacePath); + const realRoot = await fs.realpath(this.skillsPath); + if (!inside(realWorkspace, realRoot)) throw new SkillStoreError("Skill 根目录不安全", 400); + } + + private async readDetail(name: string, skillFile: string, openAiFile: string): Promise { + const skill = await readSkill(skillFile, name); + const openAi = await readOpenAi(openAiFile, name); + return { + name, + description: skill.description, + instructions: skill.instructions, + ...(openAi.interface ? { interface: openAi.interface } : {}), + path: skillFile, + revision: revision(skill.raw, openAi.raw), + managed: true, + }; + } + + private async writeOpenAi(filePath: string, current: Record, skillInterface?: ManagedSkillInterface) { + const agentsDir = path.dirname(filePath); + const existing = recordValue(current.interface); + delete existing.display_name; + delete existing.short_description; + delete existing.default_prompt; + const interfaceYaml = { + ...existing, + ...(skillInterface?.displayName ? { display_name: skillInterface.displayName } : {}), + ...(skillInterface?.shortDescription ? { short_description: skillInterface.shortDescription } : {}), + ...(skillInterface?.defaultPrompt ? { default_prompt: skillInterface.defaultPrompt } : {}), + }; + const next = { ...current }; + if (Object.keys(interfaceYaml).length) next.interface = interfaceYaml; + else delete next.interface; + if (Object.keys(next).length) { + await ensurePlainDirectory(agentsDir); + await writeFileAtomic(filePath, stringifyYaml(next, { defaultKeyType: "PLAIN", defaultStringType: "QUOTE_DOUBLE" })); + } else { + await fs.unlink(filePath).catch((error) => { + if (nodeErrorCode(error) !== "ENOENT") throw error; + }); + } + } +} + +function validName(value: string | undefined): value is string { + return Boolean(value && value.length <= MAX_NAME_LENGTH && NAME_PATTERN.test(value)); +} + +function skillName(value: unknown) { + const name = typeof value === "string" ? value : ""; + if (!validName(name)) throw new SkillStoreError("Skill 名称只能包含小写字母、数字和连字符", 400); + return name; +} + +function skillDescription(value: unknown) { + const description = typeof value === "string" ? value.trim() : ""; + if (!description) throw new SkillStoreError("请输入 Skill 描述", 400); + if (description.length > MAX_DESCRIPTION_LENGTH) throw new SkillStoreError("Skill 描述过长", 400); + if (description.includes("<") || description.includes(">")) throw new SkillStoreError("Skill 描述不能包含尖括号", 400); + return description; +} + +function skillInstructions(value: unknown) { + const instructions = typeof value === "string" ? value.trim() : ""; + if (!instructions) throw new SkillStoreError("请输入 Skill 正文", 400); + if (Buffer.byteLength(instructions, "utf8") > MAX_INSTRUCTIONS_BYTES) throw new SkillStoreError("Skill 正文不能超过 256KiB", 400); + return instructions; +} + +function skillInterfaceValue(value: unknown, name: string): ManagedSkillInterface | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "object" || Array.isArray(value)) throw new SkillStoreError("Skill 界面元数据无效", 400); + const interfaceValue = value as Record; + const displayName = optionalText(interfaceValue.displayName, "显示名称", MAX_DISPLAY_NAME_LENGTH); + const shortDescription = optionalText(interfaceValue.shortDescription, "简短描述", MAX_SHORT_DESCRIPTION_LENGTH); + const defaultPrompt = optionalText(interfaceValue.defaultPrompt, "默认提示词", MAX_DEFAULT_PROMPT_LENGTH); + if (shortDescription && shortDescription.length < MIN_SHORT_DESCRIPTION_LENGTH) throw new SkillStoreError(`简短描述不能少于 ${MIN_SHORT_DESCRIPTION_LENGTH} 个字符`, 400); + if (defaultPrompt && !new RegExp(`\\$${name}(?![A-Za-z0-9_-]|:[A-Za-z0-9_-])`).test(defaultPrompt)) throw new SkillStoreError(`默认提示词必须包含 $${name}`, 400); + return displayName || shortDescription || defaultPrompt ? { ...(displayName ? { displayName } : {}), ...(shortDescription ? { shortDescription } : {}), ...(defaultPrompt ? { defaultPrompt } : {}) } : undefined; +} + +function optionalText(value: unknown, label: string, maxLength: number) { + if (value === undefined || value === null || value === "") return undefined; + if (typeof value !== "string") throw new SkillStoreError(`${label}格式无效`, 400); + const text = value.trim(); + if (text.length > maxLength) throw new SkillStoreError(`${label}过长`, 400); + return text || undefined; +} + +function expectedRevisionValue(value: unknown) { + const expected = typeof value === "string" ? value : ""; + if (!/^[a-f0-9]{64}$/.test(expected)) throw new SkillStoreError("Skill revision 无效,请重新加载后再试", 400); + return expected; +} + +function assertRevision(expected: string, current: string) { + if (expected !== current) throw new SkillStoreError("Skill 已被其他窗口或外部编辑器修改,请重新加载后再试", 409); +} + +function serializeSkill(frontmatter: Record, instructions: string) { + return matter.stringify(`${instructions.trim()}\n`, frontmatter); +} + +async function readSkill(filePath: string, expectedName: string): Promise { + const raw = await fs.readFile(filePath, "utf8"); + let parsed: matter.GrayMatterFile; + try { + parsed = matter(raw); + } catch { + throw new SkillStoreError("SKILL.md frontmatter 格式无效", 409); + } + const frontmatter = recordValue(parsed.data); + if (frontmatter.name !== expectedName) throw new SkillStoreError("SKILL.md 中的名称与目录不一致", 409); + return { + raw, + frontmatter, + description: skillDescription(frontmatter.description), + instructions: skillInstructions(parsed.content), + }; +} + +async function readOpenAi(filePath: string, expectedName: string): Promise { + const entry = await lstatOptional(filePath); + if (!entry) return { raw: "", data: {} }; + const raw = await fs.readFile(filePath, "utf8"); + let data: Record; + try { + const parsed = parseYaml(raw); + if (parsed !== null && parsed !== undefined && (typeof parsed !== "object" || Array.isArray(parsed))) throw new Error("invalid document"); + data = recordValue(parsed); + } catch { + throw new SkillStoreError("agents/openai.yaml 格式无效", 409); + } + if (data.interface !== undefined && data.interface !== null && (typeof data.interface !== "object" || Array.isArray(data.interface))) { + throw new SkillStoreError("agents/openai.yaml interface 格式无效", 409); + } + const value = data.interface as Record | null | undefined; + const skillInterface = skillInterfaceValue({ displayName: value?.display_name, shortDescription: value?.short_description, defaultPrompt: value?.default_prompt }, expectedName); + return { raw, data, ...(skillInterface ? { interface: skillInterface } : {}) }; +} + +async function restoreSkillFiles(skillFile: string, skillRaw: string, openAiFile: string, openAiRaw: string) { + try { + await writeFileAtomic(skillFile, skillRaw); + if (openAiRaw) { + await ensurePlainDirectory(path.dirname(openAiFile)); + await writeFileAtomic(openAiFile, openAiRaw); + } else { + await fs.unlink(openAiFile).catch((error) => { + if (nodeErrorCode(error) !== "ENOENT") throw error; + }); + } + return true; + } catch { + return false; + } +} + +async function writeFileAtomic(filePath: string, content: string) { + const temporary = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`); + try { + await fs.writeFile(temporary, content, { encoding: "utf8", flag: "wx" }); + await fs.rename(temporary, filePath); + } finally { + await fs.unlink(temporary).catch((error) => { + if (nodeErrorCode(error) !== "ENOENT") throw error; + }); + } +} + +function revision(skillRaw: string, openAiRaw: string) { + return crypto.createHash("sha256").update(skillRaw).update("\0").update(openAiRaw).digest("hex"); +} + +async function ensurePlainDirectory(directory: string) { + const entry = await lstatOptional(directory); + if (!entry) { + await fs.mkdir(directory); + return; + } + if (entry.isSymbolicLink() || !entry.isDirectory()) throw new SkillStoreError("Skill 路径中存在符号链接或目录联接", 400); +} + +async function assertTreeHasNoLinks(directory: string): Promise { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + const metadata = await fs.lstat(entryPath); + if (metadata.isSymbolicLink()) throw new SkillStoreError("Skill 目录中存在符号链接或目录联接,无法删除", 400); + if (metadata.isDirectory()) await assertTreeHasNoLinks(entryPath); + } +} + +function inside(parent: string, child: string) { + const relative = path.relative(parent, child); + return Boolean(relative && !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +} + +async function lstatOptional(filePath: string) { + try { + return await fs.lstat(filePath); + } catch (error) { + if (nodeErrorCode(error) === "ENOENT") return undefined; + throw error; + } +} + +function recordValue(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as Record) } : {}; +} + +function nodeErrorCode(error: unknown) { + return error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code || "") : ""; +} + +function storeError(error: unknown, fallback: string) { + if (error instanceof SkillStoreError) return error; + if (nodeErrorCode(error) === "ENOENT") return new SkillStoreError("找不到指定 Skill", 404); + if (["EEXIST", "ENOTEMPTY", "EPERM", "EACCES"].includes(nodeErrorCode(error))) return new SkillStoreError("Skill 文件当前无法修改", 409); + return new SkillStoreError(fallback, 500); +}