agent-loft 0.2.30 → 0.2.32

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.
Files changed (47) hide show
  1. package/README.md +11 -1
  2. package/package.json +1 -1
  3. package/src/agents/claude/driver.ts +13 -8
  4. package/src/agents/claude/session.ts +38 -6
  5. package/src/agents/codex/session.ts +32 -5
  6. package/src/channels/feishu/bot.ts +8 -4
  7. package/src/channels/feishu/turn-stream.ts +3 -2
  8. package/src/channels/web/api.ts +8 -0
  9. package/src/channels/web/http.ts +2 -2
  10. package/src/channels/web/server.ts +21 -1
  11. package/src/channels/web/skills-api.ts +225 -0
  12. package/src/channels/web/tasks-api.ts +94 -0
  13. package/src/core/chat-mode.ts +16 -16
  14. package/src/core/command-catalog.ts +8 -1
  15. package/src/core/commands.ts +3 -0
  16. package/src/core/hub.ts +147 -19
  17. package/src/core/memory-mcp.ts +1 -1
  18. package/src/core/personal.ts +5 -1
  19. package/src/core/skill-runtime.ts +359 -0
  20. package/src/core/skills-bridge.ts +288 -0
  21. package/src/core/skills.ts +675 -0
  22. package/src/core/store.ts +2 -0
  23. package/src/core/task-prompt.ts +38 -0
  24. package/src/core/task-runner.ts +232 -0
  25. package/src/core/task-schedule.ts +250 -0
  26. package/src/core/tasks-bridge.ts +256 -0
  27. package/src/core/tasks-mcp.ts +66 -0
  28. package/src/core/tasks.ts +281 -0
  29. package/src/core/types.ts +21 -0
  30. package/src/core/zip.ts +98 -0
  31. package/src/main.ts +12 -0
  32. package/src/mcp/tasks-server.ts +327 -0
  33. package/web/css/skills.css +368 -0
  34. package/web/css/tasks.css +455 -0
  35. package/web/index.html +2 -0
  36. package/web/lib/dom.js +1 -0
  37. package/web/lib/ws.js +8 -0
  38. package/web/state.js +2 -0
  39. package/web/views/composer.js +15 -3
  40. package/web/views/conv-actions.js +15 -7
  41. package/web/views/layout.js +22 -1
  42. package/web/views/mention.js +193 -0
  43. package/web/views/messages.js +23 -2
  44. package/web/views/sidebar.js +4 -0
  45. package/web/views/skills.js +703 -0
  46. package/web/views/tasks.js +469 -0
  47. package/web/views/topbar.js +9 -3
@@ -0,0 +1,675 @@
1
+ // 技能(skill):一份 SKILL.md 加上它附带的脚本、资料,agent 需要时照着做。和 Claude Code / Codex 自己的
2
+ // skill 是同一种格式,只是由 agent-loft 按人管理:
3
+ // - 谁都能建(网页上写、上传技能包,或者让 agent 在对话里生成),建的人是作者;
4
+ // - 作者决定谁看得到:只有自己 / 指定的几个人 / 所有人。看得到的人在「技能广场」里安装;
5
+ // - 安装的是作者那一份本身,不是副本:作者改了,装了的人下一条消息起就用上新的;
6
+ // - 技能可以声明要用的环境变量(API 地址、密钥之类),值由每个用户自己填,只注入到他自己的会话里。
7
+ //
8
+ // 存储(data/skills/):
9
+ // index.json 所有技能的元数据
10
+ // files/<技能 id>/ SKILL.md 和附带文件
11
+ // users/<用户 id>.json 这个人装了哪些、停用了哪些、变量的值
12
+ // plugins/<用户 id>/ 给 Claude 挂的插件目录,里面是指向 files/ 的链接(skill-runtime.ts 生成)
13
+ //
14
+ // 只有主进程读写这些文件(agent 的技能工具经 tasks-bridge 转过来),所以缓存在内存里。
15
+ // 和多用户一样是「信任型」的:工作模式下 agent 能读到这里的所有文件,包括别人的变量值。
16
+ import crypto from "node:crypto";
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { EventEmitter } from "node:events";
20
+ import { dataDir, readJson, writeJsonAtomic } from "../home.js";
21
+
22
+ export type SkillVisibility = "private" | "users" | "all";
23
+
24
+ export type SkillVar = {
25
+ /** 环境变量名,大写字母、数字、下划线 */
26
+ name: string;
27
+ description: string;
28
+ /** 密钥之类:网页上不回显,只显示填没填 */
29
+ secret: boolean;
30
+ required: boolean;
31
+ /**
32
+ * 受保护的密钥(secret 且给了 hosts):不放进环境变量,agent 看不到值,只能用 skill_request 工具
33
+ * 让 agent-loft 代发请求(写 {{NAME}},发出去时才换成真值),而且只发往这些地址。
34
+ * 写法:api.example.com、*.example.com、192.168.8.2:8080。实际放行的是用户填值时确认过的那一份(见 approved)。
35
+ */
36
+ hosts?: string[];
37
+ };
38
+
39
+ export type Skill = {
40
+ id: string;
41
+ /** 小写字母、数字和 -,全局唯一,建好后不能改(Claude 里叫 loft:<name>,输入框里 @<name>) */
42
+ name: string;
43
+ description: string;
44
+ owner: string;
45
+ visibility: SkillVisibility;
46
+ /** visibility 是 users 时:给哪些用户 */
47
+ sharedWith: string[];
48
+ vars: SkillVar[];
49
+ source: "manual" | "upload" | "agent";
50
+ createdAt: number;
51
+ updatedAt: number;
52
+ };
53
+
54
+ export type UserSkillPrefs = {
55
+ /** 装了的别人的技能(自己的不用装) */
56
+ installed: string[];
57
+ /** 停用的(自己的或者装了的):不注入会话,@ 不到 */
58
+ disabled: string[];
59
+ /** 变量的值:按变量名存,几个技能用同一个变量名(比如 GITHUB_TOKEN)就共用一个值 */
60
+ vars: Record<string, string>;
61
+ /**
62
+ * 受保护的密钥,用户填值时确认过可以发往哪些地址。作者后来改了 hosts 不会自动放行新地址
63
+ * (装的是作者那一份本身,改了也跟着变 —— 偏偏这一项不能跟着变),要用户再确认一次。
64
+ */
65
+ approved: Record<string, string[]>;
66
+ };
67
+
68
+ export const SKILL_NAME = /^[a-z0-9][a-z0-9-]{0,47}$/;
69
+ export const VAR_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
70
+
71
+ export const SKILL_LIMITS = {
72
+ description: 1024,
73
+ skillMd: 100_000,
74
+ files: 200,
75
+ fileBytes: 2 * 1024 * 1024,
76
+ totalBytes: 5 * 1024 * 1024,
77
+ vars: 20,
78
+ varValue: 4000,
79
+ perUser: 100,
80
+ };
81
+
82
+ /**
83
+ * 这些变量名不让技能用:要么是 agent 自己要用的(换掉就能劫持它的账号、模型或者加载的代码),
84
+ * 要么会被 agent-loft 从子进程环境里剔掉(agent-env.ts)。
85
+ */
86
+ const RESERVED_VAR =
87
+ /^(PATH|HOME|USER|LOGNAME|SHELL|PWD|LANG|TERM|TMPDIR|TZ|EDITOR|VISUAL|PAGER|HOSTNAME|SSH_AUTH_SOCK|HTTPS?_PROXY|NO_PROXY|ALL_PROXY|BASH_ENV|ENV|ZDOTDIR|PROMPT_COMMAND|PYTHONPATH|PYTHONSTARTUP|PYTHONHOME|PERL5LIB|PERL5OPT|RUBYOPT|RUBYLIB|GIT_(DIR|WORK_TREE|SSH|SSH_COMMAND|EXEC_PATH|ASKPASS|EDITOR|PAGER|TEMPLATE_DIR|CONFIG\w*))$|^(LC_|LD_|DYLD_|NODE_|NPM_|ANTHROPIC_|CLAUDE_|CODEX_|OPENAI_|AGENT_LOFT_|IM_AGENT_|FEISHU_|LARK_|BRIDGE_|TELEGRAM_|XDG_)/;
88
+
89
+ const root = path.join(dataDir, "skills");
90
+ const indexPath = path.join(root, "index.json");
91
+ const filesRoot = path.join(root, "files");
92
+ const usersRoot = path.join(root, "users");
93
+ export const pluginsRoot = path.join(root, "plugins");
94
+
95
+ /** 技能有变化。users:受影响的人(作者和装了的人),他们空闲的会话要换上新的 */
96
+ export const skillEvents = new EventEmitter<{ changed: [users: string[]] }>();
97
+
98
+ let cache: Skill[] | null = null;
99
+
100
+ function load(): Skill[] {
101
+ cache ??= readJson<Skill[]>(indexPath, []).filter((s) => s && typeof s.id === "string" && SKILL_NAME.test(s.name) && typeof s.owner === "string");
102
+ return cache;
103
+ }
104
+
105
+ function saveIndex(): void {
106
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
107
+ writeJsonAtomic(indexPath, load());
108
+ }
109
+
110
+ const checkId = (id: string, what: string) => {
111
+ if (!/^[A-Za-z0-9_]{1,64}$/.test(id)) throw new Error(`${what} id 不合法`);
112
+ return id;
113
+ };
114
+
115
+ export function skillDir(id: string): string {
116
+ return path.join(filesRoot, checkId(id, "技能"));
117
+ }
118
+
119
+ // ---------- 每个人的设置 ----------
120
+
121
+ function prefsPath(userId: string): string {
122
+ return path.join(usersRoot, `${checkId(userId, "用户")}.json`);
123
+ }
124
+
125
+ export function userPrefs(userId: string): UserSkillPrefs {
126
+ const raw = readJson<Partial<UserSkillPrefs>>(prefsPath(userId), {});
127
+ const strings = (v: unknown) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
128
+ const vars: Record<string, string> = {};
129
+ if (raw.vars && typeof raw.vars === "object") {
130
+ for (const [k, v] of Object.entries(raw.vars)) if (VAR_NAME.test(k) && typeof v === "string") vars[k] = v;
131
+ }
132
+ const approved: Record<string, string[]> = {};
133
+ if (raw.approved && typeof raw.approved === "object") {
134
+ for (const [k, v] of Object.entries(raw.approved)) if (VAR_NAME.test(k)) approved[k] = strings(v);
135
+ }
136
+ return { installed: strings(raw.installed), disabled: strings(raw.disabled), vars, approved };
137
+ }
138
+
139
+ function savePrefs(userId: string, prefs: UserSkillPrefs): void {
140
+ fs.mkdirSync(usersRoot, { recursive: true, mode: 0o700 });
141
+ writeJsonAtomic(prefsPath(userId), prefs);
142
+ }
143
+
144
+ // ---------- 查询 ----------
145
+
146
+ export type Viewer = { id: string; role: "admin" | "member" };
147
+
148
+ export function listAllSkills(): Skill[] {
149
+ return load();
150
+ }
151
+
152
+ export function getSkill(id: string): Skill | undefined {
153
+ return load().find((s) => s.id === id);
154
+ }
155
+
156
+ export function findSkillByName(name: string): Skill | undefined {
157
+ const n = name.toLowerCase();
158
+ return load().find((s) => s.name === n);
159
+ }
160
+
161
+ /** 看得到:自己的、公开的、指名给他的 */
162
+ export function canSee(viewer: Pick<Viewer, "id">, skill: Skill): boolean {
163
+ return skill.owner === viewer.id || skill.visibility === "all" || (skill.visibility === "users" && skill.sharedWith.includes(viewer.id));
164
+ }
165
+
166
+ /** 能改、能删:只有作者。管理员能下架(删)别人公开或分享出来的,但不能改内容 */
167
+ export function canDelete(viewer: Viewer, skill: Skill): boolean {
168
+ return skill.owner === viewer.id || (viewer.role === "admin" && skill.visibility !== "private");
169
+ }
170
+
171
+ /** 这个人此刻能用的技能:自己的和装了的,看得到、没停用。按名字排 */
172
+ export function activeSkills(userId: string): Skill[] {
173
+ const prefs = userPrefs(userId);
174
+ const installed = new Set(prefs.installed);
175
+ const disabled = new Set(prefs.disabled);
176
+ return load()
177
+ .filter((s) => (s.owner === userId || (installed.has(s.id) && canSee({ id: userId }, s))) && !disabled.has(s.id))
178
+ .sort((a, b) => a.name.localeCompare(b.name));
179
+ }
180
+
181
+ /** 装了这个技能的人(不含作者) */
182
+ function installersOf(id: string): string[] {
183
+ let names: string[] = [];
184
+ try {
185
+ names = fs.readdirSync(usersRoot);
186
+ } catch {
187
+ return [];
188
+ }
189
+ return names
190
+ .filter((f) => f.endsWith(".json"))
191
+ .map((f) => f.slice(0, -5))
192
+ .filter((u) => userPrefs(u).installed.includes(id));
193
+ }
194
+
195
+ export function installCount(id: string): number {
196
+ return installersOf(id).length;
197
+ }
198
+
199
+ function affected(skill: Skill): string[] {
200
+ return [...new Set([skill.owner, ...installersOf(skill.id)])];
201
+ }
202
+
203
+ // ---------- SKILL.md ----------
204
+
205
+ export type ParsedSkillMd = { name?: string; description?: string; extra: string[]; body: string };
206
+
207
+ function unquote(v: string): string {
208
+ const t = v.trim();
209
+ if (t.startsWith('"') && t.endsWith('"') && t.length >= 2) {
210
+ try {
211
+ return JSON.parse(t) as string;
212
+ } catch {
213
+ return t.slice(1, -1);
214
+ }
215
+ }
216
+ if (t.startsWith("'") && t.endsWith("'") && t.length >= 2) return t.slice(1, -1).replace(/''/g, "'");
217
+ return t;
218
+ }
219
+
220
+ /**
221
+ * 拆开 SKILL.md:frontmatter 里的 name、description 拿出来(支持 | > 多行写法),其余的键原样留在 extra 里,
222
+ * 改写时放回去(allowed-tools、metadata 之类,agent 自己认)。
223
+ */
224
+ export function parseSkillMd(text: string): ParsedSkillMd {
225
+ const src = text.replace(/^/, "").replace(/\r\n/g, "\n");
226
+ const m = /^---\n([\s\S]*?)\n---[ \t]*(?:\n|$)/.exec(src);
227
+ if (!m) return { extra: [], body: src.trim() };
228
+ const lines = m[1]!.split("\n");
229
+ const out: ParsedSkillMd = { extra: [], body: src.slice(m[0].length).trim() };
230
+ for (let i = 0; i < lines.length; i++) {
231
+ const line = lines[i]!;
232
+ const kv = /^(name|description):(.*)$/.exec(line);
233
+ if (!kv) {
234
+ out.extra.push(line);
235
+ continue;
236
+ }
237
+ let value = kv[2]!.trim();
238
+ // 续行:缩进的行都算这个键的
239
+ const cont: string[] = [];
240
+ while (i + 1 < lines.length && /^\s+\S/.test(lines[i + 1]!)) cont.push(lines[++i]!.trim());
241
+ if (/^[|>][+-]?$/.test(value)) value = cont.join(value.startsWith("|") ? "\n" : " ");
242
+ else if (cont.length) value = [value, ...cont].join(" ");
243
+ out[kv[1] as "name" | "description"] = unquote(value);
244
+ }
245
+ return out;
246
+ }
247
+
248
+ export function composeSkillMd(name: string, description: string, body: string, extra: string[] = []): string {
249
+ const fm = [`name: ${name}`, `description: ${JSON.stringify(description)}`, ...extra.filter((l) => l.trim())];
250
+ return `---\n${fm.join("\n")}\n---\n\n${body.trim()}\n`;
251
+ }
252
+
253
+ // ---------- 校验 ----------
254
+
255
+ function cleanName(raw: unknown): string {
256
+ const name = String(raw ?? "").trim().toLowerCase();
257
+ if (!SKILL_NAME.test(name)) throw new Error(`技能名「${name || "(空)"}」不合规:只能用小写字母、数字和 -,以字母或数字开头,最长 48 个字符`);
258
+ return name;
259
+ }
260
+
261
+ function cleanDescription(raw: unknown): string {
262
+ const d = String(raw ?? "").replace(/\s+/g, " ").trim();
263
+ if (!d) throw new Error("要写一句说明:agent 靠它判断什么时候用这个技能");
264
+ if (d.length > SKILL_LIMITS.description) throw new Error(`说明最多 ${SKILL_LIMITS.description} 个字`);
265
+ return d;
266
+ }
267
+
268
+ export function cleanVars(raw: unknown): SkillVar[] {
269
+ if (raw === undefined || raw === null) return [];
270
+ if (!Array.isArray(raw)) throw new Error("变量格式不对");
271
+ if (raw.length > SKILL_LIMITS.vars) throw new Error(`一个技能最多声明 ${SKILL_LIMITS.vars} 个变量`);
272
+ const out: SkillVar[] = [];
273
+ for (const r of raw as Partial<SkillVar>[]) {
274
+ const name = String(r?.name ?? "").trim();
275
+ if (!VAR_NAME.test(name)) throw new Error(`变量名「${name || "(空)"}」不合规:大写字母开头,只能用大写字母、数字和 _,2~64 个字符`);
276
+ if (RESERVED_VAR.test(name)) throw new Error(`${name} 是系统或 agent 自己要用的变量,换个名字(比如加上技能的前缀)`);
277
+ if (out.some((v) => v.name === name)) throw new Error(`变量 ${name} 重复了`);
278
+ const secret = r?.secret === true;
279
+ const hosts = secret ? cleanHosts(r?.hosts, name) : [];
280
+ out.push({
281
+ name,
282
+ description: String(r?.description ?? "").trim().slice(0, 200),
283
+ secret,
284
+ required: r?.required === true,
285
+ ...(hosts.length ? { hosts } : {}),
286
+ });
287
+ }
288
+ return out;
289
+ }
290
+
291
+ const HOST = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*(:\d{1,5})?$/;
292
+
293
+ /** 「允许发往的地址」:去掉协议和路径,只留主机(和端口)。写了 https://api.x.com/v1 也认 */
294
+ function cleanHosts(raw: unknown, name: string): string[] {
295
+ const list = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[\s,,、]+/) : [];
296
+ const out: string[] = [];
297
+ for (const item of list) {
298
+ const h = String(item ?? "").trim().toLowerCase().replace(/^[a-z]+:\/\//, "").replace(/[/?#].*$/, "");
299
+ if (!h) continue;
300
+ if (!HOST.test(h)) throw new Error(`${name} 允许发往的地址「${h}」不合规:写域名或 IP,可以带端口,比如 api.example.com、*.example.com、192.168.8.2:8080`);
301
+ if (!out.includes(h)) out.push(h);
302
+ }
303
+ if (out.length > 10) throw new Error(`${name} 允许发往的地址最多 10 个`);
304
+ return out;
305
+ }
306
+
307
+ /** 受保护的密钥:不进环境变量,只能经 skill_request 发往确认过的地址 */
308
+ export function isProtected(v: SkillVar): boolean {
309
+ return v.secret && !!v.hosts?.length;
310
+ }
311
+
312
+ /** 这个地址在不在允许的列表里:api.x.com 不限端口,带端口的要端口也对上,*.x.com 匹配子域名 */
313
+ export function hostAllowed(allowed: string[], url: URL): boolean {
314
+ const host = url.hostname.toLowerCase();
315
+ const port = url.port || (url.protocol === "https:" ? "443" : "80");
316
+ return allowed.some((entry) => {
317
+ const [h, p] = entry.split(":") as [string, string | undefined];
318
+ if (p && p !== port) return false;
319
+ return h.startsWith("*.") ? host.endsWith(h.slice(1)) && host.length > h.length - 1 : host === h;
320
+ });
321
+ }
322
+
323
+ function cleanVisibility(raw: unknown, sharedWith: unknown, owner: string): { visibility: SkillVisibility; sharedWith: string[] } {
324
+ const visibility = (["private", "users", "all"] as const).find((v) => v === raw) ?? "private";
325
+ const list = Array.isArray(sharedWith) ? [...new Set(sharedWith.filter((u): u is string => typeof u === "string" && u !== owner))] : [];
326
+ if (visibility === "users" && list.length === 0) throw new Error("选「指定用户」时至少勾一个人");
327
+ return { visibility, sharedWith: visibility === "users" ? list : [] };
328
+ }
329
+
330
+ /** 包里的相对路径:统一成 /,不许绝对路径、..、空段;隐藏的 .git 之类丢掉 */
331
+ function cleanRelPath(raw: string): string | null {
332
+ const p = raw.replace(/\\/g, "/").replace(/^\.\/+/, "");
333
+ if (!p || p.startsWith("/") || /^[A-Za-z]:/.test(p)) throw new Error(`文件路径不合规:${raw}`);
334
+ const parts = p.split("/");
335
+ if (parts.some((s) => s === ".." || s === "" || s === ".")) throw new Error(`文件路径不合规:${raw}`);
336
+ if (parts.some((s) => s === "__MACOSX" || s === ".DS_Store" || s === ".git" || s === "Thumbs.db")) return null;
337
+ if (parts.length > 8 || p.length > 200) throw new Error(`文件路径太深或太长:${raw}`);
338
+ return p;
339
+ }
340
+
341
+ export type SkillFiles = Map<string, Buffer>;
342
+
343
+ /**
344
+ * 整理上传的一堆文件:路径校验、去掉共同的顶层目录(打包时常常多包一层)、找到 SKILL.md、
345
+ * 读可选的 agent-loft.json({ "vars": [...] } 声明变量)。返回的 files 里不含这两个。
346
+ */
347
+ export function prepareUpload(entries: { path: string; data: Buffer }[]): { md: ParsedSkillMd; files: SkillFiles; vars?: SkillVar[] } {
348
+ const cleaned: { path: string; data: Buffer }[] = [];
349
+ for (const e of entries) {
350
+ const p = cleanRelPath(e.path);
351
+ if (p) cleaned.push({ path: p, data: e.data });
352
+ }
353
+ if (cleaned.length === 0) throw new Error("没有文件");
354
+ // 全都在同一个顶层目录下、且根上没有 SKILL.md:去掉那一层
355
+ const tops = new Set(cleaned.map((e) => (e.path.includes("/") ? e.path.split("/")[0] : "")));
356
+ if (tops.size === 1 && !tops.has("")) {
357
+ const top = [...tops][0]!;
358
+ for (const e of cleaned) e.path = e.path.slice(top.length + 1);
359
+ }
360
+ const mdEntry = cleaned.find((e) => e.path.toLowerCase() === "skill.md");
361
+ if (!mdEntry) throw new Error("包里要有 SKILL.md(放在最外层)");
362
+ const files: SkillFiles = new Map();
363
+ let vars: SkillVar[] | undefined;
364
+ for (const e of cleaned) {
365
+ if (e === mdEntry) continue;
366
+ if (e.path === "agent-loft.json") {
367
+ try {
368
+ vars = cleanVars((JSON.parse(e.data.toString("utf8")) as { vars?: unknown }).vars);
369
+ } catch (err) {
370
+ throw new Error(`agent-loft.json 有问题:${err instanceof Error ? err.message : err}`);
371
+ }
372
+ continue;
373
+ }
374
+ files.set(e.path, e.data);
375
+ }
376
+ return { md: parseSkillMd(mdEntry.data.toString("utf8")), files, ...(vars ? { vars } : {}) };
377
+ }
378
+
379
+ function checkSizes(md: string, files: SkillFiles): void {
380
+ if (md.length > SKILL_LIMITS.skillMd) throw new Error(`SKILL.md 太长(最多 ${SKILL_LIMITS.skillMd} 个字符)`);
381
+ if (files.size + 1 > SKILL_LIMITS.files) throw new Error(`文件太多(最多 ${SKILL_LIMITS.files} 个)`);
382
+ let total = Buffer.byteLength(md);
383
+ for (const [p, data] of files) {
384
+ if (data.length > SKILL_LIMITS.fileBytes) throw new Error(`${p} 太大(单个文件最多 ${SKILL_LIMITS.fileBytes / 1024 / 1024}MB)`);
385
+ total += data.length;
386
+ }
387
+ if (total > SKILL_LIMITS.totalBytes) throw new Error(`技能太大(合计最多 ${SKILL_LIMITS.totalBytes / 1024 / 1024}MB)`);
388
+ }
389
+
390
+ // ---------- 文件 ----------
391
+
392
+ /** 整个目录换成新的:先写到旁边,再换名字,写一半失败不会留下半截技能 */
393
+ function replaceFiles(id: string, md: string, files: SkillFiles): void {
394
+ const dir = skillDir(id);
395
+ const tmp = `${dir}.new-${process.pid}`;
396
+ const old = `${dir}.old-${process.pid}`;
397
+ fs.rmSync(tmp, { recursive: true, force: true });
398
+ fs.mkdirSync(tmp, { recursive: true, mode: 0o700 });
399
+ fs.writeFileSync(path.join(tmp, "SKILL.md"), md, { mode: 0o600 });
400
+ for (const [rel, data] of files) {
401
+ const target = path.join(tmp, rel);
402
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
403
+ // 脚本要能直接执行:看着像脚本的给执行权限
404
+ const exec = /\.(sh|py|js|mjs|rb|pl)$/.test(rel) || data.subarray(0, 2).toString() === "#!";
405
+ fs.writeFileSync(target, data, { mode: exec ? 0o700 : 0o600 });
406
+ }
407
+ fs.rmSync(old, { recursive: true, force: true });
408
+ if (fs.existsSync(dir)) fs.renameSync(dir, old);
409
+ fs.renameSync(tmp, dir);
410
+ fs.rmSync(old, { recursive: true, force: true });
411
+ }
412
+
413
+ /** 技能目录里现有的文件(不含 SKILL.md),相对路径 → 内容 */
414
+ export function readFiles(id: string): SkillFiles {
415
+ const dir = skillDir(id);
416
+ const out: SkillFiles = new Map();
417
+ const walk = (rel: string) => {
418
+ for (const ent of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) {
419
+ const p = rel ? `${rel}/${ent.name}` : ent.name;
420
+ if (ent.isDirectory()) walk(p);
421
+ else if (ent.isFile() && p !== "SKILL.md") out.set(p, fs.readFileSync(path.join(dir, p)));
422
+ }
423
+ };
424
+ try {
425
+ walk("");
426
+ } catch {
427
+ // 目录没了:当作没有附带文件
428
+ }
429
+ return out;
430
+ }
431
+
432
+ export function listFiles(id: string): { path: string; size: number }[] {
433
+ return [...readFiles(id)].map(([p, d]) => ({ path: p, size: d.length })).sort((a, b) => a.path.localeCompare(b.path));
434
+ }
435
+
436
+ export function readSkillMd(id: string): string {
437
+ try {
438
+ return fs.readFileSync(path.join(skillDir(id), "SKILL.md"), "utf8");
439
+ } catch {
440
+ return "";
441
+ }
442
+ }
443
+
444
+ /** 读一个附带文件(文本),给网页预览和 agent 的 get_skill 用 */
445
+ export function readSkillFile(id: string, rel: string): Buffer | undefined {
446
+ try {
447
+ const p = cleanRelPath(rel);
448
+ return p ? fs.readFileSync(path.join(skillDir(id), p)) : undefined;
449
+ } catch {
450
+ // 路径不合规、文件不存在:都当没有
451
+ return undefined;
452
+ }
453
+ }
454
+
455
+ // ---------- 增删改 ----------
456
+
457
+ export type SkillInput = {
458
+ name?: unknown;
459
+ description?: unknown;
460
+ /** SKILL.md 正文(不含 frontmatter) */
461
+ body?: unknown;
462
+ vars?: unknown;
463
+ visibility?: unknown;
464
+ sharedWith?: unknown;
465
+ };
466
+
467
+ function newId(): string {
468
+ return `s_${crypto.randomBytes(5).toString("hex")}`;
469
+ }
470
+
471
+ /**
472
+ * 新建。upload 给了就以包里的为准(名字、说明没另外给时从 SKILL.md 的 frontmatter 里取);
473
+ * 否则用 body 写一份新的 SKILL.md。
474
+ */
475
+ export function createSkill(
476
+ owner: string,
477
+ input: SkillInput,
478
+ source: Skill["source"],
479
+ upload?: ReturnType<typeof prepareUpload>,
480
+ ): Skill {
481
+ if (load().filter((s) => s.owner === owner).length >= SKILL_LIMITS.perUser) throw new Error(`每人最多建 ${SKILL_LIMITS.perUser} 个技能`);
482
+ const name = cleanName(input.name ?? upload?.md.name);
483
+ if (findSkillByName(name)) throw new Error(`已经有叫 ${name} 的技能了(可能是别人建的),换个名字`);
484
+ const description = cleanDescription(input.description ?? upload?.md.description);
485
+ const body = String(input.body ?? upload?.md.body ?? "").trim();
486
+ if (!body) throw new Error("技能的正文(告诉 agent 怎么做)不能为空");
487
+ const files = upload?.files ?? new Map();
488
+ const md = composeSkillMd(name, description, body, upload?.md.extra);
489
+ checkSizes(md, files);
490
+ const vars = input.vars !== undefined ? cleanVars(input.vars) : (upload?.vars ?? []);
491
+ const now = Date.now();
492
+ const skill: Skill = {
493
+ id: newId(),
494
+ name,
495
+ description,
496
+ owner,
497
+ ...cleanVisibility(input.visibility, input.sharedWith, owner),
498
+ vars,
499
+ source,
500
+ createdAt: now,
501
+ updatedAt: now,
502
+ };
503
+ replaceFiles(skill.id, md, files);
504
+ load().push(skill);
505
+ saveIndex();
506
+ skillEvents.emit("changed", [owner]);
507
+ return skill;
508
+ }
509
+
510
+ export type FileChanges = { write?: SkillFiles; remove?: string[] };
511
+
512
+ /**
513
+ * 作者改自己的技能。upload:整包换掉(名字不变,包里的 name 忽略);
514
+ * files:只增改 / 删除其中几个文件(agent 的 update_skill 用)。
515
+ */
516
+ export function updateSkill(
517
+ actor: string,
518
+ id: string,
519
+ input: SkillInput,
520
+ opts: { upload?: ReturnType<typeof prepareUpload>; files?: FileChanges } = {},
521
+ ): Skill {
522
+ const skill = getSkill(id);
523
+ if (!skill || skill.owner !== actor) throw new Error("这个技能不存在,或者不是你建的");
524
+ const before = affected(skill);
525
+ const current = parseSkillMd(readSkillMd(id));
526
+ const upload = opts.upload;
527
+ const description = input.description !== undefined ? cleanDescription(input.description) : upload?.md.description ? cleanDescription(upload.md.description) : skill.description;
528
+ const body = input.body !== undefined ? String(input.body).trim() : (upload?.md.body ?? current.body);
529
+ if (!body) throw new Error("技能的正文不能为空");
530
+ // 先把要改的元数据都校验一遍:不合规就整个不改,别落得文件换了、设置没换
531
+ const vars = input.vars !== undefined ? cleanVars(input.vars) : upload?.vars;
532
+ const visibility = input.visibility !== undefined ? cleanVisibility(input.visibility, input.sharedWith ?? skill.sharedWith, skill.owner) : undefined;
533
+ const contentChanged = input.description !== undefined || input.body !== undefined || !!upload || !!opts.files;
534
+ if (contentChanged) {
535
+ let files = upload ? upload.files : readFiles(id);
536
+ if (opts.files) {
537
+ files = new Map(files);
538
+ for (const p of opts.files.remove ?? []) {
539
+ const rel = cleanRelPath(p);
540
+ if (rel) files.delete(rel);
541
+ }
542
+ for (const [p, data] of opts.files.write ?? []) {
543
+ const rel = cleanRelPath(p);
544
+ if (!rel || rel.toLowerCase() === "skill.md") continue;
545
+ files.set(rel, data);
546
+ }
547
+ }
548
+ const md = composeSkillMd(skill.name, description, body, upload ? upload.md.extra : current.extra);
549
+ checkSizes(md, files);
550
+ replaceFiles(id, md, files);
551
+ }
552
+ skill.description = description;
553
+ if (vars) skill.vars = vars;
554
+ if (visibility) Object.assign(skill, visibility);
555
+ skill.updatedAt = Date.now();
556
+ saveIndex();
557
+ // 改之前装着的人也要通知:可能刚被取消了分享
558
+ skillEvents.emit("changed", [...new Set([...before, ...affected(skill)])]);
559
+ return skill;
560
+ }
561
+
562
+ export function deleteSkill(viewer: Viewer, id: string): void {
563
+ const skill = getSkill(id);
564
+ if (!skill || !canSee(viewer, skill) || !canDelete(viewer, skill)) throw new Error("这个技能不存在,或者你不能删它");
565
+ const users = affected(skill);
566
+ cache = load().filter((s) => s.id !== id);
567
+ saveIndex();
568
+ fs.rmSync(skillDir(id), { recursive: true, force: true });
569
+ for (const u of users) {
570
+ const prefs = userPrefs(u);
571
+ if (!prefs.installed.includes(id) && !prefs.disabled.includes(id)) continue;
572
+ savePrefs(u, { ...prefs, installed: prefs.installed.filter((x) => x !== id), disabled: prefs.disabled.filter((x) => x !== id) });
573
+ }
574
+ skillEvents.emit("changed", users);
575
+ }
576
+
577
+ export function installSkill(userId: string, id: string, on: boolean): void {
578
+ const skill = getSkill(id);
579
+ if (!skill || !canSee({ id: userId }, skill)) throw new Error("这个技能不存在");
580
+ if (skill.owner === userId) throw new Error("自己的技能不用安装");
581
+ const prefs = userPrefs(userId);
582
+ const has = prefs.installed.includes(id);
583
+ if (has === on) return;
584
+ savePrefs(userId, {
585
+ ...prefs,
586
+ installed: on ? [...prefs.installed, id] : prefs.installed.filter((x) => x !== id),
587
+ disabled: prefs.disabled.filter((x) => x !== id),
588
+ });
589
+ skillEvents.emit("changed", [userId]);
590
+ }
591
+
592
+ export function setSkillEnabled(userId: string, id: string, on: boolean): void {
593
+ const prefs = userPrefs(userId);
594
+ const has = prefs.disabled.includes(id);
595
+ if (has === !on) return;
596
+ savePrefs(userId, { ...prefs, disabled: on ? prefs.disabled.filter((x) => x !== id) : [...prefs.disabled, id] });
597
+ skillEvents.emit("changed", [userId]);
598
+ }
599
+
600
+ /** 这个人自己的和装了的技能(含停用的):填变量、确认地址都按这些算 */
601
+ function ownedOrInstalled(userId: string): Skill[] {
602
+ const installed = new Set(userPrefs(userId).installed);
603
+ return load().filter((s) => s.owner === userId || (installed.has(s.id) && canSee({ id: userId }, s)));
604
+ }
605
+
606
+ /** 这些技能里,这个变量声明过的允许地址(几个技能都声明了就合起来) */
607
+ export function declaredHosts(skills: Skill[], name: string): string[] {
608
+ const out = new Set<string>();
609
+ for (const s of skills) for (const v of s.vars) if (v.name === name && isProtected(v)) v.hosts!.forEach((h) => out.add(h));
610
+ return [...out];
611
+ }
612
+
613
+ /**
614
+ * 改变量的值。null / 空字符串 = 删掉。填了受保护密钥的值,就等于确认了它现在声明的地址;
615
+ * approve 里的变量名:值不变,只把地址确认成现在声明的(作者改过地址之后)。
616
+ */
617
+ export function setSkillVars(userId: string, patch: Record<string, unknown>, approve: string[] = []): void {
618
+ const prefs = userPrefs(userId);
619
+ const vars = { ...prefs.vars };
620
+ const approved = { ...prefs.approved };
621
+ const skills = ownedOrInstalled(userId);
622
+ for (const [k, v] of Object.entries(patch)) {
623
+ if (!VAR_NAME.test(k) || RESERVED_VAR.test(k)) throw new Error(`变量名 ${k} 不合规`);
624
+ if (v === null || v === undefined || v === "") {
625
+ delete vars[k];
626
+ delete approved[k];
627
+ continue;
628
+ }
629
+ const s = String(v);
630
+ if (s.length > SKILL_LIMITS.varValue) throw new Error(`${k} 的值太长(最多 ${SKILL_LIMITS.varValue} 个字符)`);
631
+ vars[k] = s;
632
+ const hosts = declaredHosts(skills, k);
633
+ if (hosts.length) approved[k] = hosts;
634
+ else delete approved[k];
635
+ }
636
+ for (const k of approve) {
637
+ if (vars[k] === undefined) continue;
638
+ const hosts = declaredHosts(skills, k);
639
+ if (hosts.length) approved[k] = hosts;
640
+ }
641
+ savePrefs(userId, { ...prefs, vars, approved });
642
+ skillEvents.emit("changed", [userId]);
643
+ }
644
+
645
+ /** 受保护的变量名:任何一个启用着的技能把它声明成受保护,就不进环境变量 */
646
+ export function protectedNames(skills: Skill[]): Set<string> {
647
+ return new Set(skills.flatMap((s) => s.vars.filter(isProtected).map((v) => v.name)));
648
+ }
649
+
650
+ /** 这个人的会话要注入的环境变量:只给他启用着的技能声明过的、他填了值的;受保护的密钥不放 */
651
+ export function skillEnv(userId: string, skills: Skill[] = activeSkills(userId)): Record<string, string> {
652
+ const vars = userPrefs(userId).vars;
653
+ const guarded = protectedNames(skills);
654
+ const out: Record<string, string> = {};
655
+ for (const s of skills) for (const v of s.vars) if (vars[v.name] !== undefined && !guarded.has(v.name)) out[v.name] = vars[v.name]!;
656
+ return out;
657
+ }
658
+
659
+ /** 删用户:他建的技能(装了的人也就没了)、他的设置一起删 */
660
+ export function purgeUserSkills(userId: string): void {
661
+ for (const s of load().filter((x) => x.owner === userId)) {
662
+ try {
663
+ deleteSkill({ id: userId, role: "member" }, s.id);
664
+ } catch {
665
+ // 已经没了
666
+ }
667
+ }
668
+ fs.rmSync(prefsPath(userId), { force: true });
669
+ fs.rmSync(path.join(pluginsRoot, checkId(userId, "用户")), { recursive: true, force: true });
670
+ }
671
+
672
+ /** 测试用:丢掉内存里的缓存 */
673
+ export function resetSkillCache(): void {
674
+ cache = null;
675
+ }
package/src/core/store.ts CHANGED
@@ -59,6 +59,8 @@ export type Conversation = {
59
59
  owner?: string;
60
60
  /** chat:聊天模式(/chat)。没有就是工作模式。属于对话,跨 /new 和切 agent 保留 */
61
61
  mode?: "chat";
62
+ /** 定时任务的专用对话:是哪个任务的(见 core/tasks.ts)。任务删了就清掉 */
63
+ taskId?: string;
62
64
  };
63
65
 
64
66
  type StoreShape = Record<string, Conversation>;