@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,54 @@
|
|
|
1
|
+
export type ManagedSkillInterface = {
|
|
2
|
+
displayName?: string;
|
|
3
|
+
shortDescription?: string;
|
|
4
|
+
defaultPrompt?: string;
|
|
5
|
+
};
|
|
6
|
+
export type ManagedSkillDetail = {
|
|
7
|
+
name: string;
|
|
8
|
+
description: string;
|
|
9
|
+
instructions: string;
|
|
10
|
+
interface?: ManagedSkillInterface;
|
|
11
|
+
path: string;
|
|
12
|
+
revision: string;
|
|
13
|
+
managed: true;
|
|
14
|
+
};
|
|
15
|
+
export type CreateManagedSkillInput = {
|
|
16
|
+
name: string;
|
|
17
|
+
description: string;
|
|
18
|
+
instructions: string;
|
|
19
|
+
interface?: ManagedSkillInterface | null;
|
|
20
|
+
};
|
|
21
|
+
export type UpdateManagedSkillInput = {
|
|
22
|
+
description: string;
|
|
23
|
+
instructions: string;
|
|
24
|
+
interface?: ManagedSkillInterface | null;
|
|
25
|
+
expectedRevision: string;
|
|
26
|
+
};
|
|
27
|
+
export declare class SkillStoreError extends Error {
|
|
28
|
+
readonly statusCode: 400 | 404 | 409 | 500;
|
|
29
|
+
name: string;
|
|
30
|
+
constructor(message: string, statusCode: 400 | 404 | 409 | 500);
|
|
31
|
+
}
|
|
32
|
+
/** 只管理站点工作空间下 `.agents/skills` 中的画布专属 Skill。 */
|
|
33
|
+
export declare class SkillStore {
|
|
34
|
+
readonly workspacePath: string;
|
|
35
|
+
readonly skillsPath: string;
|
|
36
|
+
private writeQueue;
|
|
37
|
+
constructor(workspacePath: string);
|
|
38
|
+
/** 判断 Codex 返回的绝对路径是否属于本 Store 的标准 Skill 入口。 */
|
|
39
|
+
isManagedPath(filePath: string): boolean;
|
|
40
|
+
/** 读取一个可编辑 Skill 的正文与界面元数据。 */
|
|
41
|
+
get(name: string): Promise<ManagedSkillDetail>;
|
|
42
|
+
/** 创建新的画布专属 Skill。 */
|
|
43
|
+
create(input: CreateManagedSkillInput): Promise<ManagedSkillDetail>;
|
|
44
|
+
/** 通过 revision 防止覆盖已被其他窗口或外部编辑器修改的内容。 */
|
|
45
|
+
update(nameValue: string, input: UpdateManagedSkillInput): Promise<ManagedSkillDetail>;
|
|
46
|
+
/** 删除 revision 仍匹配的画布专属 Skill。 */
|
|
47
|
+
delete(nameValue: string, expectedRevisionValueInput: string): Promise<void>;
|
|
48
|
+
private mutate;
|
|
49
|
+
private ensureRoot;
|
|
50
|
+
private safeExistingPaths;
|
|
51
|
+
private assertExistingRoot;
|
|
52
|
+
private readDetail;
|
|
53
|
+
private writeOpenAi;
|
|
54
|
+
}
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import matter from "gray-matter";
|
|
5
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
6
|
+
const NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7
|
+
const MAX_NAME_LENGTH = 64;
|
|
8
|
+
const MAX_DESCRIPTION_LENGTH = 1024;
|
|
9
|
+
const MAX_INSTRUCTIONS_BYTES = 256 * 1024;
|
|
10
|
+
const MAX_DISPLAY_NAME_LENGTH = 64;
|
|
11
|
+
const MIN_SHORT_DESCRIPTION_LENGTH = 25;
|
|
12
|
+
const MAX_SHORT_DESCRIPTION_LENGTH = 64;
|
|
13
|
+
const MAX_DEFAULT_PROMPT_LENGTH = 1024;
|
|
14
|
+
export class SkillStoreError extends Error {
|
|
15
|
+
statusCode;
|
|
16
|
+
name = "SkillStoreError";
|
|
17
|
+
constructor(message, statusCode) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.statusCode = statusCode;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** 只管理站点工作空间下 `.agents/skills` 中的画布专属 Skill。 */
|
|
23
|
+
export class SkillStore {
|
|
24
|
+
workspacePath;
|
|
25
|
+
skillsPath;
|
|
26
|
+
writeQueue = Promise.resolve();
|
|
27
|
+
constructor(workspacePath) {
|
|
28
|
+
this.workspacePath = path.resolve(workspacePath);
|
|
29
|
+
this.skillsPath = path.join(this.workspacePath, ".agents", "skills");
|
|
30
|
+
}
|
|
31
|
+
/** 判断 Codex 返回的绝对路径是否属于本 Store 的标准 Skill 入口。 */
|
|
32
|
+
isManagedPath(filePath) {
|
|
33
|
+
if (!path.isAbsolute(filePath))
|
|
34
|
+
return false;
|
|
35
|
+
const relative = path.relative(this.skillsPath, path.resolve(filePath));
|
|
36
|
+
const segments = relative.split(path.sep);
|
|
37
|
+
return segments.length === 2 && validName(segments[0]) && segments[1].toLowerCase() === "skill.md";
|
|
38
|
+
}
|
|
39
|
+
/** 读取一个可编辑 Skill 的正文与界面元数据。 */
|
|
40
|
+
async get(name) {
|
|
41
|
+
try {
|
|
42
|
+
await this.writeQueue.catch(() => undefined);
|
|
43
|
+
const paths = await this.safeExistingPaths(name);
|
|
44
|
+
return await this.readDetail(name, paths.skillFile, paths.openAiFile);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
throw storeError(error, "读取 Skill 失败");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** 创建新的画布专属 Skill。 */
|
|
51
|
+
create(input) {
|
|
52
|
+
return this.mutate(async () => {
|
|
53
|
+
const name = skillName(input?.name);
|
|
54
|
+
const description = skillDescription(input?.description);
|
|
55
|
+
const instructions = skillInstructions(input?.instructions);
|
|
56
|
+
const skillInterface = skillInterfaceValue(input?.interface, name);
|
|
57
|
+
await this.ensureRoot();
|
|
58
|
+
const skillDir = path.join(this.skillsPath, name);
|
|
59
|
+
const existing = await lstatOptional(skillDir);
|
|
60
|
+
if (existing) {
|
|
61
|
+
if (existing.isSymbolicLink())
|
|
62
|
+
throw new SkillStoreError("Skill 目录不能是符号链接或目录联接", 400);
|
|
63
|
+
throw new SkillStoreError("同名 Skill 已存在", 409);
|
|
64
|
+
}
|
|
65
|
+
await fs.mkdir(skillDir);
|
|
66
|
+
try {
|
|
67
|
+
const skillFile = path.join(skillDir, "SKILL.md");
|
|
68
|
+
await writeFileAtomic(skillFile, serializeSkill({ name, description }, instructions));
|
|
69
|
+
const openAiFile = path.join(skillDir, "agents", "openai.yaml");
|
|
70
|
+
if (skillInterface)
|
|
71
|
+
await this.writeOpenAi(openAiFile, {}, skillInterface);
|
|
72
|
+
return await this.readDetail(name, skillFile, openAiFile);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
await fs.rm(skillDir, { recursive: true, force: true }).catch(() => undefined);
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** 通过 revision 防止覆盖已被其他窗口或外部编辑器修改的内容。 */
|
|
81
|
+
update(nameValue, input) {
|
|
82
|
+
return this.mutate(async () => {
|
|
83
|
+
const name = skillName(nameValue);
|
|
84
|
+
const description = skillDescription(input?.description);
|
|
85
|
+
const instructions = skillInstructions(input?.instructions);
|
|
86
|
+
const expectedRevision = expectedRevisionValue(input?.expectedRevision);
|
|
87
|
+
const interfaceInput = input?.interface;
|
|
88
|
+
const skillInterface = interfaceInput === undefined ? undefined : skillInterfaceValue(interfaceInput, name);
|
|
89
|
+
const paths = await this.safeExistingPaths(name);
|
|
90
|
+
const currentSkill = await readSkill(paths.skillFile, name);
|
|
91
|
+
const currentOpenAi = await readOpenAi(paths.openAiFile, name);
|
|
92
|
+
assertRevision(expectedRevision, revision(currentSkill.raw, currentOpenAi.raw));
|
|
93
|
+
const frontmatter = { ...currentSkill.frontmatter, name, description };
|
|
94
|
+
await writeFileAtomic(paths.skillFile, serializeSkill(frontmatter, instructions));
|
|
95
|
+
if (interfaceInput !== undefined) {
|
|
96
|
+
try {
|
|
97
|
+
await this.writeOpenAi(paths.openAiFile, currentOpenAi.data, skillInterface);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (!await restoreSkillFiles(paths.skillFile, currentSkill.raw, paths.openAiFile, currentOpenAi.raw)) {
|
|
101
|
+
throw new SkillStoreError("Skill 更新失败且无法完全恢复,请检查本地文件", 500);
|
|
102
|
+
}
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return await this.readDetail(name, paths.skillFile, paths.openAiFile);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** 删除 revision 仍匹配的画布专属 Skill。 */
|
|
110
|
+
delete(nameValue, expectedRevisionValueInput) {
|
|
111
|
+
return this.mutate(async () => {
|
|
112
|
+
const name = skillName(nameValue);
|
|
113
|
+
const expectedRevision = expectedRevisionValue(expectedRevisionValueInput);
|
|
114
|
+
const paths = await this.safeExistingPaths(name);
|
|
115
|
+
const currentSkill = await readSkill(paths.skillFile, name);
|
|
116
|
+
const currentOpenAi = await readOpenAi(paths.openAiFile, name);
|
|
117
|
+
assertRevision(expectedRevision, revision(currentSkill.raw, currentOpenAi.raw));
|
|
118
|
+
await assertTreeHasNoLinks(paths.skillDir);
|
|
119
|
+
const realRoot = await fs.realpath(this.skillsPath);
|
|
120
|
+
const realSkill = await fs.realpath(paths.skillDir);
|
|
121
|
+
if (!inside(realRoot, realSkill))
|
|
122
|
+
throw new SkillStoreError("Skill 路径不安全", 400);
|
|
123
|
+
await fs.rm(realSkill, { recursive: true });
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
mutate(operation) {
|
|
127
|
+
const result = this.writeQueue.catch(() => undefined).then(operation).catch((error) => { throw storeError(error, "修改 Skill 失败"); });
|
|
128
|
+
this.writeQueue = result.catch(() => undefined);
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
async ensureRoot() {
|
|
132
|
+
const workspace = await lstatOptional(this.workspacePath);
|
|
133
|
+
if (!workspace)
|
|
134
|
+
throw new SkillStoreError("站点工作空间不存在", 409);
|
|
135
|
+
if (workspace.isSymbolicLink())
|
|
136
|
+
throw new SkillStoreError("站点工作空间不能是符号链接或目录联接", 400);
|
|
137
|
+
if (!workspace.isDirectory())
|
|
138
|
+
throw new SkillStoreError("站点工作空间不是目录", 409);
|
|
139
|
+
const agentsPath = path.join(this.workspacePath, ".agents");
|
|
140
|
+
await ensurePlainDirectory(agentsPath);
|
|
141
|
+
await ensurePlainDirectory(this.skillsPath);
|
|
142
|
+
const realWorkspace = await fs.realpath(this.workspacePath);
|
|
143
|
+
const realRoot = await fs.realpath(this.skillsPath);
|
|
144
|
+
if (!inside(realWorkspace, realRoot))
|
|
145
|
+
throw new SkillStoreError("Skill 根目录不安全", 400);
|
|
146
|
+
}
|
|
147
|
+
async safeExistingPaths(nameValue) {
|
|
148
|
+
const name = skillName(nameValue);
|
|
149
|
+
await this.assertExistingRoot();
|
|
150
|
+
const skillDir = path.join(this.skillsPath, name);
|
|
151
|
+
const directory = await lstatOptional(skillDir);
|
|
152
|
+
if (!directory)
|
|
153
|
+
throw new SkillStoreError("找不到指定 Skill", 404);
|
|
154
|
+
if (directory.isSymbolicLink() || !directory.isDirectory())
|
|
155
|
+
throw new SkillStoreError("Skill 目录不安全", 400);
|
|
156
|
+
const realRoot = await fs.realpath(this.skillsPath);
|
|
157
|
+
const realSkill = await fs.realpath(skillDir);
|
|
158
|
+
if (!inside(realRoot, realSkill))
|
|
159
|
+
throw new SkillStoreError("Skill 路径不安全", 400);
|
|
160
|
+
const skillFile = path.join(skillDir, "SKILL.md");
|
|
161
|
+
const skillEntry = await lstatOptional(skillFile);
|
|
162
|
+
if (!skillEntry)
|
|
163
|
+
throw new SkillStoreError("Skill 缺少 SKILL.md", 404);
|
|
164
|
+
if (skillEntry.isSymbolicLink() || !skillEntry.isFile())
|
|
165
|
+
throw new SkillStoreError("SKILL.md 路径不安全", 400);
|
|
166
|
+
const agentsDir = path.join(skillDir, "agents");
|
|
167
|
+
const agentsEntry = await lstatOptional(agentsDir);
|
|
168
|
+
if (agentsEntry && (agentsEntry.isSymbolicLink() || !agentsEntry.isDirectory()))
|
|
169
|
+
throw new SkillStoreError("Skill agents 目录不安全", 400);
|
|
170
|
+
const openAiFile = path.join(agentsDir, "openai.yaml");
|
|
171
|
+
const openAiEntry = await lstatOptional(openAiFile);
|
|
172
|
+
if (openAiEntry && (openAiEntry.isSymbolicLink() || !openAiEntry.isFile()))
|
|
173
|
+
throw new SkillStoreError("openai.yaml 路径不安全", 400);
|
|
174
|
+
return { skillDir, skillFile, openAiFile };
|
|
175
|
+
}
|
|
176
|
+
async assertExistingRoot() {
|
|
177
|
+
const workspace = await lstatOptional(this.workspacePath);
|
|
178
|
+
if (!workspace)
|
|
179
|
+
throw new SkillStoreError("找不到指定 Skill", 404);
|
|
180
|
+
if (workspace.isSymbolicLink())
|
|
181
|
+
throw new SkillStoreError("站点工作空间不能是符号链接或目录联接", 400);
|
|
182
|
+
if (!workspace.isDirectory())
|
|
183
|
+
throw new SkillStoreError("找不到指定 Skill", 404);
|
|
184
|
+
const agentsPath = path.join(this.workspacePath, ".agents");
|
|
185
|
+
const agents = await lstatOptional(agentsPath);
|
|
186
|
+
const root = await lstatOptional(this.skillsPath);
|
|
187
|
+
if (!agents || !root)
|
|
188
|
+
throw new SkillStoreError("找不到指定 Skill", 404);
|
|
189
|
+
if (agents.isSymbolicLink() || !agents.isDirectory() || root.isSymbolicLink() || !root.isDirectory())
|
|
190
|
+
throw new SkillStoreError("Skill 路径中存在符号链接或目录联接", 400);
|
|
191
|
+
const realWorkspace = await fs.realpath(this.workspacePath);
|
|
192
|
+
const realRoot = await fs.realpath(this.skillsPath);
|
|
193
|
+
if (!inside(realWorkspace, realRoot))
|
|
194
|
+
throw new SkillStoreError("Skill 根目录不安全", 400);
|
|
195
|
+
}
|
|
196
|
+
async readDetail(name, skillFile, openAiFile) {
|
|
197
|
+
const skill = await readSkill(skillFile, name);
|
|
198
|
+
const openAi = await readOpenAi(openAiFile, name);
|
|
199
|
+
return {
|
|
200
|
+
name,
|
|
201
|
+
description: skill.description,
|
|
202
|
+
instructions: skill.instructions,
|
|
203
|
+
...(openAi.interface ? { interface: openAi.interface } : {}),
|
|
204
|
+
path: skillFile,
|
|
205
|
+
revision: revision(skill.raw, openAi.raw),
|
|
206
|
+
managed: true,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
async writeOpenAi(filePath, current, skillInterface) {
|
|
210
|
+
const agentsDir = path.dirname(filePath);
|
|
211
|
+
const existing = recordValue(current.interface);
|
|
212
|
+
delete existing.display_name;
|
|
213
|
+
delete existing.short_description;
|
|
214
|
+
delete existing.default_prompt;
|
|
215
|
+
const interfaceYaml = {
|
|
216
|
+
...existing,
|
|
217
|
+
...(skillInterface?.displayName ? { display_name: skillInterface.displayName } : {}),
|
|
218
|
+
...(skillInterface?.shortDescription ? { short_description: skillInterface.shortDescription } : {}),
|
|
219
|
+
...(skillInterface?.defaultPrompt ? { default_prompt: skillInterface.defaultPrompt } : {}),
|
|
220
|
+
};
|
|
221
|
+
const next = { ...current };
|
|
222
|
+
if (Object.keys(interfaceYaml).length)
|
|
223
|
+
next.interface = interfaceYaml;
|
|
224
|
+
else
|
|
225
|
+
delete next.interface;
|
|
226
|
+
if (Object.keys(next).length) {
|
|
227
|
+
await ensurePlainDirectory(agentsDir);
|
|
228
|
+
await writeFileAtomic(filePath, stringifyYaml(next, { defaultKeyType: "PLAIN", defaultStringType: "QUOTE_DOUBLE" }));
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
await fs.unlink(filePath).catch((error) => {
|
|
232
|
+
if (nodeErrorCode(error) !== "ENOENT")
|
|
233
|
+
throw error;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function validName(value) {
|
|
239
|
+
return Boolean(value && value.length <= MAX_NAME_LENGTH && NAME_PATTERN.test(value));
|
|
240
|
+
}
|
|
241
|
+
function skillName(value) {
|
|
242
|
+
const name = typeof value === "string" ? value : "";
|
|
243
|
+
if (!validName(name))
|
|
244
|
+
throw new SkillStoreError("Skill 名称只能包含小写字母、数字和连字符", 400);
|
|
245
|
+
return name;
|
|
246
|
+
}
|
|
247
|
+
function skillDescription(value) {
|
|
248
|
+
const description = typeof value === "string" ? value.trim() : "";
|
|
249
|
+
if (!description)
|
|
250
|
+
throw new SkillStoreError("请输入 Skill 描述", 400);
|
|
251
|
+
if (description.length > MAX_DESCRIPTION_LENGTH)
|
|
252
|
+
throw new SkillStoreError("Skill 描述过长", 400);
|
|
253
|
+
if (description.includes("<") || description.includes(">"))
|
|
254
|
+
throw new SkillStoreError("Skill 描述不能包含尖括号", 400);
|
|
255
|
+
return description;
|
|
256
|
+
}
|
|
257
|
+
function skillInstructions(value) {
|
|
258
|
+
const instructions = typeof value === "string" ? value.trim() : "";
|
|
259
|
+
if (!instructions)
|
|
260
|
+
throw new SkillStoreError("请输入 Skill 正文", 400);
|
|
261
|
+
if (Buffer.byteLength(instructions, "utf8") > MAX_INSTRUCTIONS_BYTES)
|
|
262
|
+
throw new SkillStoreError("Skill 正文不能超过 256KiB", 400);
|
|
263
|
+
return instructions;
|
|
264
|
+
}
|
|
265
|
+
function skillInterfaceValue(value, name) {
|
|
266
|
+
if (value === undefined || value === null)
|
|
267
|
+
return undefined;
|
|
268
|
+
if (typeof value !== "object" || Array.isArray(value))
|
|
269
|
+
throw new SkillStoreError("Skill 界面元数据无效", 400);
|
|
270
|
+
const interfaceValue = value;
|
|
271
|
+
const displayName = optionalText(interfaceValue.displayName, "显示名称", MAX_DISPLAY_NAME_LENGTH);
|
|
272
|
+
const shortDescription = optionalText(interfaceValue.shortDescription, "简短描述", MAX_SHORT_DESCRIPTION_LENGTH);
|
|
273
|
+
const defaultPrompt = optionalText(interfaceValue.defaultPrompt, "默认提示词", MAX_DEFAULT_PROMPT_LENGTH);
|
|
274
|
+
if (shortDescription && shortDescription.length < MIN_SHORT_DESCRIPTION_LENGTH)
|
|
275
|
+
throw new SkillStoreError(`简短描述不能少于 ${MIN_SHORT_DESCRIPTION_LENGTH} 个字符`, 400);
|
|
276
|
+
if (defaultPrompt && !new RegExp(`\\$${name}(?![A-Za-z0-9_-]|:[A-Za-z0-9_-])`).test(defaultPrompt))
|
|
277
|
+
throw new SkillStoreError(`默认提示词必须包含 $${name}`, 400);
|
|
278
|
+
return displayName || shortDescription || defaultPrompt ? { ...(displayName ? { displayName } : {}), ...(shortDescription ? { shortDescription } : {}), ...(defaultPrompt ? { defaultPrompt } : {}) } : undefined;
|
|
279
|
+
}
|
|
280
|
+
function optionalText(value, label, maxLength) {
|
|
281
|
+
if (value === undefined || value === null || value === "")
|
|
282
|
+
return undefined;
|
|
283
|
+
if (typeof value !== "string")
|
|
284
|
+
throw new SkillStoreError(`${label}格式无效`, 400);
|
|
285
|
+
const text = value.trim();
|
|
286
|
+
if (text.length > maxLength)
|
|
287
|
+
throw new SkillStoreError(`${label}过长`, 400);
|
|
288
|
+
return text || undefined;
|
|
289
|
+
}
|
|
290
|
+
function expectedRevisionValue(value) {
|
|
291
|
+
const expected = typeof value === "string" ? value : "";
|
|
292
|
+
if (!/^[a-f0-9]{64}$/.test(expected))
|
|
293
|
+
throw new SkillStoreError("Skill revision 无效,请重新加载后再试", 400);
|
|
294
|
+
return expected;
|
|
295
|
+
}
|
|
296
|
+
function assertRevision(expected, current) {
|
|
297
|
+
if (expected !== current)
|
|
298
|
+
throw new SkillStoreError("Skill 已被其他窗口或外部编辑器修改,请重新加载后再试", 409);
|
|
299
|
+
}
|
|
300
|
+
function serializeSkill(frontmatter, instructions) {
|
|
301
|
+
return matter.stringify(`${instructions.trim()}\n`, frontmatter);
|
|
302
|
+
}
|
|
303
|
+
async function readSkill(filePath, expectedName) {
|
|
304
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
305
|
+
let parsed;
|
|
306
|
+
try {
|
|
307
|
+
parsed = matter(raw);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
throw new SkillStoreError("SKILL.md frontmatter 格式无效", 409);
|
|
311
|
+
}
|
|
312
|
+
const frontmatter = recordValue(parsed.data);
|
|
313
|
+
if (frontmatter.name !== expectedName)
|
|
314
|
+
throw new SkillStoreError("SKILL.md 中的名称与目录不一致", 409);
|
|
315
|
+
return {
|
|
316
|
+
raw,
|
|
317
|
+
frontmatter,
|
|
318
|
+
description: skillDescription(frontmatter.description),
|
|
319
|
+
instructions: skillInstructions(parsed.content),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
async function readOpenAi(filePath, expectedName) {
|
|
323
|
+
const entry = await lstatOptional(filePath);
|
|
324
|
+
if (!entry)
|
|
325
|
+
return { raw: "", data: {} };
|
|
326
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
327
|
+
let data;
|
|
328
|
+
try {
|
|
329
|
+
const parsed = parseYaml(raw);
|
|
330
|
+
if (parsed !== null && parsed !== undefined && (typeof parsed !== "object" || Array.isArray(parsed)))
|
|
331
|
+
throw new Error("invalid document");
|
|
332
|
+
data = recordValue(parsed);
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
throw new SkillStoreError("agents/openai.yaml 格式无效", 409);
|
|
336
|
+
}
|
|
337
|
+
if (data.interface !== undefined && data.interface !== null && (typeof data.interface !== "object" || Array.isArray(data.interface))) {
|
|
338
|
+
throw new SkillStoreError("agents/openai.yaml interface 格式无效", 409);
|
|
339
|
+
}
|
|
340
|
+
const value = data.interface;
|
|
341
|
+
const skillInterface = skillInterfaceValue({ displayName: value?.display_name, shortDescription: value?.short_description, defaultPrompt: value?.default_prompt }, expectedName);
|
|
342
|
+
return { raw, data, ...(skillInterface ? { interface: skillInterface } : {}) };
|
|
343
|
+
}
|
|
344
|
+
async function restoreSkillFiles(skillFile, skillRaw, openAiFile, openAiRaw) {
|
|
345
|
+
try {
|
|
346
|
+
await writeFileAtomic(skillFile, skillRaw);
|
|
347
|
+
if (openAiRaw) {
|
|
348
|
+
await ensurePlainDirectory(path.dirname(openAiFile));
|
|
349
|
+
await writeFileAtomic(openAiFile, openAiRaw);
|
|
350
|
+
}
|
|
351
|
+
else {
|
|
352
|
+
await fs.unlink(openAiFile).catch((error) => {
|
|
353
|
+
if (nodeErrorCode(error) !== "ENOENT")
|
|
354
|
+
throw error;
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return true;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
async function writeFileAtomic(filePath, content) {
|
|
364
|
+
const temporary = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
365
|
+
try {
|
|
366
|
+
await fs.writeFile(temporary, content, { encoding: "utf8", flag: "wx" });
|
|
367
|
+
await fs.rename(temporary, filePath);
|
|
368
|
+
}
|
|
369
|
+
finally {
|
|
370
|
+
await fs.unlink(temporary).catch((error) => {
|
|
371
|
+
if (nodeErrorCode(error) !== "ENOENT")
|
|
372
|
+
throw error;
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
function revision(skillRaw, openAiRaw) {
|
|
377
|
+
return crypto.createHash("sha256").update(skillRaw).update("\0").update(openAiRaw).digest("hex");
|
|
378
|
+
}
|
|
379
|
+
async function ensurePlainDirectory(directory) {
|
|
380
|
+
const entry = await lstatOptional(directory);
|
|
381
|
+
if (!entry) {
|
|
382
|
+
await fs.mkdir(directory);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (entry.isSymbolicLink() || !entry.isDirectory())
|
|
386
|
+
throw new SkillStoreError("Skill 路径中存在符号链接或目录联接", 400);
|
|
387
|
+
}
|
|
388
|
+
async function assertTreeHasNoLinks(directory) {
|
|
389
|
+
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
|
390
|
+
const entryPath = path.join(directory, entry.name);
|
|
391
|
+
const metadata = await fs.lstat(entryPath);
|
|
392
|
+
if (metadata.isSymbolicLink())
|
|
393
|
+
throw new SkillStoreError("Skill 目录中存在符号链接或目录联接,无法删除", 400);
|
|
394
|
+
if (metadata.isDirectory())
|
|
395
|
+
await assertTreeHasNoLinks(entryPath);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function inside(parent, child) {
|
|
399
|
+
const relative = path.relative(parent, child);
|
|
400
|
+
return Boolean(relative && !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
401
|
+
}
|
|
402
|
+
async function lstatOptional(filePath) {
|
|
403
|
+
try {
|
|
404
|
+
return await fs.lstat(filePath);
|
|
405
|
+
}
|
|
406
|
+
catch (error) {
|
|
407
|
+
if (nodeErrorCode(error) === "ENOENT")
|
|
408
|
+
return undefined;
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
function recordValue(value) {
|
|
413
|
+
return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
|
|
414
|
+
}
|
|
415
|
+
function nodeErrorCode(error) {
|
|
416
|
+
return error && typeof error === "object" && "code" in error ? String(error.code || "") : "";
|
|
417
|
+
}
|
|
418
|
+
function storeError(error, fallback) {
|
|
419
|
+
if (error instanceof SkillStoreError)
|
|
420
|
+
return error;
|
|
421
|
+
if (nodeErrorCode(error) === "ENOENT")
|
|
422
|
+
return new SkillStoreError("找不到指定 Skill", 404);
|
|
423
|
+
if (["EEXIST", "ENOTEMPTY", "EPERM", "EACCES"].includes(nodeErrorCode(error)))
|
|
424
|
+
return new SkillStoreError("Skill 文件当前无法修改", 409);
|
|
425
|
+
return new SkillStoreError(fallback, 500);
|
|
426
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Redact bearer tokens, sk- keys, and explicit credential assignments before a string is logged or sent to the browser. */
|
|
2
|
+
export declare function redactAgentLog(text: string): string;
|
|
3
|
+
/** Buffer stderr by line so credentials split across chunks are redacted as one value. */
|
|
4
|
+
export declare function createAgentLogWriter(emit: (text: string) => void, normalize?: (text: string) => string): {
|
|
5
|
+
write(text: string): void;
|
|
6
|
+
flush(): void;
|
|
7
|
+
clear(): void;
|
|
8
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import stripAnsi from "strip-ansi";
|
|
2
|
+
/** Redact bearer tokens, sk- keys, and explicit credential assignments before a string is logged or sent to the browser. */
|
|
3
|
+
export function redactAgentLog(text) {
|
|
4
|
+
return text
|
|
5
|
+
.replace(/(authorization\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\r\n,;]+)/gi, "$1[REDACTED]")
|
|
6
|
+
.replace(/(Bearer\s+)[A-Za-z0-9._~+\/-]+=*/gi, "$1[REDACTED]")
|
|
7
|
+
.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "[REDACTED]")
|
|
8
|
+
.replace(/((?:api[ _-]*key|token)\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, "$1[REDACTED]");
|
|
9
|
+
}
|
|
10
|
+
/** Buffer stderr by line so credentials split across chunks are redacted as one value. */
|
|
11
|
+
export function createAgentLogWriter(emit, normalize = (text) => text) {
|
|
12
|
+
let buffer = "";
|
|
13
|
+
const send = (text) => emit(redactAgentLog(normalize(stripAnsi(text))));
|
|
14
|
+
return {
|
|
15
|
+
write(text) {
|
|
16
|
+
buffer += text;
|
|
17
|
+
let newline = buffer.indexOf("\n");
|
|
18
|
+
while (newline >= 0) {
|
|
19
|
+
send(buffer.slice(0, newline + 1));
|
|
20
|
+
buffer = buffer.slice(newline + 1);
|
|
21
|
+
newline = buffer.indexOf("\n");
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
flush() {
|
|
25
|
+
if (buffer)
|
|
26
|
+
send(buffer);
|
|
27
|
+
buffer = "";
|
|
28
|
+
},
|
|
29
|
+
clear() {
|
|
30
|
+
buffer = "";
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** 将日期格式化为适合文件名使用的本地日期字符串。 */
|
|
2
|
+
export function formatDateForFilename(date = new Date()) {
|
|
3
|
+
const year = date.getFullYear();
|
|
4
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
5
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
6
|
+
return `${year}-${month}-${day}`;
|
|
7
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** 管理 Canvas Agent 的终端与文件 Debug 日志。 */
|
|
2
|
+
export declare class Logger {
|
|
3
|
+
readonly enabled: boolean;
|
|
4
|
+
readonly filePath: string;
|
|
5
|
+
private readonly logger;
|
|
6
|
+
/** 普通模式输出 Info 以上日志,Debug 模式额外输出 Debug 并写入文件。 */
|
|
7
|
+
constructor();
|
|
8
|
+
/** 输出 Debug 级别日志。 */
|
|
9
|
+
debug(message: string, details?: unknown): void;
|
|
10
|
+
/** 输出 Info 级别日志。 */
|
|
11
|
+
info(message: string, details?: unknown): void;
|
|
12
|
+
/** 输出 Warn 级别日志。 */
|
|
13
|
+
warn(message: string, details?: unknown): void;
|
|
14
|
+
/** 输出 Error 级别日志。 */
|
|
15
|
+
error(message: string, details?: unknown): void;
|
|
16
|
+
}
|
|
17
|
+
export declare const logger: Logger;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { inspect } from "node:util";
|
|
5
|
+
import winston, { format, transports } from "winston";
|
|
6
|
+
import { formatDateForFilename } from "./date.js";
|
|
7
|
+
/** 管理 Canvas Agent 的终端与文件 Debug 日志。 */
|
|
8
|
+
export class Logger {
|
|
9
|
+
enabled = process.argv.includes("--debug");
|
|
10
|
+
filePath = this.enabled ? path.join(os.homedir(), ".infinite-canvas", "logs", `canvas-agent-${formatDateForFilename()}.log`) : "";
|
|
11
|
+
logger;
|
|
12
|
+
/** 普通模式输出 Info 以上日志,Debug 模式额外输出 Debug 并写入文件。 */
|
|
13
|
+
constructor() {
|
|
14
|
+
const line = format.printf(({ level, message, timestamp, details }) => `${timestamp} ${level.toUpperCase()} ${message}${formatDetails(details)}`);
|
|
15
|
+
const output = format.combine(format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), line);
|
|
16
|
+
if (this.enabled)
|
|
17
|
+
fs.mkdirSync(path.dirname(this.filePath), { recursive: true });
|
|
18
|
+
this.logger = winston.createLogger({
|
|
19
|
+
level: this.enabled ? "debug" : "info",
|
|
20
|
+
transports: [
|
|
21
|
+
new transports.Console({ format: output }),
|
|
22
|
+
...(this.enabled ? [new transports.File({ filename: this.filePath, format: output })] : []),
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** 输出 Debug 级别日志。 */
|
|
27
|
+
debug(message, details) {
|
|
28
|
+
if (details === undefined)
|
|
29
|
+
this.logger.debug(message);
|
|
30
|
+
else
|
|
31
|
+
this.logger.debug(message, { details: sanitize(details) });
|
|
32
|
+
}
|
|
33
|
+
/** 输出 Info 级别日志。 */
|
|
34
|
+
info(message, details) {
|
|
35
|
+
if (details === undefined)
|
|
36
|
+
this.logger.info(message);
|
|
37
|
+
else
|
|
38
|
+
this.logger.info(message, { details: sanitize(details) });
|
|
39
|
+
}
|
|
40
|
+
/** 输出 Warn 级别日志。 */
|
|
41
|
+
warn(message, details) {
|
|
42
|
+
if (details === undefined)
|
|
43
|
+
this.logger.warn(message);
|
|
44
|
+
else
|
|
45
|
+
this.logger.warn(message, { details: sanitize(details) });
|
|
46
|
+
}
|
|
47
|
+
/** 输出 Error 级别日志。 */
|
|
48
|
+
error(message, details) {
|
|
49
|
+
if (details === undefined)
|
|
50
|
+
this.logger.error(message);
|
|
51
|
+
else
|
|
52
|
+
this.logger.error(message, { details: sanitize(details) });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** 将日志详情格式化为紧凑的单行文本。 */
|
|
56
|
+
function formatDetails(details) {
|
|
57
|
+
if (details === undefined)
|
|
58
|
+
return "";
|
|
59
|
+
if (!details || typeof details !== "object" || Array.isArray(details))
|
|
60
|
+
return ` ${inspect(details, { depth: null, breakLength: Infinity })}`;
|
|
61
|
+
const text = Object.entries(details).filter(([, value]) => value !== undefined).map(([key, value]) => `${key}=${inspect(value, { depth: null, breakLength: Infinity })}`).join(" ");
|
|
62
|
+
return text ? ` ${text}` : "";
|
|
63
|
+
}
|
|
64
|
+
/** 清理日志内容中的敏感数据和不可序列化引用。 */
|
|
65
|
+
function sanitize(value, key = "", seen = new WeakSet()) {
|
|
66
|
+
if (/token|authorization|api.?key|dataurl/i.test(key))
|
|
67
|
+
return "[REDACTED]";
|
|
68
|
+
if (typeof value === "string" && value.startsWith("data:"))
|
|
69
|
+
return `[DATA URL ${value.length} chars]`;
|
|
70
|
+
if (value instanceof Error)
|
|
71
|
+
return { name: value.name, message: value.message, stack: value.stack };
|
|
72
|
+
if (!value || typeof value !== "object")
|
|
73
|
+
return value;
|
|
74
|
+
if (seen.has(value))
|
|
75
|
+
return "[CIRCULAR]";
|
|
76
|
+
seen.add(value);
|
|
77
|
+
if (Array.isArray(value))
|
|
78
|
+
return value.map((item) => sanitize(item, key, seen));
|
|
79
|
+
return Object.fromEntries(Object.entries(value).map(([field, item]) => [field, sanitize(item, field, seen)]));
|
|
80
|
+
}
|
|
81
|
+
export const logger = new Logger();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** 安全读取未知对象中的指定字段。 */
|
|
2
|
+
export function field(value, key) {
|
|
3
|
+
return value && typeof value === "object" ? value[key] : undefined;
|
|
4
|
+
}
|
|
5
|
+
/** 将未知异常转换为可展示的错误信息。 */
|
|
6
|
+
export function errorMessage(error) {
|
|
7
|
+
return error instanceof Error ? error.message : String(error);
|
|
8
|
+
}
|