feat(agent): generate Skill drafts from conversations and canvases

This commit is contained in:
yu
2026-08-05 02:45:59 +08:00
parent 08deda5b3f
commit f01cb889ef
7 changed files with 948 additions and 29 deletions
+223 -1
View File
@@ -1,9 +1,11 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { z } from "zod";
import type { CanvasSnapshot } from "../canvas/types.js";
import { logger } from "../utils/logger.js";
import { errorMessage, field } from "../utils/value.js";
import { errorMessage, field, type JsonRecord } 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";
@@ -11,6 +13,35 @@ import type { CodexReasoningEffort, CodexSkillMetadata, CodexSkillSelector, Code
import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js";
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 };
type CodexSkillDraftInput = { model?: string; effort?: CodexReasoningEffort } & ({ source: "conversation"; threadId: string } | { source: "canvas"; snapshot: CanvasSnapshot });
const skillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const skillDraftSchema = z.object({
name: z.string().trim().min(1).max(64).regex(skillNamePattern),
displayName: z.string().trim().max(64),
description: z.string().trim().min(1).max(1024).refine((value) => !/[<>]/.test(value)),
instructions: z.string().trim().min(1).max(20000),
shortDescription: z.string().trim().max(64).refine((value) => !value || value.length >= 25),
defaultPrompt: z.string().trim().max(1024),
}).strict().superRefine((draft, context) => {
if (draft.defaultPrompt && !mentionsSkill(draft.defaultPrompt, draft.name)) context.addIssue({ code: "custom", path: ["defaultPrompt"], message: `默认提示词必须包含 $${draft.name}` });
});
const SKILL_DRAFT_OUTPUT_SCHEMA: JsonRecord = {
type: "object",
additionalProperties: false,
required: ["name", "displayName", "description", "instructions", "shortDescription", "defaultPrompt"],
properties: {
name: { type: "string", pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$", minLength: 1, maxLength: 64 },
displayName: { type: "string", maxLength: 64 },
description: { type: "string", pattern: "^[^<>]+$", minLength: 1, maxLength: 1024 },
instructions: { type: "string", minLength: 1, maxLength: 20000 },
shortDescription: { anyOf: [{ type: "string", maxLength: 0 }, { type: "string", minLength: 25, maxLength: 64 }] },
defaultPrompt: { type: "string", maxLength: 1024 },
},
};
export type AgentSkillDraft = z.infer<typeof skillDraftSchema>;
export class CodexSkillLookupError extends Error {
override name = "CodexSkillLookupError";
@@ -34,6 +65,13 @@ export async function runCodexTurn(prompt: string, lifecycleEmit: AgentEmit, att
await codexQueue;
}
/** 从当前对话或指定网页画布生成可编辑草稿,不写入 Skill 文件。 */
export async function generateCodexSkillDraft(emit: AgentEmit, cwd: string, input: CodexSkillDraftInput): Promise<AgentSkillDraft> {
const queued = codexQueue.catch(() => undefined).then(() => generateCodexSkillDraftNow(emit, cwd, input));
codexQueue = queued;
return await queued;
}
/** 中断当前线程正在执行的 Codex turn。 */
export async function interruptCodexTurn(threadId?: string) {
if (!codexApp) return false;
@@ -192,6 +230,190 @@ async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string |
return thread;
}
async function generateCodexSkillDraftNow(emit: AgentEmit, cwd: string, input: CodexSkillDraftInput) {
const app = await getCodexApp(emit);
let threadId = "";
try {
const thread = input.source === "conversation" ? await app.forkSkillDraftThread(input.threadId, cwd) : await app.startSkillDraftThread(cwd);
threadId = String(field(thread, "id") || "");
const raw = await app.generateSkillDraft(threadId, skillDraftPrompt(input), SKILL_DRAFT_OUTPUT_SCHEMA, input.model, input.effort);
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
throw new Error("Codex 返回的 Skill 草稿不是有效 JSON");
}
const parsed = skillDraftSchema.safeParse(value);
if (!parsed.success) throw new Error("Codex 返回的 Skill 草稿格式不正确");
assertDraftHasNoSensitiveValues(parsed.data, input.source === "canvas" ? canvasPrivateValues(input.snapshot) : []);
return parsed.data;
} finally {
if (threadId) await app.closeSkillDraftThread(threadId).catch((error) => logger.warn("Failed to release Skill draft thread", { threadId, error }));
}
}
function skillDraftPrompt(input: CodexSkillDraftInput) {
const source = input.source === "conversation"
? "从这个临时分支继承的完整对话中,识别已经实际完成且值得复用的稳定流程。不要总结本条提炼请求,也不要保留一次性的结论、错误排查过程或工具日志。"
: `从下面经过清理的画布快照中,识别节点、连线和生成步骤所表达的可复用流程。不要把画布节点 ID 写进执行说明。\n\n画布快照:\n${JSON.stringify(canvasSkillSource(input.snapshot))}`;
return [
"请生成一个可编辑的 Codex Skill 草稿。",
source,
"要求:",
"- name 使用不超过 64 个字符的小写字母、数字和连字符,优先使用简短的动词短语。",
"- description 同时说明能力和触发场景;所有何时使用的信息都写在这里。",
"- instructions 只写另一个 Codex 真正需要的、可复用的命令式步骤、约束和输出要求,不写 YAML frontmatter。",
"- shortDescription 写 25–64 个字符的人类可读短说明;没有合适内容时返回空字符串。",
"- defaultPrompt 必须包含与 name 完全一致的 $skill-name 调用标记;没有合适内容时返回空字符串。",
"- displayName 使用简洁的人类可读名称。",
"- 不得输出 Token、API Key、密码、凭证、本地路径、媒体 URL、敏感 URL、临时错误、调试日志或一次性结果。",
"只按 outputSchema 返回对象。",
].join("\n");
}
const MAX_CANVAS_SKILL_NODES = 300;
const MAX_CANVAS_SKILL_CONNECTIONS = 600;
const MAX_CANVAS_SKILL_NODE_CHARS = 100_000;
const MAX_CANVAS_SKILL_SOURCE_CHARS = 120_000;
/** 只保留理解画布流程所需的信息,避免把媒体、外部地址、坐标和本地凭证送入草稿线程。 */
export function canvasSkillSource(snapshot: CanvasSnapshot): JsonRecord {
const allNodes = prioritizedCanvasNodes(snapshot);
const nodes = allNodes.slice(0, MAX_CANVAS_SKILL_NODES);
const nodeRefs = new Map(allNodes.map((node, index) => [node.id, `node-${index + 1}`]));
const title = cleanCanvasString(snapshot.title, nodeRefs);
const source: JsonRecord & { nodes: JsonRecord[]; connections: Array<{ from: string; to: string }> } = {
...(title ? { title } : {}),
nodes: [],
connections: [],
};
let truncated = nodes.length < allNodes.length;
nodes.forEach((node, index) => {
const metadata = { ...(node.metadata || {}) };
if (node.type !== "text") delete metadata.content;
const cleanMetadata = sanitizeCanvasValue(metadata, nodeRefs);
const nodeTitle = cleanCanvasString(node.title, nodeRefs);
const summary = { ref: `node-${index + 1}`, type: node.type, ...(nodeTitle ? { title: nodeTitle } : {}) };
const candidate = { ...summary, ...(cleanMetadata && Object.keys(cleanMetadata as JsonRecord).length ? { metadata: cleanMetadata } : {}) };
if (canvasSourceFits({ ...source, nodes: [...source.nodes, candidate] }, MAX_CANVAS_SKILL_NODE_CHARS)) source.nodes.push(candidate);
else if (canvasSourceFits({ ...source, nodes: [...source.nodes, summary] }, MAX_CANVAS_SKILL_NODE_CHARS)) (source.nodes.push(summary), truncated = true);
else truncated = true;
});
const includedRefs = new Set(source.nodes.map((node) => String(node.ref || "")));
const selectedNodeRefs = (snapshot.selectedNodeIds || []).flatMap((id) => nodeRefs.get(id) || []).filter((ref) => includedRefs.has(ref));
if (selectedNodeRefs.length) source.selectedNodeRefs = selectedNodeRefs;
const connections = (snapshot.connections || []).flatMap(({ fromNodeId, toNodeId }) => {
const from = nodeRefs.get(fromNodeId);
const to = nodeRefs.get(toNodeId);
return from && to && includedRefs.has(from) && includedRefs.has(to) ? [{ from, to }] : [];
});
if (connections.length > MAX_CANVAS_SKILL_CONNECTIONS) truncated = true;
connections.slice(0, MAX_CANVAS_SKILL_CONNECTIONS).forEach((connection) => {
if (canvasSourceFits({ ...source, connections: [...source.connections, connection] }, MAX_CANVAS_SKILL_SOURCE_CHARS - 32)) source.connections.push(connection);
else truncated = true;
});
if (truncated) source.truncated = true;
return source;
}
const sensitiveCanvasKey = /api.?key|token|secret|password|authorization|credential|storage.?key|(?:local|file).?path/i;
const transientCanvasKey = /^(?:status|progress|errorDetails|taskId|createdAt|updatedAt|startedAt|completedAt)$/i;
const canvasNodeReferenceKey = /^.*(?:Node|Group|Parent|Child|Root|Source|Target|PrimaryImage)Ids?$/i;
const directLocalPath = /^(?:file:(?:\/\/)?|[a-z]:[\\/]|\\\\|\/(?!\/)(?=[^\s`'"“”<>]+\/))/i;
const fileUrl = /\bfile:(?:\/\/)?[^\s`'"“”<>]+/gi;
const inlineLocalPath = /(?<![A-Za-z0-9/:])(?:[a-z]:[\\/]|\\\\)[^\s`'"“”<>]+|(?<![\p{L}\p{N}/:])\/(?!\/)(?=[^\s`'"“”<>]+\/)[^\s`'"“”<>]+/giu;
const credentialAssignment = /(?:api[_ -]?key|access[_ -]?(?:key|token)|connect[_ -]?token|token|secret|password|authorization|credential)\s*(?:[:=:]|为|是)\s*(?:bearer\s+)?[`'"“]?[A-Za-z0-9_./+\-=]{8,}/gi;
const bearerToken = /\bbearer\s+[A-Za-z0-9._~+/=\-]{8,}/gi;
const jwtToken = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g;
const knownApiToken = /\b(?:sk-[A-Za-z0-9_-]{12,}|(?:gh[pousr]|github_pat)_[A-Za-z0-9_]{20,}|AKIA[A-Z0-9]{16})\b/g;
const transientIdentifier = /\b(?:task|job|request|generation|node)[_-](?:\d{4,}|[A-Fa-f0-9]{8,}|(?=[A-Za-z0-9_-]{12,}\b)(?=[A-Za-z0-9_-]*\d)[A-Za-z0-9_-]+)\b/gi;
const webUrl = /\bhttps?:\/\/[^\s<>{}\[\]`'"“”]+/gi;
function sanitizeCanvasValue(value: unknown, nodeRefs: Map<string, string>, depth = 0): unknown {
if (value === null || typeof value === "boolean" || typeof value === "number") return value;
if (typeof value === "string") return cleanCanvasString(value, nodeRefs);
if (depth >= 6) return undefined;
if (Array.isArray(value)) return value.slice(0, 300).map((item) => sanitizeCanvasValue(item, nodeRefs, depth + 1)).filter((item) => item !== undefined);
if (!value || typeof value !== "object") return undefined;
const result: JsonRecord = {};
Object.entries(value as JsonRecord).forEach(([key, item]) => {
if (sensitiveCanvasKey.test(key) || transientCanvasKey.test(key)) return;
if (canvasNodeReferenceKey.test(key)) {
const refs = (Array.isArray(item) ? item : [item]).flatMap((id) => typeof id === "string" ? nodeRefs.get(id) || [] : []);
if (refs.length) result[key.replace(/Ids$/i, "Refs").replace(/Id$/i, "Ref")] = Array.isArray(item) ? [...new Set(refs)] : refs[0];
return;
}
const clean = sanitizeCanvasValue(item, nodeRefs, depth + 1);
if (clean !== undefined) result[replaceCanvasNodeRefs(key, nodeRefs)] = clean;
});
return result;
}
function cleanCanvasString(value: unknown, nodeRefs: Map<string, string>) {
const valueText = typeof value === "string" ? value.trim() : "";
if (!valueText || /^(?:data:|blob:|https?:\/\/|file:)/i.test(valueText) || directLocalPath.test(valueText)) return undefined;
const text = replaceCanvasNodeRefs(valueText, nodeRefs)
.replace(/\b(?:data:|blob:)[^\s`'"“”<>]+/gi, "[媒体地址已移除]")
.replace(fileUrl, "[本地路径已移除]")
.replace(webUrl, "[外部地址已移除]")
.replace(inlineLocalPath, "[本地路径已移除]")
.replace(credentialAssignment, "[敏感凭证已移除]")
.replace(bearerToken, "[敏感凭证已移除]")
.replace(jwtToken, "[敏感凭证已移除]")
.replace(knownApiToken, "[敏感凭证已移除]")
.trim();
return text.length > 12000 ? `${text.slice(0, 12000)}\n[内容已截断]` : text || undefined;
}
function replaceCanvasNodeRefs(value: string, nodeRefs: Map<string, string>) {
const nodeIds = [...nodeRefs.keys()].filter(Boolean).sort((left, right) => right.length - left.length);
const nodeIdPattern = nodeIds.length ? new RegExp(nodeIds.map((id) => id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "g") : undefined;
return nodeIdPattern ? value.replace(nodeIdPattern, (id) => nodeRefs.get(id) || id) : value;
}
function prioritizedCanvasNodes(snapshot: CanvasSnapshot) {
const nodes = snapshot.nodes || [];
const selectedIds = new Set(snapshot.selectedNodeIds || []);
const relatedIds = new Set<string>();
(snapshot.connections || []).forEach(({ fromNodeId, toNodeId }) => {
if (selectedIds.has(fromNodeId)) relatedIds.add(toNodeId);
if (selectedIds.has(toNodeId)) relatedIds.add(fromNodeId);
});
return [
...nodes.filter((node) => selectedIds.has(node.id)),
...nodes.filter((node) => !selectedIds.has(node.id) && relatedIds.has(node.id)),
...nodes.filter((node) => !selectedIds.has(node.id) && !relatedIds.has(node.id)),
];
}
function canvasSourceFits(source: JsonRecord, limit: number) {
return JSON.stringify(source).length <= limit;
}
function canvasPrivateValues(snapshot: CanvasSnapshot) {
return [snapshot.projectId, snapshot.clientId, ...(snapshot.nodes || []).map((node) => node.id), ...(snapshot.connections || []).map((connection) => connection.id)]
.filter((value): value is string => typeof value === "string" && value.length >= 6);
}
function patternMatches(pattern: RegExp, text: string) {
pattern.lastIndex = 0;
return pattern.test(text);
}
function mentionsSkill(prompt: string, name: string) {
return new RegExp(`\\$${name}(?![A-Za-z0-9_-]|:[A-Za-z0-9_-])`).test(prompt);
}
export function assertDraftHasNoSensitiveValues(draft: AgentSkillDraft, privateValues: string[]) {
const text = Object.values(draft).join("\n");
const localPath = /(?:^|[\s`'"“”((\[{,:;:])(?:file:(?:\/\/)?|[a-z]:[\\/]|\\\\|\/(?!\/))/im;
const externalUrl = (text.match(webUrl) || []).length > 0;
const hasPrivateValue = privateValues.some((value) => text.includes(value));
if (localPath.test(text) || /\b(?:data:|blob:)/i.test(text) || patternMatches(credentialAssignment, text) || patternMatches(bearerToken, text) || patternMatches(jwtToken, text) || patternMatches(knownApiToken, text) || patternMatches(transientIdentifier, text) || externalUrl || hasPrivateValue) {
throw new Error("生成的 Skill 草稿包含外部地址、本地路径、敏感凭证或一次性标识,已拒绝返回");
}
}
/** 读取线程历史,并显式标记 Codex 是否已经物化 turns。 */
async function loadCodexHistory(emit: AgentEmit, threadId: string, cwd?: string) {
try {