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
package/src/core/zip.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// 最小的 zip 解包:上传技能包(.zip / .skill)用。只认最常见的两种:不压缩(stored)和 deflate,
|
|
2
|
+
// 加密的、zip64 的直接报错。不落盘,解到内存里交给调用方校验路径、大小后再写。
|
|
3
|
+
import zlib from "node:zlib";
|
|
4
|
+
|
|
5
|
+
export type ZipEntry = { path: string; data: Buffer };
|
|
6
|
+
|
|
7
|
+
const EOCD_SIG = 0x06054b50;
|
|
8
|
+
const CENTRAL_SIG = 0x02014b50;
|
|
9
|
+
const LOCAL_SIG = 0x04034b50;
|
|
10
|
+
|
|
11
|
+
/** maxTotal:解压后的总大小上限(防 zip 炸弹),超了就报错 */
|
|
12
|
+
export function readZip(buf: Buffer, maxTotal: number, maxEntries = 1000): ZipEntry[] {
|
|
13
|
+
// 目录尾记录在最后 22 字节 + 最长 64KB 的注释里
|
|
14
|
+
let eocd = -1;
|
|
15
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i--) {
|
|
16
|
+
if (buf.readUInt32LE(i) === EOCD_SIG) {
|
|
17
|
+
eocd = i;
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (eocd < 0) throw new Error("不是 zip 文件");
|
|
22
|
+
const count = buf.readUInt16LE(eocd + 10);
|
|
23
|
+
let offset = buf.readUInt32LE(eocd + 16);
|
|
24
|
+
if (count === 0xffff || offset === 0xffffffff) throw new Error("不支持 zip64 格式的压缩包");
|
|
25
|
+
if (count > maxEntries) throw new Error(`压缩包里的文件太多(最多 ${maxEntries} 个)`);
|
|
26
|
+
|
|
27
|
+
const out: ZipEntry[] = [];
|
|
28
|
+
let total = 0;
|
|
29
|
+
for (let n = 0; n < count; n++) {
|
|
30
|
+
if (offset + 46 > buf.length || buf.readUInt32LE(offset) !== CENTRAL_SIG) throw new Error("压缩包损坏");
|
|
31
|
+
const flags = buf.readUInt16LE(offset + 8);
|
|
32
|
+
const method = buf.readUInt16LE(offset + 10);
|
|
33
|
+
const compressed = buf.readUInt32LE(offset + 20);
|
|
34
|
+
const size = buf.readUInt32LE(offset + 24);
|
|
35
|
+
const nameLen = buf.readUInt16LE(offset + 28);
|
|
36
|
+
const extraLen = buf.readUInt16LE(offset + 30);
|
|
37
|
+
const commentLen = buf.readUInt16LE(offset + 32);
|
|
38
|
+
const local = buf.readUInt32LE(offset + 42);
|
|
39
|
+
// 第 11 位:文件名是 UTF-8。没标的多半也是(macOS / 新版 Windows),按 UTF-8 读
|
|
40
|
+
const name = buf.subarray(offset + 46, offset + 46 + nameLen).toString("utf8");
|
|
41
|
+
offset += 46 + nameLen + extraLen + commentLen;
|
|
42
|
+
if (name.endsWith("/")) continue; // 目录
|
|
43
|
+
if (flags & 1) throw new Error("不支持加密的压缩包");
|
|
44
|
+
if (size === 0xffffffff || compressed === 0xffffffff) throw new Error("不支持 zip64 格式的压缩包");
|
|
45
|
+
total += size;
|
|
46
|
+
if (total > maxTotal) throw new Error(`解压后太大(上限 ${Math.round(maxTotal / 1024 / 1024)}MB)`);
|
|
47
|
+
if (local + 30 > buf.length || buf.readUInt32LE(local) !== LOCAL_SIG) throw new Error("压缩包损坏");
|
|
48
|
+
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
|
|
49
|
+
const raw = buf.subarray(start, start + compressed);
|
|
50
|
+
let data: Buffer;
|
|
51
|
+
if (method === 0) data = Buffer.from(raw);
|
|
52
|
+
else if (method === 8) data = zlib.inflateRawSync(raw, { maxOutputLength: Math.max(1, size) });
|
|
53
|
+
else throw new Error(`不支持的压缩方式(${method})`);
|
|
54
|
+
if (data.length !== size) throw new Error("压缩包损坏");
|
|
55
|
+
out.push({ path: name, data });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 测试和导出用:打一个不压缩的 zip */
|
|
61
|
+
export function writeZip(entries: ZipEntry[]): Buffer {
|
|
62
|
+
const locals: Buffer[] = [];
|
|
63
|
+
const centrals: Buffer[] = [];
|
|
64
|
+
let offset = 0;
|
|
65
|
+
for (const e of entries) {
|
|
66
|
+
const name = Buffer.from(e.path, "utf8");
|
|
67
|
+
const crc = zlib.crc32(e.data);
|
|
68
|
+
const local = Buffer.alloc(30);
|
|
69
|
+
local.writeUInt32LE(LOCAL_SIG, 0);
|
|
70
|
+
local.writeUInt16LE(20, 4);
|
|
71
|
+
local.writeUInt16LE(0x800, 6);
|
|
72
|
+
local.writeUInt32LE(crc, 14);
|
|
73
|
+
local.writeUInt32LE(e.data.length, 18);
|
|
74
|
+
local.writeUInt32LE(e.data.length, 22);
|
|
75
|
+
local.writeUInt16LE(name.length, 26);
|
|
76
|
+
locals.push(local, name, e.data);
|
|
77
|
+
const central = Buffer.alloc(46);
|
|
78
|
+
central.writeUInt32LE(CENTRAL_SIG, 0);
|
|
79
|
+
central.writeUInt16LE(20, 4);
|
|
80
|
+
central.writeUInt16LE(20, 6);
|
|
81
|
+
central.writeUInt16LE(0x800, 8);
|
|
82
|
+
central.writeUInt32LE(crc, 16);
|
|
83
|
+
central.writeUInt32LE(e.data.length, 20);
|
|
84
|
+
central.writeUInt32LE(e.data.length, 24);
|
|
85
|
+
central.writeUInt16LE(name.length, 28);
|
|
86
|
+
central.writeUInt32LE(offset, 42);
|
|
87
|
+
centrals.push(central, name);
|
|
88
|
+
offset += 30 + name.length + e.data.length;
|
|
89
|
+
}
|
|
90
|
+
const dir = Buffer.concat(centrals);
|
|
91
|
+
const end = Buffer.alloc(22);
|
|
92
|
+
end.writeUInt32LE(EOCD_SIG, 0);
|
|
93
|
+
end.writeUInt16LE(entries.length, 8);
|
|
94
|
+
end.writeUInt16LE(entries.length, 10);
|
|
95
|
+
end.writeUInt32LE(dir.length, 12);
|
|
96
|
+
end.writeUInt32LE(offset, 16);
|
|
97
|
+
return Buffer.concat([...locals, dir, end]);
|
|
98
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -11,6 +11,8 @@ import { startWeb, stopWeb } from "./channels/web/server.js";
|
|
|
11
11
|
import { home } from "./home.js";
|
|
12
12
|
import { startMemoryCurator } from "./core/memory-curator.js";
|
|
13
13
|
import { startScheduler, stopScheduler } from "./core/scheduled.js";
|
|
14
|
+
import { startTaskRunner, stopTaskRunner } from "./core/task-runner.js";
|
|
15
|
+
import { startTasksBridge, stopTasksBridge } from "./core/tasks-bridge.js";
|
|
14
16
|
|
|
15
17
|
async function startDriver(id: "claude" | "codex"): Promise<void> {
|
|
16
18
|
const driver = hub.driver(id);
|
|
@@ -52,8 +54,15 @@ async function main(): Promise<void> {
|
|
|
52
54
|
hub.send(m.key, { text: m.text, attachments: [] }, { channel: "schedule", channelId: "schedule", name: "定时发送" });
|
|
53
55
|
hub.emitEvent(m.key, { type: "meta" });
|
|
54
56
|
});
|
|
57
|
+
// 定时任务:agent 用的工具通道先开,再开始按点运行(停机期间错过的补跑)
|
|
58
|
+
startTasksBridge();
|
|
59
|
+
startTaskRunner();
|
|
55
60
|
// 先查一遍 agent 装没装、登没登录,网页打开时新对话里就只列出能用的
|
|
56
61
|
void agentHealth().catch(() => {});
|
|
62
|
+
// 读一次 Claude 的命令表:成员会话的技能白名单要带上 Claude Code 自带的那些(见 claude/session.ts 的 claudeBuiltins)
|
|
63
|
+
if (config().agents.claude.enabled) {
|
|
64
|
+
setTimeout(() => void hub.driver("claude")?.commands?.(config().workspaceRoot).catch(() => {}), 5000).unref();
|
|
65
|
+
}
|
|
57
66
|
|
|
58
67
|
console.log(`[启动] workspace=${config().workspaceRoot}`);
|
|
59
68
|
if (config().workspaceRoot === process.cwd()) {
|
|
@@ -71,6 +80,9 @@ async function main(): Promise<void> {
|
|
|
71
80
|
stopFeishu();
|
|
72
81
|
await stopWeb();
|
|
73
82
|
await hub.shutdown().catch(() => {});
|
|
83
|
+
// hub 收尾时会把正在跑的定时任务记成中断,所以放在它后面
|
|
84
|
+
stopTaskRunner();
|
|
85
|
+
stopTasksBridge();
|
|
74
86
|
flushStore();
|
|
75
87
|
process.exit(0);
|
|
76
88
|
};
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// 定时任务工具的 MCP 服务(stdio):给 agent 用的 list / create / update / delete。
|
|
2
|
+
// 环境变量 AGENT_LOFT_TOOLSET=skills 时换成技能工具(list / get / create / update_skill),走同一条通道。
|
|
3
|
+
//
|
|
4
|
+
// 这里只是个传话的:每次调用原样转给主进程(core/tasks-bridge.ts,经 unix socket),带上拉起时
|
|
5
|
+
// 定死的用户、对话和签名(见 core/tasks-mcp.ts)。校验、授权卡、真正的增删改都在主进程里做。
|
|
6
|
+
//
|
|
7
|
+
// 和记忆工具一样只实现 MCP 里用得到的那一小块;stdout 只能写协议消息,日志一律走 stderr。
|
|
8
|
+
import net from "node:net";
|
|
9
|
+
|
|
10
|
+
const socketPath = process.env.AGENT_LOFT_TASKS_SOCKET ?? "";
|
|
11
|
+
const caller = {
|
|
12
|
+
user: process.env.AGENT_LOFT_TASKS_USER ?? "",
|
|
13
|
+
conv: process.env.AGENT_LOFT_TASKS_CONV ?? "",
|
|
14
|
+
sig: process.env.AGENT_LOFT_TASKS_SIG ?? "",
|
|
15
|
+
};
|
|
16
|
+
const tz = process.env.AGENT_LOFT_TZ || "服务端本地时区";
|
|
17
|
+
|
|
18
|
+
type Request = { jsonrpc: "2.0"; id?: string | number | null; method: string; params?: Record<string, unknown> };
|
|
19
|
+
|
|
20
|
+
const SCHEDULE = {
|
|
21
|
+
type: "object",
|
|
22
|
+
description:
|
|
23
|
+
`什么时候运行,时间一律按 ${tz} 解释。kind 选一种:` +
|
|
24
|
+
"once(一次:at 写「YYYY-MM-DD HH:MM」,或者 in_minutes 写多少分钟后)、" +
|
|
25
|
+
"daily(每天 time)、weekdays(工作日 time)、weekly(每周 days 的 time,days 里 0=周日…6=周六)、" +
|
|
26
|
+
"monthly(每月 day 日的 time)、hourly(每 every 小时一次,在第 minute 分)、cron(5 段 cron 表达式 expr)。" +
|
|
27
|
+
"time 写「HH:MM」。两次运行至少隔 15 分钟。",
|
|
28
|
+
properties: {
|
|
29
|
+
kind: { type: "string", enum: ["once", "daily", "weekdays", "weekly", "monthly", "hourly", "cron"] },
|
|
30
|
+
at: { type: "string", description: "once:运行时间,「YYYY-MM-DD HH:MM」" },
|
|
31
|
+
in_minutes: { type: "number", description: "once:多少分钟之后(和 at 二选一)" },
|
|
32
|
+
time: { type: "string", description: "daily / weekdays / weekly / monthly:几点,「HH:MM」" },
|
|
33
|
+
days: { type: "array", items: { type: "integer" }, description: "weekly:星期几,0=周日 … 6=周六" },
|
|
34
|
+
day: { type: "integer", description: "monthly:几号(1-31)" },
|
|
35
|
+
every: { type: "integer", description: "hourly:每几小时(1-23)" },
|
|
36
|
+
minute: { type: "integer", description: "hourly:第几分(0-59)" },
|
|
37
|
+
expr: { type: "string", description: "cron:分 时 日 月 周" },
|
|
38
|
+
},
|
|
39
|
+
required: ["kind"],
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const TOOLS = [
|
|
44
|
+
{
|
|
45
|
+
name: "list_tasks",
|
|
46
|
+
description: "列出用户的定时任务(id、名称、频率、下次运行时间、最近一次结果),同时返回现在的时间。改、删任务之前先用它拿到 id。",
|
|
47
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
48
|
+
annotations: { title: "查看定时任务", readOnlyHint: true, openWorldHint: false },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "create_task",
|
|
52
|
+
description:
|
|
53
|
+
"给用户建一个定时任务:到点 agent 会收到 prompt 并照着做,结果默认回到当前这条对话。用户说「每天 / 每周 / 明早 / 两小时后帮我……」时用。" +
|
|
54
|
+
"notify 默认 smart:每次运行时由 agent 自己判断有没有需要告诉用户的事,没有就不提醒(适合「有更新才告诉我」);always:每次都提醒。" +
|
|
55
|
+
"prompt 要写成到点时独立可执行的完整指令(那时用户不在,不能再问他),需要对比时写清楚怎么判断(比如「和上次的结果比」「新版本号大于当前版本才算更新」)。" +
|
|
56
|
+
"调用后会弹授权卡给用户确认,确认了才会建。",
|
|
57
|
+
inputSchema: {
|
|
58
|
+
type: "object",
|
|
59
|
+
properties: {
|
|
60
|
+
title: { type: "string", description: "简短的名称,比如「检查 agent-loft 更新」" },
|
|
61
|
+
prompt: { type: "string", description: "到点时发给 agent 的完整指令" },
|
|
62
|
+
schedule: SCHEDULE,
|
|
63
|
+
notify: { type: "string", enum: ["smart", "always"], description: "smart:有需要才提醒(默认);always:每次都提醒" },
|
|
64
|
+
reply_to: {
|
|
65
|
+
type: "string",
|
|
66
|
+
enum: ["this_conversation", "dedicated"],
|
|
67
|
+
description: "this_conversation:结果回到当前对话(默认);dedicated:给任务单独开一条对话(可以指定 mode)",
|
|
68
|
+
},
|
|
69
|
+
mode: { type: "string", enum: ["chat", "work"], description: "dedicated 时用:chat 只能联网搜索、开销小(默认);work 能执行命令、读写本机文件" },
|
|
70
|
+
},
|
|
71
|
+
required: ["prompt", "schedule"],
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
},
|
|
74
|
+
annotations: { title: "新建定时任务", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: "update_task",
|
|
78
|
+
description: "修改用户的一个定时任务:改名称、提示词、频率、提醒方式,或者暂停 / 恢复(enabled)。只给要改的字段。除了暂停以外的修改会弹授权卡让用户确认。",
|
|
79
|
+
inputSchema: {
|
|
80
|
+
type: "object",
|
|
81
|
+
properties: {
|
|
82
|
+
id: { type: "string", description: "任务 id(list_tasks 里的),形如 t_1a2b3c4d5e" },
|
|
83
|
+
title: { type: "string" },
|
|
84
|
+
prompt: { type: "string" },
|
|
85
|
+
schedule: SCHEDULE,
|
|
86
|
+
notify: { type: "string", enum: ["smart", "always"] },
|
|
87
|
+
enabled: { type: "boolean", description: "false 暂停,true 恢复" },
|
|
88
|
+
},
|
|
89
|
+
required: ["id"],
|
|
90
|
+
additionalProperties: false,
|
|
91
|
+
},
|
|
92
|
+
annotations: { title: "修改定时任务", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "delete_task",
|
|
96
|
+
description: "删掉用户的一个定时任务(用户要求删除 / 取消时用;只是暂时不想运行的话用 update_task 暂停)。会弹授权卡让用户确认。",
|
|
97
|
+
inputSchema: {
|
|
98
|
+
type: "object",
|
|
99
|
+
properties: { id: { type: "string", description: "任务 id" } },
|
|
100
|
+
required: ["id"],
|
|
101
|
+
additionalProperties: false,
|
|
102
|
+
},
|
|
103
|
+
annotations: { title: "删除定时任务", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
// ---------- 技能工具(AGENT_LOFT_TOOLSET=skills 时只报这几个) ----------
|
|
108
|
+
|
|
109
|
+
const VARS = {
|
|
110
|
+
type: "array",
|
|
111
|
+
description:
|
|
112
|
+
"技能要用的环境变量(API 地址、密钥之类)。值由用户自己在网页上填,运行时注入到他的会话环境里,SKILL.md 里用 $NAME 引用。" +
|
|
113
|
+
"变量名用大写字母、数字和 _,最好带上技能相关的前缀(比如 WEATHER_API_KEY)。",
|
|
114
|
+
items: {
|
|
115
|
+
type: "object",
|
|
116
|
+
properties: {
|
|
117
|
+
name: { type: "string" },
|
|
118
|
+
description: { type: "string", description: "给用户看的说明:这是什么、去哪里拿" },
|
|
119
|
+
secret: { type: "boolean", description: "密钥、令牌之类:网页上不回显" },
|
|
120
|
+
required: { type: "boolean" },
|
|
121
|
+
hosts: {
|
|
122
|
+
type: "array",
|
|
123
|
+
items: { type: "string" },
|
|
124
|
+
description:
|
|
125
|
+
"secret 时建议给:这个密钥只允许发往的地址(域名或 IP,可带端口,比如 api.example.com)。给了就是受保护的密钥:不放进环境变量,agent 看不到值," +
|
|
126
|
+
"只能用 skill_request 工具在请求里写 {{NAME}} 由 agent-loft 代发。这时 body 里要写成用 skill_request 调用,而不是 curl $NAME。",
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
required: ["name"],
|
|
130
|
+
additionalProperties: false,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const FILES = {
|
|
135
|
+
type: "array",
|
|
136
|
+
description: "技能附带的文件(脚本、模板、参考资料),路径相对技能目录,比如 scripts/fetch.sh。只支持文本。",
|
|
137
|
+
items: {
|
|
138
|
+
type: "object",
|
|
139
|
+
properties: { path: { type: "string" }, content: { type: "string" } },
|
|
140
|
+
required: ["path", "content"],
|
|
141
|
+
additionalProperties: false,
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const SKILL_TOOLS = [
|
|
146
|
+
{
|
|
147
|
+
name: "list_skills",
|
|
148
|
+
description: "列出用户在 agent-loft 里启用着的技能(名字、说明、谁建的、变量配没配)。",
|
|
149
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
150
|
+
annotations: { title: "查看技能", readOnlyHint: true, openWorldHint: false },
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
name: "get_skill",
|
|
154
|
+
description: "读一个技能的 SKILL.md 和附带文件清单;给 file 时读那个附带文件。改技能之前先读。",
|
|
155
|
+
inputSchema: {
|
|
156
|
+
type: "object",
|
|
157
|
+
properties: { name: { type: "string", description: "技能名" }, file: { type: "string", description: "附带文件的相对路径(可选)" } },
|
|
158
|
+
required: ["name"],
|
|
159
|
+
additionalProperties: false,
|
|
160
|
+
},
|
|
161
|
+
annotations: { title: "读技能", readOnlyHint: true, openWorldHint: false },
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: "create_skill",
|
|
165
|
+
description:
|
|
166
|
+
"把一套做事的方法存成用户的技能(agent-loft 管理,Claude Code 和 Codex 都能用;格式同 Claude Code 的 SKILL.md)。" +
|
|
167
|
+
"用户说「把这个做成技能 / 以后都这么做 / 存成 skill」时用。写法:description 一句话说清什么时候该用它(agent 靠它判断);" +
|
|
168
|
+
"body 是给 agent 的操作说明(Markdown),步骤具体、可以直接照做;要调接口的,把地址、密钥做成 vars,body 里用 $NAME 引用,不要把真实的密钥写进去。" +
|
|
169
|
+
"调用后会弹授权卡给用户确认。建好后只有用户自己看得到,下一轮起可用。不要写到 ~/.claude/skills 之类的目录里,那是机器上的,只有管理员能用。",
|
|
170
|
+
inputSchema: {
|
|
171
|
+
type: "object",
|
|
172
|
+
properties: {
|
|
173
|
+
name: { type: "string", description: "技能名:小写字母、数字和 -,最长 48 个字符,全站唯一" },
|
|
174
|
+
description: { type: "string", description: "什么时候用这个技能(一两句话)" },
|
|
175
|
+
body: { type: "string", description: "SKILL.md 的正文(不含 frontmatter)" },
|
|
176
|
+
files: FILES,
|
|
177
|
+
vars: VARS,
|
|
178
|
+
},
|
|
179
|
+
required: ["name", "description", "body"],
|
|
180
|
+
additionalProperties: false,
|
|
181
|
+
},
|
|
182
|
+
annotations: { title: "新建技能", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
name: "skill_request",
|
|
186
|
+
description:
|
|
187
|
+
"替你发一个 HTTP 请求,url、headers、body 里的 {{变量名}} 由 agent-loft 换成用户在技能里填的值 —— 你看不到密钥本身。" +
|
|
188
|
+
"技能里的受保护密钥(list_skills 里标了「受保护」的)只能这样用,而且只能发往用户确认过的地址;普通变量也可以这样用。" +
|
|
189
|
+
"不跟随跳转;响应里出现的变量值会被抹掉;最多返回 256KB 文本。",
|
|
190
|
+
inputSchema: {
|
|
191
|
+
type: "object",
|
|
192
|
+
properties: {
|
|
193
|
+
method: { type: "string", enum: ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"], description: "默认 GET" },
|
|
194
|
+
url: { type: "string", description: "完整地址,比如 https://api.example.com/v1/weather?city=北京&key={{WEATHER_API_KEY}}" },
|
|
195
|
+
headers: { type: "object", additionalProperties: { type: "string" }, description: "比如 { \"Authorization\": \"Bearer {{MY_TOKEN}}\" }" },
|
|
196
|
+
body: { type: "string", description: "请求体(JSON 记得自己序列化,并带上 content-type)" },
|
|
197
|
+
},
|
|
198
|
+
required: ["url"],
|
|
199
|
+
additionalProperties: false,
|
|
200
|
+
},
|
|
201
|
+
// openWorldHint 不标:Codex 见到它会先弹 MCP 审批(agent-loft 不接,等于拒绝)。能发往哪里由 agent-loft 自己卡着
|
|
202
|
+
annotations: { title: "代发请求", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
name: "update_skill",
|
|
206
|
+
description: "修改用户自己建的一个技能:说明、正文、附带文件、变量,只给要改的。会弹授权卡让用户确认。分享出去的技能改了之后,装了的人也会用上新的。",
|
|
207
|
+
inputSchema: {
|
|
208
|
+
type: "object",
|
|
209
|
+
properties: {
|
|
210
|
+
name: { type: "string", description: "技能名(不能改名)" },
|
|
211
|
+
description: { type: "string" },
|
|
212
|
+
body: { type: "string", description: "新的 SKILL.md 正文(整段替换)" },
|
|
213
|
+
files: FILES,
|
|
214
|
+
delete_files: { type: "array", items: { type: "string" }, description: "要删掉的附带文件" },
|
|
215
|
+
vars: VARS,
|
|
216
|
+
},
|
|
217
|
+
required: ["name"],
|
|
218
|
+
additionalProperties: false,
|
|
219
|
+
},
|
|
220
|
+
annotations: { title: "修改技能", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
221
|
+
},
|
|
222
|
+
];
|
|
223
|
+
|
|
224
|
+
const skillsOnly = process.env.AGENT_LOFT_TOOLSET === "skills";
|
|
225
|
+
const ACTIVE = skillsOnly ? SKILL_TOOLS : TOOLS;
|
|
226
|
+
|
|
227
|
+
function text(t: string, isError = false) {
|
|
228
|
+
return { content: [{ type: "text", text: t }], ...(isError ? { isError: true } : {}) };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 转给主进程,等它回一行 JSON */
|
|
232
|
+
function forward(tool: string, args: Record<string, unknown>): Promise<{ text: string; isError?: boolean }> {
|
|
233
|
+
return new Promise((resolve) => {
|
|
234
|
+
if (!socketPath || !caller.user || !caller.conv) {
|
|
235
|
+
resolve({ text: `${skillsOnly ? "技能" : "定时任务"}工具没有正确配置(缺少用户或对话)。`, isError: true });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const sock = net.createConnection(socketPath);
|
|
239
|
+
let buf = "";
|
|
240
|
+
let done = false;
|
|
241
|
+
const finish = (r: { text: string; isError?: boolean }) => {
|
|
242
|
+
if (done) return;
|
|
243
|
+
done = true;
|
|
244
|
+
sock.destroy();
|
|
245
|
+
resolve(r);
|
|
246
|
+
};
|
|
247
|
+
sock.setEncoding("utf8");
|
|
248
|
+
sock.on("connect", () => sock.write(`${JSON.stringify({ ...caller, tool, args })}\n`));
|
|
249
|
+
sock.on("data", (chunk: string) => {
|
|
250
|
+
buf += chunk;
|
|
251
|
+
const n = buf.indexOf("\n");
|
|
252
|
+
if (n < 0) return;
|
|
253
|
+
try {
|
|
254
|
+
const res = JSON.parse(buf.slice(0, n)) as { text?: string; isError?: boolean };
|
|
255
|
+
finish({ text: String(res.text ?? ""), ...(res.isError ? { isError: true } : {}) });
|
|
256
|
+
} catch {
|
|
257
|
+
finish({ text: "agent-loft 返回的内容看不懂。", isError: true });
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
sock.on("error", (err) => finish({ text: `连不上 agent-loft(${err.message})。它可能刚重启过,开个新会话再试。`, isError: true }));
|
|
261
|
+
sock.on("close", () => finish({ text: "agent-loft 断开了连接。", isError: true }));
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function handle(req: Request): Promise<unknown> {
|
|
266
|
+
switch (req.method) {
|
|
267
|
+
case "initialize":
|
|
268
|
+
return {
|
|
269
|
+
protocolVersion: typeof req.params?.protocolVersion === "string" ? req.params.protocolVersion : "2025-06-18",
|
|
270
|
+
capabilities: { tools: {} },
|
|
271
|
+
serverInfo: { name: skillsOnly ? "agent-loft-skills" : "agent-loft-tasks", version: "1.0.0" },
|
|
272
|
+
};
|
|
273
|
+
case "ping":
|
|
274
|
+
return {};
|
|
275
|
+
case "tools/list":
|
|
276
|
+
return { tools: ACTIVE };
|
|
277
|
+
case "tools/call": {
|
|
278
|
+
const params = req.params ?? {};
|
|
279
|
+
const name = String(params.name ?? "");
|
|
280
|
+
if (!ACTIVE.some((t) => t.name === name)) return text(`没有工具 ${name}`, true);
|
|
281
|
+
const res = await forward(name, (params.arguments as Record<string, unknown> | undefined) ?? {});
|
|
282
|
+
return text(res.text, !!res.isError);
|
|
283
|
+
}
|
|
284
|
+
default:
|
|
285
|
+
throw Object.assign(new Error(`不支持 ${req.method}`), { code: -32601 });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function reply(message: unknown): void {
|
|
290
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function onLine(line: string): void {
|
|
294
|
+
if (!line.trim()) return;
|
|
295
|
+
let req: Request;
|
|
296
|
+
try {
|
|
297
|
+
req = JSON.parse(line) as Request;
|
|
298
|
+
} catch {
|
|
299
|
+
reply({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "JSON 解析失败" } });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const isRequest = req.id !== undefined && req.id !== null;
|
|
303
|
+
// 不按顺序等:一个调用在等授权卡的时候,ping 之类照样要回
|
|
304
|
+
handle(req).then(
|
|
305
|
+
(result) => {
|
|
306
|
+
if (isRequest) reply({ jsonrpc: "2.0", id: req.id, result });
|
|
307
|
+
},
|
|
308
|
+
(err) => {
|
|
309
|
+
if (!isRequest) return;
|
|
310
|
+
const code = (err as { code?: number }).code ?? -32603;
|
|
311
|
+
reply({ jsonrpc: "2.0", id: req.id, error: { code, message: err instanceof Error ? err.message : String(err) } });
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let buf = "";
|
|
317
|
+
process.stdin.setEncoding("utf8");
|
|
318
|
+
process.stdin.on("data", (chunk: string) => {
|
|
319
|
+
buf += chunk;
|
|
320
|
+
let n: number;
|
|
321
|
+
while ((n = buf.indexOf("\n")) >= 0) {
|
|
322
|
+
const line = buf.slice(0, n);
|
|
323
|
+
buf = buf.slice(n + 1);
|
|
324
|
+
onLine(line);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
process.stdin.on("end", () => process.exit(0));
|