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
package/src/core/hub.ts
CHANGED
|
@@ -46,7 +46,9 @@ import type {
|
|
|
46
46
|
import { buildCatalog, expandCustomCommand, type AgentCommand, type CommandEntry, type CommandPrefs } from "./command-catalog.js";
|
|
47
47
|
import { helpText, type ParsedCommand } from "./commands.js";
|
|
48
48
|
import { listScheduled } from "./scheduled.js";
|
|
49
|
-
import { stripContext } from "./chat-mode.js";
|
|
49
|
+
import { stripContext, wrapContext } from "./chat-mode.js";
|
|
50
|
+
import { isMachineCommand, mentionContext, sessionSkills, skillsText } from "./skill-runtime.js";
|
|
51
|
+
import { skillEvents } from "./skills.js";
|
|
50
52
|
import { isSilentReply } from "./task-prompt.js";
|
|
51
53
|
|
|
52
54
|
// ---------- 事件 ----------
|
|
@@ -227,6 +229,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
227
229
|
constructor() {
|
|
228
230
|
super();
|
|
229
231
|
this.setMaxListeners(100);
|
|
232
|
+
// 技能装了、改了、停用了、变量改了:受影响的人空闲的会话换上新的
|
|
233
|
+
skillEvents.on("changed", (users) => this.personalChanged(new Set(users)));
|
|
230
234
|
// workspaceRoot 改了:store 会把没显式 /cd 过的会话目录重置,正在跑的旧目录会话要作废
|
|
231
235
|
onConfigChange((next, prev) => {
|
|
232
236
|
// 默认模型可能改了,缓存作废,下次显示时重新读
|
|
@@ -520,6 +524,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
520
524
|
const memory = sessionMemory(conv);
|
|
521
525
|
// 定时任务工具:和记忆一样只给有主人的对话(飞书群聊里谁都能让机器人建任务,算不清是谁的)
|
|
522
526
|
const taskOwner = memoryOwnerOf(conv);
|
|
527
|
+
// 技能也跟着主人走:他启用的技能、填的变量;成员看不到机器上原有的技能(见 skill-runtime.ts)
|
|
528
|
+
const skills = sessionSkills(taskOwner);
|
|
523
529
|
const mode = conv.mode ?? "work";
|
|
524
530
|
// 续着的会话上次被告知的模式和现在不一样(新会话没有历史,算原装的工作模式)
|
|
525
531
|
const announceMode = (resumeId ? (binding.announcedMode ?? "work") : "work") !== mode;
|
|
@@ -543,6 +549,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
543
549
|
...(memory ? { memory } : {}),
|
|
544
550
|
...(announceMode ? { announceMode: true } : {}),
|
|
545
551
|
...(taskOwner ? { tasks: { userId: taskOwner, convKey: key } } : {}),
|
|
552
|
+
...(skills ? { skills } : {}),
|
|
546
553
|
},
|
|
547
554
|
{
|
|
548
555
|
onTurnStart: () => {
|
|
@@ -875,10 +882,40 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
875
882
|
// 清理要等旧会话 dispose,期间可能又点了停止
|
|
876
883
|
if (cancelled()) return;
|
|
877
884
|
}
|
|
878
|
-
this.
|
|
885
|
+
const blocked = await this.blockedSlash(key, input.text);
|
|
886
|
+
if (blocked) {
|
|
887
|
+
this.emitEvent(key, { type: "notice", text: blocked });
|
|
888
|
+
return gone(blocked);
|
|
889
|
+
}
|
|
890
|
+
this.sendNow(key, this.withMentions(key, input), task ?? null);
|
|
879
891
|
});
|
|
880
892
|
}
|
|
881
893
|
|
|
894
|
+
/**
|
|
895
|
+
* 消息里 @ 了技能:附一段背景告诉 agent 用哪个、怎么用(用户看不到,见 skill-runtime.ts 的 mentionContext)。
|
|
896
|
+
* 按发出去这一刻对话的 agent 和模式拼:排队期间可能刚切过。
|
|
897
|
+
*/
|
|
898
|
+
private withMentions(key: string, input: UserInput): UserInput {
|
|
899
|
+
const conv = getConversation(key);
|
|
900
|
+
if (!conv) return input;
|
|
901
|
+
const lines = mentionContext(stripContext(input.text), { owner: memoryOwnerOf(conv), agent: conv.agent, mode: conv.mode ?? "work" });
|
|
902
|
+
return lines ? { ...input, text: `${input.text}\n\n${wrapContext(lines)}` } : input;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** 成员直接发「/机器上的技能」:Claude 会绕过技能白名单把它展开,这里先拦下。返回拦下的原因 */
|
|
906
|
+
private async blockedSlash(key: string, text: string): Promise<string | null> {
|
|
907
|
+
const m = /^[//](\S+)/.exec(text.trim());
|
|
908
|
+
if (!m) return null;
|
|
909
|
+
const conv = getConversation(key);
|
|
910
|
+
if (!conv || conv.agent !== "claude" || conv.mode === "chat") return null;
|
|
911
|
+
const owner = memoryOwnerOf(conv);
|
|
912
|
+
if (!owner || getUser(owner)?.role === "admin") return null;
|
|
913
|
+
const driver = this.enabledAgents().find((d) => d.id === conv.agent);
|
|
914
|
+
if (!driver?.commands) return null;
|
|
915
|
+
const native = await withTimeout(driver.commands(conv.cwd), "读取命令列表").catch(() => [] as AgentCommand[]);
|
|
916
|
+
return isMachineCommand(m[1]!, native, conv.cwd) ? `/${m[1]} 是这台机器上装的技能,只有管理员能用。` : null;
|
|
917
|
+
}
|
|
918
|
+
|
|
882
919
|
/** 刚点过停止(被打断的那一轮还可能晚到一个出错,见 onError 里的 stopped) */
|
|
883
920
|
recentlyStopped(key: string): boolean {
|
|
884
921
|
return Date.now() - (this.stoppedAt.get(key) ?? 0) < 15_000;
|
|
@@ -1140,7 +1177,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
1140
1177
|
this.staleLive.add(live.id);
|
|
1141
1178
|
return;
|
|
1142
1179
|
}
|
|
1143
|
-
await this.drop(key, "
|
|
1180
|
+
await this.drop(key, "记忆或技能已更新");
|
|
1144
1181
|
});
|
|
1145
1182
|
}
|
|
1146
1183
|
|
|
@@ -1149,9 +1186,15 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
1149
1186
|
* agent 自己用 remember / forget 改的不走这里 —— 那些本来就在上下文里,没必要重启进程、白白丢掉提示缓存。
|
|
1150
1187
|
*/
|
|
1151
1188
|
memoryChanged(userId: string): void {
|
|
1189
|
+
this.personalChanged(new Set([userId]));
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/** 这些人的记忆或技能变了:他们名下活着的会话空闲下来就重新拉起 */
|
|
1193
|
+
private personalChanged(users: Set<string>): void {
|
|
1152
1194
|
for (const [key, live] of this.live) {
|
|
1153
1195
|
const conv = getConversation(key);
|
|
1154
|
-
|
|
1196
|
+
const owner = conv ? memoryOwnerOf(conv) : undefined;
|
|
1197
|
+
if (!owner || !users.has(owner)) continue;
|
|
1155
1198
|
this.staleLive.add(live.id);
|
|
1156
1199
|
this.dropIfStale(key);
|
|
1157
1200
|
}
|
|
@@ -1371,12 +1414,16 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
1371
1414
|
switch (cmd.kind) {
|
|
1372
1415
|
case "help":
|
|
1373
1416
|
return text(helpText(ctx.allowedAgents, channel));
|
|
1417
|
+
case "skills":
|
|
1418
|
+
return text(skillsText(memoryOwnerOf(getConversation(key)!)));
|
|
1374
1419
|
case "commands": {
|
|
1375
1420
|
const conv = getConversation(key)!;
|
|
1421
|
+
const owner = memoryOwnerOf(conv);
|
|
1376
1422
|
const all = await this.commandCatalog(conv.agent, {
|
|
1377
1423
|
cwd: conv.cwd,
|
|
1378
1424
|
allowedAgents: ctx.allowedAgents,
|
|
1379
1425
|
...(channel ? { channel } : {}),
|
|
1426
|
+
...(owner && getUser(owner)?.role !== "admin" ? { member: true } : {}),
|
|
1380
1427
|
});
|
|
1381
1428
|
return { type: "commands", agent: conv.agent, commands: all.filter((c) => !c.hidden) };
|
|
1382
1429
|
}
|
|
@@ -1449,7 +1496,7 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
1449
1496
|
*/
|
|
1450
1497
|
async commandCatalog(
|
|
1451
1498
|
agent: string,
|
|
1452
|
-
opts: { cwd?: string; allowedAgents: string[]; channel?: string },
|
|
1499
|
+
opts: { cwd?: string; allowedAgents: string[]; channel?: string; member?: boolean },
|
|
1453
1500
|
): Promise<CommandEntry[]> {
|
|
1454
1501
|
const driver = this.enabledAgents().find((d) => d.id === agent);
|
|
1455
1502
|
let native: AgentCommand[] = [];
|
|
@@ -1459,6 +1506,8 @@ class Hub extends EventEmitter<{ event: [EventEnvelope]; task: [TaskTurnEnd] }>
|
|
|
1459
1506
|
return [];
|
|
1460
1507
|
});
|
|
1461
1508
|
}
|
|
1509
|
+
// 成员:机器上装的技能不列(用了也会被 blockedSlash 拦下)
|
|
1510
|
+
if (opts.member) native = native.filter((c) => !isMachineCommand(c.name, native, opts.cwd ?? config().workspaceRoot));
|
|
1462
1511
|
const prefs = (config().commands as Record<string, CommandPrefs | undefined>)[agent];
|
|
1463
1512
|
return buildCatalog({ allowedAgents: opts.allowedAgents, native, prefs, ...(opts.channel ? { channel: opts.channel } : {}) });
|
|
1464
1513
|
}
|
package/src/core/personal.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { deleteMemory, memoryPrompt, readMemory } from "./memory.js";
|
|
|
3
3
|
import { purgeHistory } from "./chat-history.js";
|
|
4
4
|
import { unlinkUser } from "./feishu-links.js";
|
|
5
5
|
import { deleteUserTasks } from "./tasks.js";
|
|
6
|
+
import { purgeUserSkills } from "./skills.js";
|
|
6
7
|
import type { Conversation } from "./store.js";
|
|
7
8
|
import { getUser, legacyOwnerId } from "./users.js";
|
|
8
9
|
|
|
@@ -37,10 +38,11 @@ export function sessionMemory(
|
|
|
37
38
|
};
|
|
38
39
|
}
|
|
39
40
|
|
|
40
|
-
/**
|
|
41
|
+
/** 删用户时一起删掉的个人数据:记忆、聊天记录索引、飞书绑定、定时任务和技能(他建的技能,装了的人也就没了) */
|
|
41
42
|
export function purgeUserData(userId: string): void {
|
|
42
43
|
deleteMemory(userId);
|
|
43
44
|
purgeHistory(userId);
|
|
44
45
|
unlinkUser(userId);
|
|
45
46
|
deleteUserTasks(userId);
|
|
47
|
+
purgeUserSkills(userId);
|
|
46
48
|
}
|
|
@@ -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
|
+
|