agent-loft 0.2.31 → 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.
- package/README.md +8 -3
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +10 -6
- package/src/agents/claude/session.ts +30 -5
- package/src/agents/codex/session.ts +27 -6
- package/src/channels/web/api.ts +4 -0
- package/src/channels/web/http.ts +2 -2
- package/src/channels/web/server.ts +8 -0
- package/src/channels/web/skills-api.ts +225 -0
- package/src/core/chat-mode.ts +3 -3
- package/src/core/command-catalog.ts +8 -1
- package/src/core/commands.ts +3 -0
- package/src/core/hub.ts +54 -5
- package/src/core/personal.ts +3 -1
- package/src/core/skill-runtime.ts +359 -0
- package/src/core/skills-bridge.ts +288 -0
- package/src/core/skills.ts +675 -0
- package/src/core/tasks-bridge.ts +5 -1
- package/src/core/tasks-mcp.ts +11 -0
- package/src/core/types.ts +20 -1
- package/src/core/zip.ts +98 -0
- package/src/main.ts +4 -0
- package/src/mcp/tasks-server.ts +125 -4
- package/web/css/skills.css +368 -0
- package/web/index.html +1 -0
- package/web/lib/dom.js +1 -0
- package/web/lib/ws.js +4 -0
- package/web/state.js +1 -1
- package/web/views/composer.js +15 -3
- package/web/views/layout.js +10 -0
- package/web/views/mention.js +193 -0
- package/web/views/messages.js +2 -2
- package/web/views/sidebar.js +1 -0
- package/web/views/skills.js +703 -0
- package/web/views/tasks.js +11 -2
- package/web/views/topbar.js +3 -3
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// 主进程这头的技能工具:agent 在对话里帮用户建、改技能(mcp/tasks-server.ts 以 skills 工具集拉起,
|
|
2
|
+
// 经 tasks-bridge 的 unix socket 转到这里)。身份校验在 tasks-bridge 里做过了。
|
|
3
|
+
//
|
|
4
|
+
// 规矩和定时任务一样:
|
|
5
|
+
// - 只碰得到这条对话主人自己的技能;能读别人的(看得到的)但改不了;
|
|
6
|
+
// - 建、改都弹授权卡让用户确认:技能会在以后的会话里被照着执行,网页里藏的指令不能就这么写进去;
|
|
7
|
+
// - 新建的技能只有自己看得到,要分享让用户自己去「技能」页面设置。
|
|
8
|
+
import { hub } from "./hub.js";
|
|
9
|
+
import { interactions } from "./interactions.js";
|
|
10
|
+
import { config } from "./config.js";
|
|
11
|
+
import { TASK_TOOL_TIMEOUT_SEC } from "./tasks-mcp.js";
|
|
12
|
+
import {
|
|
13
|
+
activeSkills,
|
|
14
|
+
canSee,
|
|
15
|
+
cleanVars,
|
|
16
|
+
createSkill,
|
|
17
|
+
findSkillByName,
|
|
18
|
+
hostAllowed,
|
|
19
|
+
isProtected,
|
|
20
|
+
protectedNames,
|
|
21
|
+
listFiles,
|
|
22
|
+
readSkillFile,
|
|
23
|
+
readSkillMd,
|
|
24
|
+
updateSkill,
|
|
25
|
+
userPrefs,
|
|
26
|
+
type FileChanges,
|
|
27
|
+
type Skill,
|
|
28
|
+
} from "./skills.js";
|
|
29
|
+
import { getUser } from "./users.js";
|
|
30
|
+
|
|
31
|
+
type Reply = { text: string; isError?: boolean };
|
|
32
|
+
const fail = (text: string): Reply => ({ text, isError: true });
|
|
33
|
+
|
|
34
|
+
export const SKILL_TOOL_NAMES = ["list_skills", "get_skill", "create_skill", "update_skill", "skill_request"];
|
|
35
|
+
|
|
36
|
+
// ---------- skill_request:替 agent 发带密钥的请求 ----------
|
|
37
|
+
|
|
38
|
+
const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"];
|
|
39
|
+
const RESPONSE_MAX = 256 * 1024;
|
|
40
|
+
const PLACEHOLDER = /\{\{\s*([A-Z][A-Z0-9_]{1,63})\s*\}\}/g;
|
|
41
|
+
|
|
42
|
+
/** 响应里出现了密钥的值(接口回显、报错里带着):换掉,别让它回到模型手里 */
|
|
43
|
+
function redact(text: string, secrets: [string, string][]): string {
|
|
44
|
+
let out = text;
|
|
45
|
+
for (const [name, value] of secrets) if (value.length >= 4) out = out.split(value).join(`[已隐藏 ${name}]`);
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 按 agent 给的 url / headers / body 发一个 HTTP 请求,里面的 {{NAME}} 换成用户填的变量值。
|
|
51
|
+
* 受保护的密钥只能发往用户确认过的地址(换完之后的地址:密钥塞进主机名里也逃不掉检查),
|
|
52
|
+
* 不跟随跳转(跳到别处会把带着密钥的请求带过去),响应里出现的变量值一律抹掉。
|
|
53
|
+
*/
|
|
54
|
+
export async function skillRequest(userId: string, args: Record<string, unknown>): Promise<Reply> {
|
|
55
|
+
const prefs = userPrefs(userId);
|
|
56
|
+
const skills = activeSkills(userId);
|
|
57
|
+
const guarded = protectedNames(skills);
|
|
58
|
+
const declared = new Set(skills.flatMap((sk) => sk.vars.map((v) => v.name)));
|
|
59
|
+
const method = String(args.method ?? "GET").toUpperCase();
|
|
60
|
+
if (!METHODS.includes(method)) return fail(`method 只能是 ${METHODS.join("、")}`);
|
|
61
|
+
const rawHeaders = args.headers && typeof args.headers === "object" ? (args.headers as Record<string, unknown>) : {};
|
|
62
|
+
const body = args.body === undefined || args.body === null ? undefined : String(args.body);
|
|
63
|
+
|
|
64
|
+
const used = new Set<string>();
|
|
65
|
+
const missing = new Set<string>();
|
|
66
|
+
const fillIn = (text: string, encode: boolean) =>
|
|
67
|
+
text.replace(PLACEHOLDER, (m, name: string) => {
|
|
68
|
+
used.add(name);
|
|
69
|
+
const value = declared.has(name) ? prefs.vars[name] : undefined;
|
|
70
|
+
if (value === undefined) {
|
|
71
|
+
missing.add(name);
|
|
72
|
+
return m;
|
|
73
|
+
}
|
|
74
|
+
return encode ? encodeURIComponent(value) : value;
|
|
75
|
+
});
|
|
76
|
+
let url: URL;
|
|
77
|
+
try {
|
|
78
|
+
url = new URL(fillIn(String(args.url ?? ""), true));
|
|
79
|
+
} catch {
|
|
80
|
+
return fail("url 不是合法的地址。");
|
|
81
|
+
}
|
|
82
|
+
const headers: Record<string, string> = {};
|
|
83
|
+
for (const [k, v] of Object.entries(rawHeaders)) headers[k] = fillIn(String(v), false);
|
|
84
|
+
const filledBody = body === undefined ? undefined : fillIn(body, false);
|
|
85
|
+
if (missing.size) {
|
|
86
|
+
return fail(`这些变量用户还没配(或者没有启用着的技能声明过):${[...missing].join("、")}。请用户去网页侧栏「技能」里填好,下一条消息起可用。`);
|
|
87
|
+
}
|
|
88
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return fail("只支持 http 和 https。");
|
|
89
|
+
for (const name of used) {
|
|
90
|
+
if (!guarded.has(name)) continue;
|
|
91
|
+
const allowed = prefs.approved[name] ?? [];
|
|
92
|
+
if (!hostAllowed(allowed, url)) {
|
|
93
|
+
return fail(
|
|
94
|
+
allowed.length
|
|
95
|
+
? `${name} 只能发往 ${allowed.join("、")},这次的地址是 ${url.host},没有发出去。`
|
|
96
|
+
: `${name} 允许发往的地址用户还没确认(技能作者可能改过),请用户去网页侧栏「技能」里确认一下。`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const secrets: [string, string][] = Object.entries(prefs.vars).filter(([k]) => declared.has(k));
|
|
102
|
+
let res: Response;
|
|
103
|
+
try {
|
|
104
|
+
res = await fetch(url, {
|
|
105
|
+
method,
|
|
106
|
+
headers,
|
|
107
|
+
...(filledBody !== undefined && method !== "GET" && method !== "HEAD" ? { body: filledBody } : {}),
|
|
108
|
+
redirect: "manual",
|
|
109
|
+
signal: AbortSignal.timeout(30_000),
|
|
110
|
+
});
|
|
111
|
+
} catch (err) {
|
|
112
|
+
return fail(`请求失败:${redact(err instanceof Error ? err.message : String(err), secrets)}`);
|
|
113
|
+
}
|
|
114
|
+
const lines = [`HTTP ${res.status} ${res.statusText}`];
|
|
115
|
+
for (const h of ["content-type", "location", "retry-after"]) {
|
|
116
|
+
const v = res.headers.get(h);
|
|
117
|
+
if (v) lines.push(`${h}: ${redact(v, secrets)}`);
|
|
118
|
+
}
|
|
119
|
+
if (res.status >= 300 && res.status < 400) lines.push("(没有跟随跳转:要的话用 location 里的地址再发一次)");
|
|
120
|
+
const type = res.headers.get("content-type") ?? "";
|
|
121
|
+
const buf = await readCapped(res, RESPONSE_MAX);
|
|
122
|
+
const textual = !type || /text|json|xml|javascript|csv|yaml|x-www-form-urlencoded/i.test(type);
|
|
123
|
+
if (method === "HEAD") return { text: lines.join("\n") };
|
|
124
|
+
if (!textual) return { text: `${lines.join("\n")}\n\n(二进制内容,${buf.length} 字节,没有返回)` };
|
|
125
|
+
const text = redact(buf.toString("utf8"), secrets);
|
|
126
|
+
return { text: `${lines.join("\n")}\n\n${text}${buf.length >= RESPONSE_MAX ? "\n…(响应太长,只返回了前 256KB)" : ""}`, ...(res.ok ? {} : { isError: true }) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function readCapped(res: Response, max: number): Promise<Buffer> {
|
|
130
|
+
if (!res.body) return Buffer.alloc(0);
|
|
131
|
+
const chunks: Buffer[] = [];
|
|
132
|
+
let size = 0;
|
|
133
|
+
const reader = res.body.getReader();
|
|
134
|
+
for (;;) {
|
|
135
|
+
const { done, value } = await reader.read();
|
|
136
|
+
if (done) break;
|
|
137
|
+
chunks.push(Buffer.from(value));
|
|
138
|
+
size += value.length;
|
|
139
|
+
if (size >= max) {
|
|
140
|
+
await reader.cancel().catch(() => {});
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return Buffer.concat(chunks).subarray(0, max);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function confirm(convKey: string, title: string, detail: string): Promise<boolean> {
|
|
148
|
+
const timeout = Math.min(config().approvalTimeoutMs, (TASK_TOOL_TIMEOUT_SEC - 60) * 1000);
|
|
149
|
+
const outcome = await interactions.create(convKey, { kind: "approval", title, detail }, timeout, hub.liveSessionId(convKey));
|
|
150
|
+
return outcome.settlement === "allow-once" || outcome.settlement === "allow-always" || outcome.settlement === "allow-session";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** agent 给的文件:[{ path, content }],content 是文本 */
|
|
154
|
+
function filesArg(raw: unknown): Map<string, Buffer> {
|
|
155
|
+
const out = new Map<string, Buffer>();
|
|
156
|
+
if (raw === undefined) return out;
|
|
157
|
+
if (!Array.isArray(raw)) throw new Error("files 要是数组:[{ path, content }]");
|
|
158
|
+
for (const f of raw as { path?: unknown; content?: unknown }[]) {
|
|
159
|
+
const p = String(f?.path ?? "").trim();
|
|
160
|
+
if (!p) throw new Error("files 里每一项都要有 path");
|
|
161
|
+
out.set(p, Buffer.from(String(f?.content ?? ""), "utf8"));
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function quote(text: string, max = 1500): string {
|
|
167
|
+
const t = text.length > max ? `${text.slice(0, max)}\n…(共 ${text.length} 字,后面省略)` : text;
|
|
168
|
+
return t.split("\n").map((l) => `> ${l}`).join("\n");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function cardOf(fields: { name: string; description?: string; body?: string; files?: string[]; removed?: string[]; vars?: Skill["vars"] }): string {
|
|
172
|
+
const lines = [`**技能**:${fields.name}`];
|
|
173
|
+
if (fields.description) lines.push(`**说明**:${fields.description}`);
|
|
174
|
+
if (fields.vars?.length) lines.push(`**要用的变量**:${fields.vars.map((v) => `${v.name}${v.secret ? "(密钥)" : ""}`).join("、")}`);
|
|
175
|
+
if (fields.files?.length) lines.push(`**写入文件**:${fields.files.join("、")}`);
|
|
176
|
+
if (fields.removed?.length) lines.push(`**删除文件**:${fields.removed.join("、")}`);
|
|
177
|
+
if (fields.body) lines.push("", "**SKILL.md 正文**:", "", quote(fields.body));
|
|
178
|
+
return lines.join("\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function describe(s: Skill, userId: string): string {
|
|
182
|
+
const vars = userPrefs(userId).vars;
|
|
183
|
+
const v = s.vars.length
|
|
184
|
+
? ` · 变量 ${s.vars.map((x) => `${x.name}${vars[x.name] !== undefined ? "(已配)" : x.required ? "(未配,必填)" : "(未配)"}${isProtected(x) ? `(受保护,只能用 skill_request 发往 ${x.hosts!.join("、")})` : ""}`).join("、")}`
|
|
185
|
+
: "";
|
|
186
|
+
return `- ${s.name}:${s.description}${s.owner === userId ? "(自己建的)" : `(${getUser(s.owner)?.name ?? "别人"}建的)`}${v}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function skillToolCall(userId: string, convKey: string, tool: string, args: Record<string, unknown>): Promise<Reply> {
|
|
190
|
+
if (tool === "skill_request") return skillRequest(userId, args);
|
|
191
|
+
if (tool === "list_skills") {
|
|
192
|
+
const list = activeSkills(userId);
|
|
193
|
+
if (!list.length) return { text: "用户在 agent-loft 里还没有启用任何技能。" };
|
|
194
|
+
return { text: `用户启用着的技能(${list.length} 个):\n${list.map((s) => describe(s, userId)).join("\n")}` };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const name = String(args.name ?? "").trim().toLowerCase();
|
|
198
|
+
if (!name) return fail("要给 name(技能名)。");
|
|
199
|
+
|
|
200
|
+
if (tool === "get_skill") {
|
|
201
|
+
const s = findSkillByName(name);
|
|
202
|
+
if (!s || !canSee({ id: userId }, s)) return fail(`没有叫 ${name} 的技能(或者用户看不到它)。`);
|
|
203
|
+
const file = typeof args.file === "string" && args.file.trim() ? args.file.trim() : "";
|
|
204
|
+
if (file) {
|
|
205
|
+
const data = readSkillFile(s.id, file);
|
|
206
|
+
if (!data) return fail(`${name} 里没有文件 ${file}。`);
|
|
207
|
+
return { text: data.toString("utf8").slice(0, 100_000) };
|
|
208
|
+
}
|
|
209
|
+
const files = listFiles(s.id);
|
|
210
|
+
return {
|
|
211
|
+
text: [
|
|
212
|
+
describe(s, userId),
|
|
213
|
+
"",
|
|
214
|
+
"SKILL.md:",
|
|
215
|
+
"",
|
|
216
|
+
readSkillMd(s.id),
|
|
217
|
+
...(files.length ? ["", `附带文件(用 file 参数读):${files.map((f) => `${f.path}(${f.size} 字节)`).join("、")}`] : []),
|
|
218
|
+
].join("\n"),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (tool === "create_skill") {
|
|
223
|
+
let files: Map<string, Buffer>;
|
|
224
|
+
let vars: Skill["vars"];
|
|
225
|
+
try {
|
|
226
|
+
files = filesArg(args.files);
|
|
227
|
+
vars = cleanVars(args.vars);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
230
|
+
}
|
|
231
|
+
const description = String(args.description ?? "").trim();
|
|
232
|
+
const body = String(args.body ?? "").trim();
|
|
233
|
+
if (findSkillByName(name)) return fail(`已经有叫 ${name} 的技能了(可能是别人建的),换个名字;要改自己的用 update_skill。`);
|
|
234
|
+
const ok = await confirm(convKey, `新建技能「${name}」`, `${cardOf({ name, description, body, files: [...files.keys()], vars })}\n\n建好后只有你自己看得到,可以在网页侧栏「技能」里修改、分享给别人。`);
|
|
235
|
+
if (!ok) return fail("用户没有同意,技能没有建。");
|
|
236
|
+
try {
|
|
237
|
+
const skill = createSkill(userId, { name, description, body, vars }, "agent", files.size ? { md: { extra: [], body }, files } : undefined);
|
|
238
|
+
return {
|
|
239
|
+
text: `已建好技能 ${skill.name}(只有用户自己看得到)。这一轮结束后会话会自动重新加载,之后就能用了;用户也可以在输入框里 @${skill.name} 点名用它。${skill.vars.length ? `它要用的变量请用户去网页侧栏「技能」里填:${skill.vars.map((v) => v.name).join("、")}。` : ""}`,
|
|
240
|
+
};
|
|
241
|
+
} catch (err) {
|
|
242
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (tool === "update_skill") {
|
|
247
|
+
const s = findSkillByName(name);
|
|
248
|
+
if (!s || s.owner !== userId) return fail(`${name} 不存在,或者不是用户自己建的(只能改自己的技能)。`);
|
|
249
|
+
let changes: FileChanges;
|
|
250
|
+
let vars: Skill["vars"] | undefined;
|
|
251
|
+
try {
|
|
252
|
+
const write = filesArg(args.files);
|
|
253
|
+
const remove = Array.isArray(args.delete_files) ? args.delete_files.map(String) : [];
|
|
254
|
+
changes = { ...(write.size ? { write } : {}), ...(remove.length ? { remove } : {}) };
|
|
255
|
+
vars = args.vars !== undefined ? cleanVars(args.vars) : undefined;
|
|
256
|
+
} catch (err) {
|
|
257
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
258
|
+
}
|
|
259
|
+
const description = typeof args.description === "string" ? args.description.trim() : undefined;
|
|
260
|
+
const body = typeof args.body === "string" ? args.body.trim() : undefined;
|
|
261
|
+
if (description === undefined && body === undefined && !changes.write && !changes.remove && !vars) return fail("没有要改的内容。");
|
|
262
|
+
const ok = await confirm(
|
|
263
|
+
convKey,
|
|
264
|
+
`修改技能「${name}」`,
|
|
265
|
+
`${cardOf({
|
|
266
|
+
name,
|
|
267
|
+
...(description ? { description } : {}),
|
|
268
|
+
...(body ? { body } : {}),
|
|
269
|
+
...(changes.write ? { files: [...changes.write.keys()] } : {}),
|
|
270
|
+
...(changes.remove ? { removed: changes.remove } : {}),
|
|
271
|
+
...(vars ? { vars } : {}),
|
|
272
|
+
})}${s.visibility !== "private" ? "\n\n这个技能分享给了别人,改了之后装了它的人也会用上新的。" : ""}`,
|
|
273
|
+
);
|
|
274
|
+
if (!ok) return fail("用户没有同意,技能没有改。");
|
|
275
|
+
try {
|
|
276
|
+
updateSkill(
|
|
277
|
+
userId,
|
|
278
|
+
s.id,
|
|
279
|
+
{ ...(description !== undefined ? { description } : {}), ...(body !== undefined ? { body } : {}), ...(vars ? { vars } : {}) },
|
|
280
|
+
changes.write || changes.remove ? { files: changes } : {},
|
|
281
|
+
);
|
|
282
|
+
return { text: `已更新技能 ${name},这一轮结束后会话会自动重新加载。` };
|
|
283
|
+
} catch (err) {
|
|
284
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return fail(`没有工具 ${tool}`);
|
|
288
|
+
}
|