@kobinflow/canvas-agent 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +159 -0
- package/agent-instructions.md +13 -0
- package/dist/agent/claude.d.ts +3 -0
- package/dist/agent/claude.js +54 -0
- package/dist/agent/codex-client.d.ts +139 -0
- package/dist/agent/codex-client.js +937 -0
- package/dist/agent/codex-event-history.d.ts +21 -0
- package/dist/agent/codex-event-history.js +163 -0
- package/dist/agent/codex-history.d.ts +46 -0
- package/dist/agent/codex-history.js +822 -0
- package/dist/agent/codex-protocol.d.ts +335 -0
- package/dist/agent/codex-protocol.js +1 -0
- package/dist/agent/codex.d.ts +178 -0
- package/dist/agent/codex.js +509 -0
- package/dist/agent/message-metadata.d.ts +51 -0
- package/dist/agent/message-metadata.js +378 -0
- package/dist/agent/types.d.ts +45 -0
- package/dist/agent/types.js +1 -0
- package/dist/canvas/operations.d.ts +13 -0
- package/dist/canvas/operations.js +167 -0
- package/dist/canvas/schemas.d.ts +989 -0
- package/dist/canvas/schemas.js +155 -0
- package/dist/canvas/session.d.ts +163 -0
- package/dist/canvas/session.js +523 -0
- package/dist/canvas/tools.d.ts +214 -0
- package/dist/canvas/tools.js +27 -0
- package/dist/canvas/types.d.ts +34 -0
- package/dist/canvas/types.js +1 -0
- package/dist/config.d.ts +30 -0
- package/dist/config.js +86 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +7 -0
- package/dist/server/http.d.ts +2 -0
- package/dist/server/http.js +576 -0
- package/dist/server/mcp.d.ts +2 -0
- package/dist/server/mcp.js +27 -0
- package/dist/skills/store.d.ts +54 -0
- package/dist/skills/store.js +426 -0
- package/dist/utils/agent-runtime.d.ts +8 -0
- package/dist/utils/agent-runtime.js +33 -0
- package/dist/utils/date.d.ts +2 -0
- package/dist/utils/date.js +7 -0
- package/dist/utils/logger.d.ts +17 -0
- package/dist/utils/logger.js +81 -0
- package/dist/utils/value.d.ts +5 -0
- package/dist/utils/value.js +8 -0
- package/dist/version-check.d.ts +2 -0
- package/dist/version-check.js +65 -0
- package/package.json +55 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { logger } from "../utils/logger.js";
|
|
6
|
+
import { errorMessage, field } from "../utils/value.js";
|
|
7
|
+
import { CodexAppClient, CodexReportedError } from "./codex-client.js";
|
|
8
|
+
import { codexEventHistory } from "./codex-event-history.js";
|
|
9
|
+
import { settledTurnIds, summarizeCodexThread, threadMessages } from "./codex-history.js";
|
|
10
|
+
import { messageMetadataStore } from "./message-metadata.js";
|
|
11
|
+
const skillNamePattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
12
|
+
const skillDraftSchema = z.object({
|
|
13
|
+
name: z.string().trim().min(1).max(64).regex(skillNamePattern),
|
|
14
|
+
displayName: z.string().trim().max(64),
|
|
15
|
+
description: z.string().trim().min(1).max(1024).refine((value) => !/[<>]/.test(value)),
|
|
16
|
+
instructions: z.string().trim().min(1).max(20000),
|
|
17
|
+
shortDescription: z.string().trim().max(64).refine((value) => !value || value.length >= 25),
|
|
18
|
+
defaultPrompt: z.string().trim().max(1024),
|
|
19
|
+
}).strict().superRefine((draft, context) => {
|
|
20
|
+
if (draft.defaultPrompt && !mentionsSkill(draft.defaultPrompt, draft.name))
|
|
21
|
+
context.addIssue({ code: "custom", path: ["defaultPrompt"], message: `默认提示词必须包含 $${draft.name}` });
|
|
22
|
+
});
|
|
23
|
+
const SKILL_DRAFT_OUTPUT_SCHEMA = {
|
|
24
|
+
type: "object",
|
|
25
|
+
additionalProperties: false,
|
|
26
|
+
required: ["name", "displayName", "description", "instructions", "shortDescription", "defaultPrompt"],
|
|
27
|
+
properties: {
|
|
28
|
+
name: { type: "string", pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$", minLength: 1, maxLength: 64 },
|
|
29
|
+
displayName: { type: "string", maxLength: 64 },
|
|
30
|
+
description: { type: "string", pattern: "^[^<>]+$", minLength: 1, maxLength: 1024 },
|
|
31
|
+
instructions: { type: "string", minLength: 1, maxLength: 20000 },
|
|
32
|
+
shortDescription: { anyOf: [{ type: "string", maxLength: 0 }, { type: "string", minLength: 25, maxLength: 64 }] },
|
|
33
|
+
defaultPrompt: { type: "string", maxLength: 1024 },
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
export class CodexSkillLookupError extends Error {
|
|
37
|
+
statusCode;
|
|
38
|
+
name = "CodexSkillLookupError";
|
|
39
|
+
constructor(message, statusCode) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.statusCode = statusCode;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
let codexQueue = Promise.resolve();
|
|
45
|
+
let codexApp = null;
|
|
46
|
+
let codexAppStart = null;
|
|
47
|
+
/** 仅表示最近主动加载/选择的线程;运行中的 turn 身份由 CodexAppClient 自己维护。 */
|
|
48
|
+
let loadedThreadId = "";
|
|
49
|
+
export { summarizeCodexThread } from "./codex-history.js";
|
|
50
|
+
/** 将 Codex turn 加入串行队列并等待执行完成。 */
|
|
51
|
+
export async function runCodexTurn(prompt, lifecycleEmit, attachments = [], options = {}) {
|
|
52
|
+
if (!prompt.trim())
|
|
53
|
+
return;
|
|
54
|
+
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, lifecycleEmit, attachments, options));
|
|
55
|
+
await codexQueue;
|
|
56
|
+
}
|
|
57
|
+
/** 从当前对话或指定网页画布生成可编辑草稿,不写入 Skill 文件。 */
|
|
58
|
+
export async function generateCodexSkillDraft(emit, cwd, input) {
|
|
59
|
+
const queued = codexQueue.catch(() => undefined).then(() => generateCodexSkillDraftNow(emit, cwd, input));
|
|
60
|
+
codexQueue = queued;
|
|
61
|
+
return await queued;
|
|
62
|
+
}
|
|
63
|
+
/** 中断当前线程正在执行的 Codex turn。 */
|
|
64
|
+
export async function interruptCodexTurn(threadId) {
|
|
65
|
+
if (!codexApp)
|
|
66
|
+
return false;
|
|
67
|
+
return await codexApp.interruptCurrentTurn(threadId);
|
|
68
|
+
}
|
|
69
|
+
/** 回复当前 app-server 的待处理权限请求。 */
|
|
70
|
+
export async function resolveCodexApproval(requestId, decision) {
|
|
71
|
+
return Boolean(codexApp?.resolveApproval(requestId, decision));
|
|
72
|
+
}
|
|
73
|
+
/** 创建新的 Codex 线程并记录当前线程 ID。 */
|
|
74
|
+
export async function startCodexThread(emit, cwd, permissionMode = "request", preheat = false) {
|
|
75
|
+
const app = await getCodexApp(emit);
|
|
76
|
+
const thread = await app.startThread(cwd, permissionMode, preheat);
|
|
77
|
+
loadedThreadId = String(field(thread, "id") || "");
|
|
78
|
+
return thread;
|
|
79
|
+
}
|
|
80
|
+
/** 恢复指定 Codex 线程并返回聊天历史。 */
|
|
81
|
+
export async function resumeCodexThread(emit, threadId, cwd, permissionMode = "request", preheat = false) {
|
|
82
|
+
const app = await getCodexApp(emit);
|
|
83
|
+
const thread = await resumeLoadedThread(app, threadId, cwd, permissionMode, true, preheat);
|
|
84
|
+
const history = await loadCodexHistory(emit, threadId, cwd);
|
|
85
|
+
const supplementalItems = await codexEventHistory.readThread(threadId);
|
|
86
|
+
const messages = await mergeMessageMetadata(threadId, threadMessages(history.thread, app.planUpdates(threadId), supplementalItems));
|
|
87
|
+
return { thread, messages, settledTurnIds: settledTurnIds(history.thread, supplementalItems), historyReady: history.historyReady };
|
|
88
|
+
}
|
|
89
|
+
/** 查询当前工作空间中的 Codex 线程。 */
|
|
90
|
+
export async function listCodexThreads(emit, options) {
|
|
91
|
+
const app = await getCodexApp(emit);
|
|
92
|
+
const result = await app.listThreads({
|
|
93
|
+
limit: options.limit || 40,
|
|
94
|
+
sortKey: "updated_at",
|
|
95
|
+
sortDirection: "desc",
|
|
96
|
+
sourceKinds: ["cli", "vscode", "appServer", "exec"],
|
|
97
|
+
cwd: options.cwd,
|
|
98
|
+
...(options.searchTerm ? { searchTerm: options.searchTerm } : {}),
|
|
99
|
+
});
|
|
100
|
+
const data = Array.isArray(field(result, "data")) ? field(result, "data").map(summarizeCodexThread).filter((thread) => threadInWorkspace(thread, options.cwd)) : [];
|
|
101
|
+
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
|
|
102
|
+
}
|
|
103
|
+
/** 查询当前账号可用于新任务的 Codex 模型。 */
|
|
104
|
+
export async function listCodexModels(emit) {
|
|
105
|
+
return await (await getCodexApp(emit)).listModels();
|
|
106
|
+
}
|
|
107
|
+
/** 查询当前工作空间的原生 Skill 列表。 */
|
|
108
|
+
export async function listCodexSkills(emit, cwd, forceReload = false) {
|
|
109
|
+
const result = await (await getCodexApp(emit)).listSkills(cwd, forceReload);
|
|
110
|
+
return result.data.find((entry) => samePath(entry.cwd, cwd)) || { cwd, skills: [], errors: [] };
|
|
111
|
+
}
|
|
112
|
+
/** 从原生 Skill 列表中解析并校验浏览器提交的选择器。 */
|
|
113
|
+
export async function resolveCodexSkill(emit, cwd, selector, requireEnabled = false) {
|
|
114
|
+
const name = String(selector?.name || "");
|
|
115
|
+
const requestedPath = String(selector?.path || "");
|
|
116
|
+
if (!name || !requestedPath || !path.isAbsolute(requestedPath))
|
|
117
|
+
throw new CodexSkillLookupError("Skill 选择无效", 400);
|
|
118
|
+
const { skills } = await listCodexSkills(emit, cwd, true);
|
|
119
|
+
const skill = skills.find((item) => item.name === name && samePath(item.path, requestedPath));
|
|
120
|
+
if (!skill)
|
|
121
|
+
throw new CodexSkillLookupError("找不到指定 Skill,请刷新列表后重试", 404);
|
|
122
|
+
if (requireEnabled && !skill.enabled)
|
|
123
|
+
throw new CodexSkillLookupError("该 Skill 已停用,请先启用后再使用", 409);
|
|
124
|
+
return skill;
|
|
125
|
+
}
|
|
126
|
+
/** 修改经过原生列表校验的 Skill 启用状态。 */
|
|
127
|
+
export async function configureCodexSkill(emit, cwd, selector, enabled) {
|
|
128
|
+
const skill = await resolveCodexSkill(emit, cwd, selector);
|
|
129
|
+
const result = await (await getCodexApp(emit)).setSkillEnabled(skill.path, enabled);
|
|
130
|
+
return { ...result, skill: { ...skill, enabled: result.effectiveEnabled } };
|
|
131
|
+
}
|
|
132
|
+
/** 读取指定 Codex 线程及其聊天历史。 */
|
|
133
|
+
export async function readCodexThread(emit, threadId, cwd) {
|
|
134
|
+
const app = await getCodexApp(emit);
|
|
135
|
+
const history = await loadCodexHistory(emit, threadId, cwd);
|
|
136
|
+
const supplementalItems = await codexEventHistory.readThread(threadId);
|
|
137
|
+
const messages = await mergeMessageMetadata(threadId, threadMessages(history.thread, app.planUpdates(threadId), supplementalItems));
|
|
138
|
+
return { thread: summarizeCodexThread(history.thread), messages, settledTurnIds: settledTurnIds(history.thread, supplementalItems), historyReady: history.historyReady };
|
|
139
|
+
}
|
|
140
|
+
/** 归档指定 Codex 线程。 */
|
|
141
|
+
export async function archiveCodexThread(emit, threadId, cwd) {
|
|
142
|
+
const app = await getCodexApp(emit);
|
|
143
|
+
try {
|
|
144
|
+
await loadCodexThread(emit, threadId, cwd, false);
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (!isRecoverableThreadError(error))
|
|
148
|
+
throw error;
|
|
149
|
+
await resumeLoadedThread(app, threadId, cwd, "request", false);
|
|
150
|
+
}
|
|
151
|
+
await app.archiveThread(threadId);
|
|
152
|
+
app.clearPlanUpdates(threadId);
|
|
153
|
+
await codexEventHistory.removeThread(threadId);
|
|
154
|
+
await messageMetadataStore.removeThread(threadId).catch((error) => logger.warn("Failed to remove archived thread message metadata", { threadId, error }));
|
|
155
|
+
if (loadedThreadId === threadId)
|
|
156
|
+
loadedThreadId = "";
|
|
157
|
+
}
|
|
158
|
+
async function mergeMessageMetadata(threadId, messages) {
|
|
159
|
+
try {
|
|
160
|
+
return await messageMetadataStore.mergeThread(threadId, messages);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
logger.warn("Failed to read thread message metadata", { threadId, error });
|
|
164
|
+
return messages;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** 判断线程异常是否允许自动新建线程后重试。 */
|
|
168
|
+
export function isRecoverableThreadError(error) {
|
|
169
|
+
return /thread not loaded|no rollout found/i.test(errorMessage(error));
|
|
170
|
+
}
|
|
171
|
+
/** 执行一次 Codex turn,并负责附件临时文件和线程恢复。 */
|
|
172
|
+
async function runCodexTurnNow(prompt, lifecycleEmit, attachments, options) {
|
|
173
|
+
let files = [];
|
|
174
|
+
try {
|
|
175
|
+
options.onStart?.();
|
|
176
|
+
files = await writeAttachmentFiles(attachments);
|
|
177
|
+
const app = await getCodexApp(options.appEmit || lifecycleEmit);
|
|
178
|
+
let threadId = await ensureCodexThread(app, options, lifecycleEmit);
|
|
179
|
+
options.onThread?.(threadId);
|
|
180
|
+
try {
|
|
181
|
+
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn, options.skill, options.messageText);
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
if (!isRecoverableThreadError(error))
|
|
185
|
+
throw error;
|
|
186
|
+
lifecycleEmit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
|
187
|
+
loadedThreadId = "";
|
|
188
|
+
threadId = await ensureCodexThread(app, { cwd: options.cwd }, lifecycleEmit);
|
|
189
|
+
options.onThread?.(threadId);
|
|
190
|
+
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.model, options.effort, options.onTurn, options.skill, options.messageText);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
logger.error("Codex turn failed", error);
|
|
195
|
+
if (!(error instanceof CodexReportedError))
|
|
196
|
+
lifecycleEmit("agent_error", { message: errorMessage(error) });
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
options.onFinish?.();
|
|
200
|
+
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/** 恢复请求线程或创建新的 Codex 线程。 */
|
|
204
|
+
async function ensureCodexThread(app, options, emit) {
|
|
205
|
+
if (options.threadId) {
|
|
206
|
+
if (options.threadId === loadedThreadId)
|
|
207
|
+
return loadedThreadId;
|
|
208
|
+
try {
|
|
209
|
+
await resumeLoadedThread(app, options.threadId, options.cwd, options.permissionMode || "request", true);
|
|
210
|
+
return loadedThreadId;
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (!isRecoverableThreadError(error))
|
|
214
|
+
throw error;
|
|
215
|
+
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
|
216
|
+
loadedThreadId = "";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!loadedThreadId) {
|
|
220
|
+
const thread = await app.startThread(options.cwd, options.permissionMode || "request");
|
|
221
|
+
loadedThreadId = String(field(thread, "id") || "");
|
|
222
|
+
}
|
|
223
|
+
return loadedThreadId;
|
|
224
|
+
}
|
|
225
|
+
/** 从 app-server 读取线程并校验工作空间。 */
|
|
226
|
+
async function loadCodexThread(emit, threadId, cwd, includeTurns) {
|
|
227
|
+
const app = await getCodexApp(emit);
|
|
228
|
+
const result = await app.readThread(threadId, includeTurns);
|
|
229
|
+
const thread = field(result, "thread") || {};
|
|
230
|
+
assertThreadWorkspace(thread, cwd);
|
|
231
|
+
return thread;
|
|
232
|
+
}
|
|
233
|
+
async function generateCodexSkillDraftNow(emit, cwd, input) {
|
|
234
|
+
const app = await getCodexApp(emit);
|
|
235
|
+
let threadId = "";
|
|
236
|
+
try {
|
|
237
|
+
const thread = input.source === "conversation" ? await app.forkSkillDraftThread(input.threadId, cwd) : await app.startSkillDraftThread(cwd);
|
|
238
|
+
threadId = String(field(thread, "id") || "");
|
|
239
|
+
const raw = await app.generateSkillDraft(threadId, skillDraftPrompt(input), SKILL_DRAFT_OUTPUT_SCHEMA, input.model, input.effort);
|
|
240
|
+
let value;
|
|
241
|
+
try {
|
|
242
|
+
value = JSON.parse(raw);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
throw new Error("Codex 返回的 Skill 草稿不是有效 JSON");
|
|
246
|
+
}
|
|
247
|
+
const parsed = skillDraftSchema.safeParse(value);
|
|
248
|
+
if (!parsed.success)
|
|
249
|
+
throw new Error("Codex 返回的 Skill 草稿格式不正确");
|
|
250
|
+
assertDraftHasNoSensitiveValues(parsed.data, input.source === "canvas" ? canvasPrivateValues(input.snapshot) : []);
|
|
251
|
+
return parsed.data;
|
|
252
|
+
}
|
|
253
|
+
finally {
|
|
254
|
+
if (threadId)
|
|
255
|
+
await app.closeSkillDraftThread(threadId).catch((error) => logger.warn("Failed to release Skill draft thread", { threadId, error }));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
function skillDraftPrompt(input) {
|
|
259
|
+
const source = input.source === "conversation"
|
|
260
|
+
? "从这个临时分支继承的完整对话中,识别已经实际完成且值得复用的稳定流程。不要总结本条提炼请求,也不要保留一次性的结论、错误排查过程或工具日志。"
|
|
261
|
+
: `从下面经过清理的画布快照中,识别节点、连线和生成步骤所表达的可复用流程。不要把画布节点 ID 写进执行说明。\n\n画布快照:\n${JSON.stringify(canvasSkillSource(input.snapshot))}`;
|
|
262
|
+
return [
|
|
263
|
+
"请生成一个可编辑的 Codex Skill 草稿。",
|
|
264
|
+
source,
|
|
265
|
+
"要求:",
|
|
266
|
+
"- name 使用不超过 64 个字符的小写字母、数字和连字符,优先使用简短的动词短语。",
|
|
267
|
+
"- description 同时说明能力和触发场景;所有何时使用的信息都写在这里。",
|
|
268
|
+
"- instructions 只写另一个 Codex 真正需要的、可复用的命令式步骤、约束和输出要求,不写 YAML frontmatter。",
|
|
269
|
+
"- shortDescription 写 25–64 个字符的人类可读短说明;没有合适内容时返回空字符串。",
|
|
270
|
+
"- defaultPrompt 必须包含与 name 完全一致的 $skill-name 调用标记;没有合适内容时返回空字符串。",
|
|
271
|
+
"- displayName 使用简洁的人类可读名称。",
|
|
272
|
+
"- 不得输出 Token、API Key、密码、凭证、本地路径、媒体 URL、敏感 URL、临时错误、调试日志或一次性结果。",
|
|
273
|
+
"只按 outputSchema 返回对象。",
|
|
274
|
+
].join("\n");
|
|
275
|
+
}
|
|
276
|
+
const MAX_CANVAS_SKILL_NODES = 300;
|
|
277
|
+
const MAX_CANVAS_SKILL_CONNECTIONS = 600;
|
|
278
|
+
const MAX_CANVAS_SKILL_NODE_CHARS = 100_000;
|
|
279
|
+
const MAX_CANVAS_SKILL_SOURCE_CHARS = 120_000;
|
|
280
|
+
/** 只保留理解画布流程所需的信息,避免把媒体、外部地址、坐标和本地凭证送入草稿线程。 */
|
|
281
|
+
export function canvasSkillSource(snapshot) {
|
|
282
|
+
const allNodes = prioritizedCanvasNodes(snapshot);
|
|
283
|
+
const nodes = allNodes.slice(0, MAX_CANVAS_SKILL_NODES);
|
|
284
|
+
const nodeRefs = new Map(allNodes.map((node, index) => [node.id, `node-${index + 1}`]));
|
|
285
|
+
const title = cleanCanvasString(snapshot.title, nodeRefs);
|
|
286
|
+
const source = {
|
|
287
|
+
...(title ? { title } : {}),
|
|
288
|
+
nodes: [],
|
|
289
|
+
connections: [],
|
|
290
|
+
};
|
|
291
|
+
let truncated = nodes.length < allNodes.length;
|
|
292
|
+
nodes.forEach((node, index) => {
|
|
293
|
+
const metadata = { ...(node.metadata || {}) };
|
|
294
|
+
if (node.type !== "text")
|
|
295
|
+
delete metadata.content;
|
|
296
|
+
const cleanMetadata = sanitizeCanvasValue(metadata, nodeRefs);
|
|
297
|
+
const nodeTitle = cleanCanvasString(node.title, nodeRefs);
|
|
298
|
+
const summary = { ref: `node-${index + 1}`, type: node.type, ...(nodeTitle ? { title: nodeTitle } : {}) };
|
|
299
|
+
const candidate = { ...summary, ...(cleanMetadata && Object.keys(cleanMetadata).length ? { metadata: cleanMetadata } : {}) };
|
|
300
|
+
if (canvasSourceFits({ ...source, nodes: [...source.nodes, candidate] }, MAX_CANVAS_SKILL_NODE_CHARS))
|
|
301
|
+
source.nodes.push(candidate);
|
|
302
|
+
else if (canvasSourceFits({ ...source, nodes: [...source.nodes, summary] }, MAX_CANVAS_SKILL_NODE_CHARS))
|
|
303
|
+
(source.nodes.push(summary), truncated = true);
|
|
304
|
+
else
|
|
305
|
+
truncated = true;
|
|
306
|
+
});
|
|
307
|
+
const includedRefs = new Set(source.nodes.map((node) => String(node.ref || "")));
|
|
308
|
+
const selectedNodeRefs = (snapshot.selectedNodeIds || []).flatMap((id) => nodeRefs.get(id) || []).filter((ref) => includedRefs.has(ref));
|
|
309
|
+
if (selectedNodeRefs.length)
|
|
310
|
+
source.selectedNodeRefs = selectedNodeRefs;
|
|
311
|
+
const connections = (snapshot.connections || []).flatMap(({ fromNodeId, toNodeId }) => {
|
|
312
|
+
const from = nodeRefs.get(fromNodeId);
|
|
313
|
+
const to = nodeRefs.get(toNodeId);
|
|
314
|
+
return from && to && includedRefs.has(from) && includedRefs.has(to) ? [{ from, to }] : [];
|
|
315
|
+
});
|
|
316
|
+
if (connections.length > MAX_CANVAS_SKILL_CONNECTIONS)
|
|
317
|
+
truncated = true;
|
|
318
|
+
connections.slice(0, MAX_CANVAS_SKILL_CONNECTIONS).forEach((connection) => {
|
|
319
|
+
if (canvasSourceFits({ ...source, connections: [...source.connections, connection] }, MAX_CANVAS_SKILL_SOURCE_CHARS - 32))
|
|
320
|
+
source.connections.push(connection);
|
|
321
|
+
else
|
|
322
|
+
truncated = true;
|
|
323
|
+
});
|
|
324
|
+
if (truncated)
|
|
325
|
+
source.truncated = true;
|
|
326
|
+
return source;
|
|
327
|
+
}
|
|
328
|
+
const sensitiveCanvasKey = /api.?key|token|secret|password|authorization|credential|storage.?key|(?:local|file).?path/i;
|
|
329
|
+
const transientCanvasKey = /^(?:status|progress|errorDetails|taskId|createdAt|updatedAt|startedAt|completedAt)$/i;
|
|
330
|
+
const canvasNodeReferenceKey = /^.*(?:Node|Group|Parent|Child|Root|Source|Target|PrimaryImage)Ids?$/i;
|
|
331
|
+
const directLocalPath = /^(?:file:(?:\/\/)?|[a-z]:[\\/]|\\\\|\/(?!\/)(?=[^\s`'"“”<>]+\/))/i;
|
|
332
|
+
const fileUrl = /\bfile:(?:\/\/)?[^\s`'"“”<>]+/gi;
|
|
333
|
+
const inlineLocalPath = /(?<![A-Za-z0-9/:])(?:[a-z]:[\\/]|\\\\)[^\s`'"“”<>]+|(?<![\p{L}\p{N}/:])\/(?!\/)(?=[^\s`'"“”<>]+\/)[^\s`'"“”<>]+/giu;
|
|
334
|
+
const credentialAssignment = /(?:api[_ -]?key|access[_ -]?(?:key|token)|connect[_ -]?token|token|secret|password|authorization|credential)\s*(?:[:=:]|为|是)\s*(?:bearer\s+)?[`'"“]?[A-Za-z0-9_./+\-=]{8,}/gi;
|
|
335
|
+
const bearerToken = /\bbearer\s+[A-Za-z0-9._~+/=\-]{8,}/gi;
|
|
336
|
+
const jwtToken = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g;
|
|
337
|
+
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;
|
|
338
|
+
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;
|
|
339
|
+
const webUrl = /\bhttps?:\/\/[^\s<>{}\[\]`'"“”]+/gi;
|
|
340
|
+
function sanitizeCanvasValue(value, nodeRefs, depth = 0) {
|
|
341
|
+
if (value === null || typeof value === "boolean" || typeof value === "number")
|
|
342
|
+
return value;
|
|
343
|
+
if (typeof value === "string")
|
|
344
|
+
return cleanCanvasString(value, nodeRefs);
|
|
345
|
+
if (depth >= 6)
|
|
346
|
+
return undefined;
|
|
347
|
+
if (Array.isArray(value))
|
|
348
|
+
return value.slice(0, 300).map((item) => sanitizeCanvasValue(item, nodeRefs, depth + 1)).filter((item) => item !== undefined);
|
|
349
|
+
if (!value || typeof value !== "object")
|
|
350
|
+
return undefined;
|
|
351
|
+
const result = {};
|
|
352
|
+
Object.entries(value).forEach(([key, item]) => {
|
|
353
|
+
if (sensitiveCanvasKey.test(key) || transientCanvasKey.test(key))
|
|
354
|
+
return;
|
|
355
|
+
if (canvasNodeReferenceKey.test(key)) {
|
|
356
|
+
const refs = (Array.isArray(item) ? item : [item]).flatMap((id) => typeof id === "string" ? nodeRefs.get(id) || [] : []);
|
|
357
|
+
if (refs.length)
|
|
358
|
+
result[key.replace(/Ids$/i, "Refs").replace(/Id$/i, "Ref")] = Array.isArray(item) ? [...new Set(refs)] : refs[0];
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const clean = sanitizeCanvasValue(item, nodeRefs, depth + 1);
|
|
362
|
+
if (clean !== undefined)
|
|
363
|
+
result[replaceCanvasNodeRefs(key, nodeRefs)] = clean;
|
|
364
|
+
});
|
|
365
|
+
return result;
|
|
366
|
+
}
|
|
367
|
+
function cleanCanvasString(value, nodeRefs) {
|
|
368
|
+
const valueText = typeof value === "string" ? value.trim() : "";
|
|
369
|
+
if (!valueText || /^(?:data:|blob:|https?:\/\/|file:)/i.test(valueText) || directLocalPath.test(valueText))
|
|
370
|
+
return undefined;
|
|
371
|
+
const text = replaceCanvasNodeRefs(valueText, nodeRefs)
|
|
372
|
+
.replace(/\b(?:data:|blob:)[^\s`'"“”<>]+/gi, "[媒体地址已移除]")
|
|
373
|
+
.replace(fileUrl, "[本地路径已移除]")
|
|
374
|
+
.replace(webUrl, "[外部地址已移除]")
|
|
375
|
+
.replace(inlineLocalPath, "[本地路径已移除]")
|
|
376
|
+
.replace(credentialAssignment, "[敏感凭证已移除]")
|
|
377
|
+
.replace(bearerToken, "[敏感凭证已移除]")
|
|
378
|
+
.replace(jwtToken, "[敏感凭证已移除]")
|
|
379
|
+
.replace(knownApiToken, "[敏感凭证已移除]")
|
|
380
|
+
.trim();
|
|
381
|
+
return text.length > 12000 ? `${text.slice(0, 12000)}\n[内容已截断]` : text || undefined;
|
|
382
|
+
}
|
|
383
|
+
function replaceCanvasNodeRefs(value, nodeRefs) {
|
|
384
|
+
const nodeIds = [...nodeRefs.keys()].filter(Boolean).sort((left, right) => right.length - left.length);
|
|
385
|
+
const nodeIdPattern = nodeIds.length ? new RegExp(nodeIds.map((id) => id.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"), "g") : undefined;
|
|
386
|
+
return nodeIdPattern ? value.replace(nodeIdPattern, (id) => nodeRefs.get(id) || id) : value;
|
|
387
|
+
}
|
|
388
|
+
function prioritizedCanvasNodes(snapshot) {
|
|
389
|
+
const nodes = snapshot.nodes || [];
|
|
390
|
+
const selectedIds = new Set(snapshot.selectedNodeIds || []);
|
|
391
|
+
const relatedIds = new Set();
|
|
392
|
+
(snapshot.connections || []).forEach(({ fromNodeId, toNodeId }) => {
|
|
393
|
+
if (selectedIds.has(fromNodeId))
|
|
394
|
+
relatedIds.add(toNodeId);
|
|
395
|
+
if (selectedIds.has(toNodeId))
|
|
396
|
+
relatedIds.add(fromNodeId);
|
|
397
|
+
});
|
|
398
|
+
return [
|
|
399
|
+
...nodes.filter((node) => selectedIds.has(node.id)),
|
|
400
|
+
...nodes.filter((node) => !selectedIds.has(node.id) && relatedIds.has(node.id)),
|
|
401
|
+
...nodes.filter((node) => !selectedIds.has(node.id) && !relatedIds.has(node.id)),
|
|
402
|
+
];
|
|
403
|
+
}
|
|
404
|
+
function canvasSourceFits(source, limit) {
|
|
405
|
+
return JSON.stringify(source).length <= limit;
|
|
406
|
+
}
|
|
407
|
+
function canvasPrivateValues(snapshot) {
|
|
408
|
+
return [snapshot.projectId, snapshot.clientId, ...(snapshot.nodes || []).map((node) => node.id), ...(snapshot.connections || []).map((connection) => connection.id)]
|
|
409
|
+
.filter((value) => typeof value === "string" && value.length >= 6);
|
|
410
|
+
}
|
|
411
|
+
function patternMatches(pattern, text) {
|
|
412
|
+
pattern.lastIndex = 0;
|
|
413
|
+
return pattern.test(text);
|
|
414
|
+
}
|
|
415
|
+
function mentionsSkill(prompt, name) {
|
|
416
|
+
return new RegExp(`\\$${name}(?![A-Za-z0-9_-]|:[A-Za-z0-9_-])`).test(prompt);
|
|
417
|
+
}
|
|
418
|
+
export function assertDraftHasNoSensitiveValues(draft, privateValues) {
|
|
419
|
+
const text = Object.values(draft).join("\n");
|
|
420
|
+
const localPath = /(?:^|[\s`'"“”((\[{,:;:])(?:file:(?:\/\/)?|[a-z]:[\\/]|\\\\|\/(?!\/))/im;
|
|
421
|
+
const externalUrl = (text.match(webUrl) || []).length > 0;
|
|
422
|
+
const hasPrivateValue = privateValues.some((value) => text.includes(value));
|
|
423
|
+
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) {
|
|
424
|
+
throw new Error("生成的 Skill 草稿包含外部地址、本地路径、敏感凭证或一次性标识,已拒绝返回");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
/** 读取线程历史,并显式标记 Codex 是否已经物化 turns。 */
|
|
428
|
+
async function loadCodexHistory(emit, threadId, cwd) {
|
|
429
|
+
try {
|
|
430
|
+
return { thread: await loadCodexThread(emit, threadId, cwd, true), historyReady: true };
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
if (/not materialized yet.*includeTurns/i.test(errorMessage(error)))
|
|
434
|
+
return { thread: await loadCodexThread(emit, threadId, cwd, false), historyReady: false };
|
|
435
|
+
if (!isRecoverableThreadError(error))
|
|
436
|
+
throw error;
|
|
437
|
+
const app = await getCodexApp(emit);
|
|
438
|
+
const thread = await resumeLoadedThread(app, threadId, cwd, "request", false);
|
|
439
|
+
try {
|
|
440
|
+
return { thread: await loadCodexThread(emit, threadId, cwd, true), historyReady: true };
|
|
441
|
+
}
|
|
442
|
+
catch (historyError) {
|
|
443
|
+
if (/not materialized yet.*includeTurns/i.test(errorMessage(historyError)))
|
|
444
|
+
return { thread, historyReady: false };
|
|
445
|
+
throw historyError;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/** 恢复线程并统一校验工作空间与进程内活动线程。 */
|
|
450
|
+
async function resumeLoadedThread(app, threadId, cwd, permissionMode = "request", updateLoaded = true, preheat = false) {
|
|
451
|
+
const thread = await app.resumeThread(threadId, cwd, permissionMode, preheat);
|
|
452
|
+
assertThreadWorkspace(thread, cwd);
|
|
453
|
+
if (updateLoaded)
|
|
454
|
+
loadedThreadId = String(field(thread, "id") || threadId);
|
|
455
|
+
return thread;
|
|
456
|
+
}
|
|
457
|
+
/** 获取已启动的 Codex app-server 客户端。 */
|
|
458
|
+
async function getCodexApp(emit) {
|
|
459
|
+
if (codexApp)
|
|
460
|
+
return codexApp;
|
|
461
|
+
codexAppStart ||= CodexAppClient.start(emit, () => {
|
|
462
|
+
codexApp = null;
|
|
463
|
+
loadedThreadId = "";
|
|
464
|
+
});
|
|
465
|
+
try {
|
|
466
|
+
codexApp = await codexAppStart;
|
|
467
|
+
return codexApp;
|
|
468
|
+
}
|
|
469
|
+
finally {
|
|
470
|
+
codexAppStart = null;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/** 校验线程是否属于指定工作空间。 */
|
|
474
|
+
function assertThreadWorkspace(thread, cwd) {
|
|
475
|
+
if (!cwd || threadInWorkspace(thread, cwd))
|
|
476
|
+
return;
|
|
477
|
+
throw new Error("该 Codex 会话不属于当前画布工作空间");
|
|
478
|
+
}
|
|
479
|
+
/** 判断线程工作目录是否与当前工作空间一致。 */
|
|
480
|
+
function threadInWorkspace(thread, cwd) {
|
|
481
|
+
const threadCwd = String(field(thread, "cwd") || "");
|
|
482
|
+
return Boolean(threadCwd && samePath(threadCwd, cwd));
|
|
483
|
+
}
|
|
484
|
+
/** 比较跨平台绝对路径;Windows 路径不区分大小写。 */
|
|
485
|
+
function samePath(left, right) {
|
|
486
|
+
const normalize = (value) => process.platform === "win32" ? path.resolve(value).toLowerCase() : path.resolve(value);
|
|
487
|
+
return normalize(left) === normalize(right);
|
|
488
|
+
}
|
|
489
|
+
/** 将图片附件写入临时文件供 Codex 读取。 */
|
|
490
|
+
async function writeAttachmentFiles(attachments) {
|
|
491
|
+
return await Promise.all(attachments.filter((item) => item.dataUrl?.startsWith("data:image/")).map(writeAttachmentFile));
|
|
492
|
+
}
|
|
493
|
+
/** 将单个 Data URL 图片附件写入临时文件。 */
|
|
494
|
+
async function writeAttachmentFile(item) {
|
|
495
|
+
const [, meta = "", data = ""] = item.dataUrl?.match(/^data:([^;]+);base64,(.+)$/) || [];
|
|
496
|
+
if (!data)
|
|
497
|
+
throw new Error(`图片附件无效:${item.name || "未命名图片"}`);
|
|
498
|
+
const file = path.join(os.tmpdir(), `infinite-canvas-${Date.now()}-${Math.random().toString(16).slice(2)}.${imageExt(meta || item.type)}`);
|
|
499
|
+
await fs.writeFile(file, Buffer.from(data, "base64"));
|
|
500
|
+
return file;
|
|
501
|
+
}
|
|
502
|
+
/** 根据图片 MIME 类型返回临时文件扩展名。 */
|
|
503
|
+
function imageExt(type = "") {
|
|
504
|
+
if (type.includes("png"))
|
|
505
|
+
return "png";
|
|
506
|
+
if (type.includes("webp"))
|
|
507
|
+
return "webp";
|
|
508
|
+
return "jpg";
|
|
509
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { AgentMessageMetadata, AgentSkillReference } from "./types.js";
|
|
2
|
+
type MetadataMessage = {
|
|
3
|
+
role: string;
|
|
4
|
+
threadId: string;
|
|
5
|
+
turnId: string;
|
|
6
|
+
clientMessageId?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare class MessageMetadataStore {
|
|
9
|
+
private directory;
|
|
10
|
+
private queue;
|
|
11
|
+
private ready?;
|
|
12
|
+
constructor(directory?: string);
|
|
13
|
+
recordPending(clientMessageId: string, value: unknown): Promise<{
|
|
14
|
+
skill?: AgentSkillReference | undefined;
|
|
15
|
+
canvasReferences?: {
|
|
16
|
+
previewUrl?: string;
|
|
17
|
+
nodeId: string;
|
|
18
|
+
label: string;
|
|
19
|
+
title: string;
|
|
20
|
+
kind: "image" | "video" | "audio" | "text";
|
|
21
|
+
text?: string;
|
|
22
|
+
}[] | undefined;
|
|
23
|
+
attachments?: {
|
|
24
|
+
url: string;
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
type?: string;
|
|
28
|
+
size?: number;
|
|
29
|
+
width?: number;
|
|
30
|
+
height?: number;
|
|
31
|
+
}[] | undefined;
|
|
32
|
+
} | undefined>;
|
|
33
|
+
bindThread(clientMessageId: string, threadId: string): Promise<void>;
|
|
34
|
+
bindTurn(clientMessageId: string, threadId: string, turnId: string): Promise<void>;
|
|
35
|
+
mergeThread<T extends MetadataMessage>(threadId: string, messages: T[]): Promise<(T & AgentMessageMetadata)[]>;
|
|
36
|
+
remove(clientMessageId: string, threadId?: string): Promise<void>;
|
|
37
|
+
removeThread(threadId: string): Promise<void>;
|
|
38
|
+
readAsset(messageKey: string, assetFile: string): Promise<{
|
|
39
|
+
data: NonSharedBuffer;
|
|
40
|
+
contentType: string;
|
|
41
|
+
} | undefined>;
|
|
42
|
+
private run;
|
|
43
|
+
private ensureReady;
|
|
44
|
+
private initialize;
|
|
45
|
+
private pendingFile;
|
|
46
|
+
private threadDirectory;
|
|
47
|
+
private threadFile;
|
|
48
|
+
private assetDirectory;
|
|
49
|
+
}
|
|
50
|
+
export declare const messageMetadataStore: MessageMetadataStore;
|
|
51
|
+
export {};
|