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.
- package/README.md +11 -1
- package/package.json +1 -1
- package/src/agents/claude/driver.ts +13 -8
- package/src/agents/claude/session.ts +38 -6
- package/src/agents/codex/session.ts +32 -5
- package/src/channels/feishu/bot.ts +8 -4
- package/src/channels/feishu/turn-stream.ts +3 -2
- package/src/channels/web/api.ts +8 -0
- package/src/channels/web/http.ts +2 -2
- package/src/channels/web/server.ts +21 -1
- package/src/channels/web/skills-api.ts +225 -0
- package/src/channels/web/tasks-api.ts +94 -0
- package/src/core/chat-mode.ts +16 -16
- package/src/core/command-catalog.ts +8 -1
- package/src/core/commands.ts +3 -0
- package/src/core/hub.ts +147 -19
- package/src/core/memory-mcp.ts +1 -1
- package/src/core/personal.ts +5 -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/store.ts +2 -0
- package/src/core/task-prompt.ts +38 -0
- package/src/core/task-runner.ts +232 -0
- package/src/core/task-schedule.ts +250 -0
- package/src/core/tasks-bridge.ts +256 -0
- package/src/core/tasks-mcp.ts +66 -0
- package/src/core/tasks.ts +281 -0
- package/src/core/types.ts +21 -0
- package/src/core/zip.ts +98 -0
- package/src/main.ts +12 -0
- package/src/mcp/tasks-server.ts +327 -0
- package/web/css/skills.css +368 -0
- package/web/css/tasks.css +455 -0
- package/web/index.html +2 -0
- package/web/lib/dom.js +1 -0
- package/web/lib/ws.js +8 -0
- package/web/state.js +2 -0
- package/web/views/composer.js +15 -3
- package/web/views/conv-actions.js +15 -7
- package/web/views/layout.js +22 -1
- package/web/views/mention.js +193 -0
- package/web/views/messages.js +23 -2
- package/web/views/sidebar.js +4 -0
- package/web/views/skills.js +703 -0
- package/web/views/tasks.js +469 -0
- package/web/views/topbar.js +9 -3
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// 技能怎么交到 agent 手上:开会话时挂哪些、注入哪些变量;消息里 @ 了技能时附上什么;
|
|
2
|
+
// 机器上原有的技能(~/.claude/skills、~/.agents/skills 这些)只给管理员。
|
|
3
|
+
//
|
|
4
|
+
// 两个 agent 的做法不一样(都实测过):
|
|
5
|
+
// - Claude:每个用户一个插件目录(data/skills/plugins/<用户>),里面链接着他启用的技能,开会话时用
|
|
6
|
+
// plugins 挂上,技能名是 loft:<name>。成员的会话再用 skills 白名单只留 agent-loft 的技能、Claude Code
|
|
7
|
+
// 自带的和项目目录里的 —— 机器上装的那些模型看不到、Skill 工具也调不了。但「/技能名」会被 CLI
|
|
8
|
+
// 直接展开,绕过白名单,所以 hub 在发出去之前还要拦一道(blockedSlash)。
|
|
9
|
+
// - Codex:所有人共用一个 app-server,技能目录是全局的,没法按人挂;turn 里的 skill 输入也只认它自己
|
|
10
|
+
// 发现的技能。所以技能清单(名字、说明、SKILL.md 路径)写进附在第一条消息后面的背景里,用法和
|
|
11
|
+
// Codex 自己的技能一样:要用时先读 SKILL.md。成员的会话关掉 Codex 自己的技能清单。
|
|
12
|
+
// 变量用 thread 的配置覆盖 shell_environment_policy.set 注入,只作用于这个 thread 里跑的命令。
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { config } from "./config.js";
|
|
17
|
+
import { activeSkills, parseSkillMd, pluginsRoot, protectedNames, skillDir, skillEnv, userPrefs, type Skill } from "./skills.js";
|
|
18
|
+
import { SKILLS_MCP_NAME } from "./tasks-mcp.js";
|
|
19
|
+
import type { SessionSkills } from "./types.js";
|
|
20
|
+
import { getUser } from "./users.js";
|
|
21
|
+
|
|
22
|
+
/** Claude 插件名:技能在 Claude 里叫 loft:<name> */
|
|
23
|
+
export const PLUGIN_NAME = "loft";
|
|
24
|
+
|
|
25
|
+
// ---------- 机器上原有的技能 ----------
|
|
26
|
+
|
|
27
|
+
export type MachineSkill = { name: string; description: string; dir: string; agents: ("claude" | "codex")[] };
|
|
28
|
+
|
|
29
|
+
function frontmatterOf(file: string): { name?: string; description?: string } {
|
|
30
|
+
try {
|
|
31
|
+
const md = parseSkillMd(fs.readFileSync(file, "utf8").slice(0, 20_000));
|
|
32
|
+
return { ...(md.name ? { name: md.name } : {}), ...(md.description ? { description: md.description } : {}) };
|
|
33
|
+
} catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 某个目录下一层的技能:<dir>/<名字>/SKILL.md */
|
|
39
|
+
function scanDir(dir: string, agent: "claude" | "codex", out: Map<string, MachineSkill>): void {
|
|
40
|
+
let entries: fs.Dirent[];
|
|
41
|
+
try {
|
|
42
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
43
|
+
} catch {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
for (const ent of entries) {
|
|
47
|
+
if (ent.name.startsWith(".")) continue;
|
|
48
|
+
const sub = path.join(dir, ent.name);
|
|
49
|
+
const md = path.join(sub, "SKILL.md");
|
|
50
|
+
if (!fs.existsSync(md)) continue;
|
|
51
|
+
let real = sub;
|
|
52
|
+
try {
|
|
53
|
+
real = fs.realpathSync(sub);
|
|
54
|
+
} catch {
|
|
55
|
+
// 断掉的链接:照原路径算
|
|
56
|
+
}
|
|
57
|
+
const hit = out.get(real);
|
|
58
|
+
if (hit) {
|
|
59
|
+
if (!hit.agents.includes(agent)) hit.agents.push(agent);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const fm = frontmatterOf(md);
|
|
63
|
+
out.set(real, { name: fm.name || ent.name, description: fm.description ?? "", dir: real, agents: [agent] });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let machineCache: { at: number; list: MachineSkill[] } | null = null;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 机器上原有的技能:Claude 的 ~/.claude/skills(含从 claude.ai 同步下来的 synced/<id>/<名字>)、
|
|
71
|
+
* Codex 的 ~/.codex/skills 和 ~/.agents/skills。Codex 自带的 .system 不算(那是 Codex 本身的一部分)。
|
|
72
|
+
* 插件市场装的技能不在这里列,但一样只给管理员(Claude 成员会话的白名单里没有它们)。缓存一分钟。
|
|
73
|
+
*/
|
|
74
|
+
export function machineSkills(): MachineSkill[] {
|
|
75
|
+
if (machineCache && Date.now() - machineCache.at < 60_000) return machineCache.list;
|
|
76
|
+
const out = new Map<string, MachineSkill>();
|
|
77
|
+
const claudeHome = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
|
|
78
|
+
scanDir(path.join(claudeHome, "skills"), "claude", out);
|
|
79
|
+
let synced: string[] = [];
|
|
80
|
+
try {
|
|
81
|
+
synced = fs.readdirSync(path.join(claudeHome, "skills", "synced"));
|
|
82
|
+
} catch {
|
|
83
|
+
// 没有同步的技能
|
|
84
|
+
}
|
|
85
|
+
for (const s of synced) scanDir(path.join(claudeHome, "skills", "synced", s), "claude", out);
|
|
86
|
+
const codexHome = config().agents.codex.home || path.join(os.homedir(), ".codex");
|
|
87
|
+
scanDir(path.join(codexHome, "skills"), "codex", out);
|
|
88
|
+
scanDir(path.join(os.homedir(), ".agents", "skills"), "codex", out);
|
|
89
|
+
const list = [...out.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
90
|
+
machineCache = { at: Date.now(), list };
|
|
91
|
+
return list;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 项目目录里的 Claude 技能 / 命令名(<cwd>/.claude/skills/*、.claude/commands/*.md):成员也能用 */
|
|
95
|
+
export function projectCommandNames(cwd: string): string[] {
|
|
96
|
+
const names: string[] = [];
|
|
97
|
+
try {
|
|
98
|
+
for (const ent of fs.readdirSync(path.join(cwd, ".claude", "skills"), { withFileTypes: true })) {
|
|
99
|
+
if (!ent.name.startsWith(".")) names.push(ent.name);
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// 没有
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
for (const f of fs.readdirSync(path.join(cwd, ".claude", "commands"))) if (f.endsWith(".md")) names.push(f.slice(0, -3));
|
|
106
|
+
} catch {
|
|
107
|
+
// 没有
|
|
108
|
+
}
|
|
109
|
+
return names;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------- 插件目录 ----------
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 把这个人启用的技能整理成一个 Claude 插件目录:skills/<name> 链接到技能本体(作者一改,这里就是新的)。
|
|
116
|
+
* 每次开会话时对一遍账:多的删、少的补,正在用这个目录的其他会话不受影响。
|
|
117
|
+
*/
|
|
118
|
+
export function syncPluginDir(userId: string, skills: Skill[]): string {
|
|
119
|
+
const dir = path.join(pluginsRoot, userId);
|
|
120
|
+
const skillsDir = path.join(dir, "skills");
|
|
121
|
+
fs.mkdirSync(path.join(dir, ".claude-plugin"), { recursive: true, mode: 0o700 });
|
|
122
|
+
fs.mkdirSync(skillsDir, { recursive: true, mode: 0o700 });
|
|
123
|
+
const manifest = path.join(dir, ".claude-plugin", "plugin.json");
|
|
124
|
+
const body = JSON.stringify({ name: PLUGIN_NAME, version: "1.0.0", description: "agent-loft 里安装的技能" }, null, 2);
|
|
125
|
+
if (!fs.existsSync(manifest) || fs.readFileSync(manifest, "utf8") !== body) fs.writeFileSync(manifest, body, { mode: 0o600 });
|
|
126
|
+
const want = new Map(skills.map((s) => [s.name, skillDir(s.id)]));
|
|
127
|
+
for (const name of fs.readdirSync(skillsDir)) {
|
|
128
|
+
const link = path.join(skillsDir, name);
|
|
129
|
+
let target = "";
|
|
130
|
+
try {
|
|
131
|
+
target = fs.readlinkSync(link);
|
|
132
|
+
} catch {
|
|
133
|
+
// 不是链接(链接建不了时复制的):一律重建
|
|
134
|
+
}
|
|
135
|
+
if (want.get(name) !== target || !target) fs.rmSync(link, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
for (const [name, target] of want) {
|
|
138
|
+
const link = path.join(skillsDir, name);
|
|
139
|
+
if (fs.existsSync(link)) continue;
|
|
140
|
+
try {
|
|
141
|
+
// Windows 上普通用户建不了符号链接,目录联接(junction)可以
|
|
142
|
+
fs.symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
|
|
143
|
+
} catch {
|
|
144
|
+
fs.cpSync(target, link, { recursive: true });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return dir;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------- 开会话 ----------
|
|
151
|
+
|
|
152
|
+
/** 受保护的密钥怎么用:只能经 skill_request 代发。没有就是空 */
|
|
153
|
+
function protectedLines(skills: Skill[], userId: string): string[] {
|
|
154
|
+
const prefs = userPrefs(userId);
|
|
155
|
+
const names = [...protectedNames(skills)].filter((n) => prefs.vars[n] !== undefined);
|
|
156
|
+
if (!names.length) return [];
|
|
157
|
+
return [
|
|
158
|
+
`这些密钥受保护,不在环境变量里,你也看不到值:${names.map((n) => `${n}(只能发往 ${(prefs.approved[n] ?? []).join("、") || "用户还没确认的地址"})`).join(";")}。` +
|
|
159
|
+
`要调用接口时用 ${SKILLS_MCP_NAME} 的 skill_request 工具,在 url、headers 或 body 里写 {{变量名}},agent-loft 发请求时才换成真值。技能说明里写的是 curl $变量名 的,换成 skill_request 来发。`,
|
|
160
|
+
];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function varsPrompt(skills: Skill[], env: Record<string, string>, userId: string): string | undefined {
|
|
164
|
+
const set = new Set<string>();
|
|
165
|
+
const missing = new Map<string, string[]>();
|
|
166
|
+
const values = userPrefs(userId).vars;
|
|
167
|
+
for (const s of skills) {
|
|
168
|
+
for (const v of s.vars) {
|
|
169
|
+
if (env[v.name] !== undefined) set.add(v.name);
|
|
170
|
+
else if (v.required && values[v.name] === undefined) missing.set(v.name, [...(missing.get(v.name) ?? []), s.name]);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const guarded = protectedLines(skills, userId);
|
|
174
|
+
if (set.size === 0 && missing.size === 0 && !guarded.length) return undefined;
|
|
175
|
+
const lines = ["## 技能变量", ""];
|
|
176
|
+
if (set.size) lines.push(`这些环境变量用户已经在 agent-loft 里配好了,命令里直接用(比如 $${[...set][0]}),不要把值打印出来或写进文件:${[...set].join("、")}。`);
|
|
177
|
+
lines.push(...guarded);
|
|
178
|
+
if (missing.size) {
|
|
179
|
+
lines.push(
|
|
180
|
+
`这些变量还没配:${[...missing].map(([n, by]) => `${n}(${by.join("、")} 要用)`).join(";")}。用到时告诉用户去网页侧栏「技能」里找到对应的技能填上,填好后下一条消息起生效。`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return lines.join("\n");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 开会话时给 agent 的技能(SessionOptions.skills)。owner 是这条对话的主人(personal.ts 的 memoryOwnerOf);
|
|
188
|
+
* 没有主人的对话(飞书群聊等,只有管理员能配)照旧:机器上的技能都在,没有 agent-loft 的技能。
|
|
189
|
+
*/
|
|
190
|
+
export function sessionSkills(owner: string | undefined): SessionSkills | undefined {
|
|
191
|
+
if (!owner) return undefined;
|
|
192
|
+
const user = getUser(owner);
|
|
193
|
+
if (!user) return undefined;
|
|
194
|
+
const admin = user.role === "admin";
|
|
195
|
+
const skills = activeSkills(owner);
|
|
196
|
+
const env = skillEnv(owner, skills);
|
|
197
|
+
const prompt = varsPrompt(skills, env, owner);
|
|
198
|
+
let pluginDir: string | undefined;
|
|
199
|
+
if (skills.length) {
|
|
200
|
+
try {
|
|
201
|
+
pluginDir = syncPluginDir(owner, skills);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.error("[技能] 整理插件目录失败:", err instanceof Error ? err.message : err);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
userId: owner,
|
|
208
|
+
admin,
|
|
209
|
+
...(pluginDir ? { pluginDir } : {}),
|
|
210
|
+
list: skills.map((s) => ({ name: s.name, description: s.description, path: path.join(skillDir(s.id), "SKILL.md") })),
|
|
211
|
+
env,
|
|
212
|
+
...(prompt ? { prompt } : {}),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Codex 背景里的技能清单 */
|
|
217
|
+
export function codexSkillLines(skills: SessionSkills | undefined): string[] {
|
|
218
|
+
if (!skills || (!skills.list.length && !skills.prompt)) return [];
|
|
219
|
+
const lines: string[] = [];
|
|
220
|
+
if (skills.list.length) {
|
|
221
|
+
lines.push(
|
|
222
|
+
"## 技能",
|
|
223
|
+
"",
|
|
224
|
+
"用户在 agent-loft 里启用了下面这些技能。任务和某个技能的说明对得上时,先完整读一遍它的 SKILL.md,再照着做(相对路径以 SKILL.md 所在目录为准):",
|
|
225
|
+
...skills.list.map((s) => `- ${s.name}:${s.description}(${s.path})`),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (skills.prompt) lines.push("", skills.prompt);
|
|
229
|
+
return lines;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---------- @ 技能 ----------
|
|
233
|
+
|
|
234
|
+
export type MentionTarget = { name: string; description: string; kind: "loft" | "machine"; skillMd: string; agents?: ("claude" | "codex")[] };
|
|
235
|
+
|
|
236
|
+
/** 这个人能 @ 的技能:启用着的 agent-loft 技能;管理员再加上机器上原有的(同名时 agent-loft 的优先) */
|
|
237
|
+
export function mentionTargets(owner: string | undefined): MentionTarget[] {
|
|
238
|
+
if (!owner) return [];
|
|
239
|
+
const user = getUser(owner);
|
|
240
|
+
if (!user) return [];
|
|
241
|
+
const out: MentionTarget[] = activeSkills(owner).map((s) => ({
|
|
242
|
+
name: s.name,
|
|
243
|
+
description: s.description,
|
|
244
|
+
kind: "loft",
|
|
245
|
+
skillMd: path.join(skillDir(s.id), "SKILL.md"),
|
|
246
|
+
}));
|
|
247
|
+
if (user.role === "admin") {
|
|
248
|
+
const taken = new Set(out.map((t) => t.name));
|
|
249
|
+
for (const m of machineSkills()) {
|
|
250
|
+
if (taken.has(m.name)) continue;
|
|
251
|
+
taken.add(m.name);
|
|
252
|
+
out.push({ name: m.name, description: m.description, kind: "machine", skillMd: path.join(m.dir, "SKILL.md"), agents: m.agents });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** 消息里 @ 到的技能名(前面是开头、空白或标点,不是邮箱里的 @),按出现顺序去重 */
|
|
259
|
+
export function parseMentions(text: string): string[] {
|
|
260
|
+
const out: string[] = [];
|
|
261
|
+
for (const m of text.matchAll(/(?:^|[^\w.@/-])@([a-z0-9][a-z0-9_-]*)/gi)) {
|
|
262
|
+
const name = m[1]!.toLowerCase();
|
|
263
|
+
if (!out.includes(name)) out.push(name);
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const INLINE_MAX = 30_000;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 消息里 @ 了技能:附给 agent 的说明(包进 wrapContext 之前的那几行)。没 @ 到能用的就是 null。
|
|
272
|
+
* 聊天模式没有工具,直接把 SKILL.md 的内容放进来。
|
|
273
|
+
*/
|
|
274
|
+
export function mentionContext(
|
|
275
|
+
text: string,
|
|
276
|
+
opts: { owner: string | undefined; agent: string; mode: "chat" | "work" },
|
|
277
|
+
): string[] | null {
|
|
278
|
+
const names = parseMentions(text);
|
|
279
|
+
if (!names.length) return null;
|
|
280
|
+
const targets = mentionTargets(opts.owner);
|
|
281
|
+
const hits = names.map((n) => targets.find((t) => t.name === n)).filter((t): t is MentionTarget => !!t);
|
|
282
|
+
if (!hits.length) return null;
|
|
283
|
+
if (opts.mode === "chat") {
|
|
284
|
+
const lines = [
|
|
285
|
+
`用户在这条消息里点名要用技能:${hits.map((t) => t.name).join("、")}。下面是技能的说明。现在是聊天模式,不能执行命令、读写文件:说明里能直接做的照着做,需要执行命令、读文件的步骤告诉用户发 /work 切到工作模式再来。`,
|
|
286
|
+
];
|
|
287
|
+
let budget = INLINE_MAX;
|
|
288
|
+
for (const t of hits) {
|
|
289
|
+
let md = "";
|
|
290
|
+
try {
|
|
291
|
+
md = fs.readFileSync(t.skillMd, "utf8");
|
|
292
|
+
} catch {
|
|
293
|
+
md = "(读不到这个技能的 SKILL.md)";
|
|
294
|
+
}
|
|
295
|
+
if (md.length > budget) md = `${md.slice(0, Math.max(0, budget))}\n…(太长,后面省略)`;
|
|
296
|
+
budget -= md.length;
|
|
297
|
+
lines.push("", `### 技能 ${t.name}`, "", md.trim());
|
|
298
|
+
}
|
|
299
|
+
// 受保护的密钥走 skill_request(MCP 工具聊天模式下也有),调接口的技能在聊天模式也能用
|
|
300
|
+
const mentioned = activeSkills(opts.owner!).filter((sk) => hits.some((t) => t.kind === "loft" && t.name === sk.name));
|
|
301
|
+
const guarded = protectedLines(mentioned, opts.owner!);
|
|
302
|
+
if (guarded.length) lines.push("", ...guarded);
|
|
303
|
+
return lines;
|
|
304
|
+
}
|
|
305
|
+
if (opts.agent === "claude") {
|
|
306
|
+
// Claude 认得的:agent-loft 的(插件里)和它自己目录里的;只在 Codex 目录里的它不认得,让它去读文件
|
|
307
|
+
const known = hits.filter((t) => t.kind === "loft" || t.agents?.includes("claude"));
|
|
308
|
+
const other = hits.filter((t) => !known.includes(t));
|
|
309
|
+
const lines = [`用户在这条消息里点名要用技能:${hits.map((t) => t.name).join("、")}。`];
|
|
310
|
+
if (known.length) {
|
|
311
|
+
const refs = known.map((t) => (t.kind === "loft" ? `${PLUGIN_NAME}:${t.name}` : t.name));
|
|
312
|
+
lines.push(`先用 Skill 工具调用 ${refs.join("、")},再按技能的说明完成用户的要求。`);
|
|
313
|
+
}
|
|
314
|
+
if (other.length) lines.push("这些先完整读一遍 SKILL.md 再照着做(相对路径以 SKILL.md 所在目录为准):", ...other.map((t) => `- ${t.name}:${t.skillMd}`));
|
|
315
|
+
return lines;
|
|
316
|
+
}
|
|
317
|
+
return [
|
|
318
|
+
`用户在这条消息里点名要用技能:${hits.map((t) => t.name).join("、")}。先完整读一遍对应的 SKILL.md,再照着做(相对路径以 SKILL.md 所在目录为准):`,
|
|
319
|
+
...hits.map((t) => `- ${t.name}:${t.skillMd}`),
|
|
320
|
+
];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 成员在 Claude 会话里直接发「/机器上的技能」:CLI 会绕过白名单直接展开,这里先拦下。
|
|
325
|
+
* native 是 Claude 报上来的命令表;读不到(超时、出错)时还按扫出来的机器技能名拦,不能因此放行。
|
|
326
|
+
*/
|
|
327
|
+
export function isMachineCommand(name: string, native: { name: string; builtin?: boolean }[], cwd: string): boolean {
|
|
328
|
+
const n = name.toLowerCase();
|
|
329
|
+
if (n.startsWith(`${PLUGIN_NAME}:`)) return false;
|
|
330
|
+
const bare = n.slice(n.lastIndexOf(":") + 1);
|
|
331
|
+
const hit = native.find((c) => c.name.toLowerCase() === n || c.name.toLowerCase().endsWith(`:${n}`) || n.endsWith(`:${c.name.toLowerCase()}`));
|
|
332
|
+
if (hit?.builtin) return false;
|
|
333
|
+
if (projectCommandNames(cwd).some((p) => p.toLowerCase() === n)) return false;
|
|
334
|
+
if (hit) return true;
|
|
335
|
+
return machineSkills().some((m) => m.name.toLowerCase() === bare);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** /skills 的回复:这条对话里能 @ 的技能 */
|
|
339
|
+
export function skillsText(owner: string | undefined): string {
|
|
340
|
+
if (!owner) return "这条对话没有绑定 agent-loft 用户,用不了 agent-loft 的技能。飞书私聊里发 /bind 绑定账号后就能用。";
|
|
341
|
+
const targets = mentionTargets(owner);
|
|
342
|
+
const loft = targets.filter((t) => t.kind === "loft");
|
|
343
|
+
const machine = targets.filter((t) => t.kind === "machine");
|
|
344
|
+
const lines: string[] = [];
|
|
345
|
+
if (loft.length) {
|
|
346
|
+
lines.push(`**能用的技能(${loft.length} 个)**`, "", ...loft.map((t) => `- \`@${t.name}\` ${t.description}`));
|
|
347
|
+
} else {
|
|
348
|
+
lines.push("还没有启用任何技能。可以在网页侧栏「技能」里新建、上传,或者从技能广场安装;也可以直接让 agent「把刚才的做法存成技能」。");
|
|
349
|
+
}
|
|
350
|
+
if (machine.length) lines.push("", `**本机技能(只有管理员能用,${machine.length} 个)**:${machine.map((t) => `\`@${t.name}\``).join(" ")}`);
|
|
351
|
+
lines.push("", "在消息里写 @名字 点名使用;工作模式下 agent 也会自己挑合适的用。");
|
|
352
|
+
return lines.join("\n");
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** 测试用 */
|
|
356
|
+
export function resetMachineCache(): void {
|
|
357
|
+
machineCache = null;
|
|
358
|
+
}
|
|
359
|
+
|
|
@@ -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
|
+
}
|