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/README.md
CHANGED
|
@@ -59,6 +59,7 @@ Logo 就是这个意思:一大两小几颗星(多个 agent)站在同一张
|
|
|
59
59
|
- **聊天模式 / 工作模式**:右上角的「💬 聊天 / 🛠 工作」,或者发 `/chat`、`/work`,同一条对话随时切换、上下文接着。聊天模式换成精简的系统提示,只留联网搜索,不加载技能、MCP、插件,不执行命令、不读写本机文件(文本附件的内容直接放进消息),每轮的固定开销小得多:实测一句话 Claude 约 1.6 万 → 2 千 token,Codex 约 2 万 → 1.2 万(Codex 的基础指令改不了)。适合日常聊天、查资料;要动手干活时切回工作模式。切换后第一轮要重建提示缓存,会贵一些;在工作模式里读过的大段内容还留在历史里。
|
|
60
60
|
- **记忆**:像 ChatGPT 的记忆,设置 → 记忆。「关于我」加上一条条记住的事,每次对话都带上;agent 在聊天里遇到长期有用的信息会自己用 `remember` 记一条(回复里会说),记错了用 `forget` 删。**自动整理**:把上次整理之后新说的话交给模型(不留会话记录的一次性调用),补充、更新或删掉过时的记忆,用户自己加的条目它不动。什么时候整理每个人自己定:每天一次(默认凌晨 4 点,把当天的对话合起来一次整理,最省)/每段对话聊完后(停多久可选,改了什么会在对话里说一声)/只在点「现在整理」时/不整理;整理哪些对话也能选:全部/只整理聊天模式的/只整理工作模式的;用哪个 agent 的哪个模型也能选,默认 Claude Haiku,没有 Claude 就用 Codex 的默认模型、思考程度 low。**参考以前的聊天**:agent 遇到「上次说的那个……」会用 `search_past_chats` 搜你以前的对话(关键词搜索,只有你自己的、没被删掉的对话;第一次用时会把已有对话收录进来)。两项都能在设置里单独关。每个用户一份,Claude 和 Codex、Web 和飞书共用。只用在有主人的对话里:你的 Web 对话,以及绑定了飞书账号后和机器人的私聊;飞书群聊不用。别人(包括管理员)看不到你的记忆,管理员只能清空它;删用户时记忆一起删,不转给管理员。
|
|
61
61
|
- **定时任务**:像 ChatGPT 的 Tasks,侧栏「最近」上面的「定时任务」。到点把一段提示词交给 agent,结果发回对话:一次 / 每天 / 工作日 / 每周 / 每月 / 每隔几小时 / 自定义 cron,按服务端时区,两次至少隔 15 分钟。**有需要才提醒**(默认):agent 自己判断这次有没有要告诉你的事(比如「有新版本才告诉我」),没有就在回复第一行写 `[[静默]]`,推送、飞书都不发,对话里折叠成一行;没写暗号一律照常提醒。结果发到**专用对话**(任务自己的一条对话,第一次运行时新建、之后续用,可选聊天 / 工作模式和目录),或者**原来的对话**。也能直接在对话里说「每天早上 9 点帮我……」:agent 用 `create_task` / `update_task` / `delete_task` / `list_tasks` 工具管理,新建、修改、删除都会先弹授权卡让你确认(只有暂停不用;没有「总是允许」),任务自己运行的那一轮里不能再建、改、删任务。按人隔离:每人只看得到、改得了自己的任务(最多 30 个),删用户时一起删;到点再核对一次对话还是不是他的,对话被删或换了主人就暂停任务。到点对话在忙(正在回复或挂着授权)就等它,最多 30 分钟;服务停着时错过 2 小时以内的启动后补跑一次。工具和记忆一样只给有主人的对话,飞书群聊里没有。
|
|
62
|
+
- **技能(skills)**:侧栏「定时任务」下面的「技能」。技能就是 Claude Code / Codex 用的那种 SKILL.md(可以带脚本和资料),由 agent-loft 按人管理,两个 agent 都能用。**来源**:在页面上写、上传技能包(`.zip` / `.skill` / 单个 SKILL.md / 整个文件夹),或者在对话里让 agent「把刚才的做法存成技能」(agent 用 `create_skill` / `update_skill`,先弹授权卡)。**分享**:作者选谁看得到——仅自己 / 指定的人 / 所有人;看得到的人在「技能广场」里安装。装的是作者那一份本身,作者改了,装了的人下一条消息起就用上新的;作者取消分享或删掉,装了的人也就没了。管理员能下架别人分享出来的技能,看不到别人私有的。**使用**:工作模式下 agent 遇到对得上的事自己用;在输入框或定时任务的「要做的事」里打 `@` 选技能,点名这次用哪个(聊天模式下会把技能内容直接附上);飞书里发 `/skills` 看能 @ 哪些。**变量**:技能可以声明要用的环境变量(接口地址、密钥),每个人填自己的值,只注入自己的会话;密钥再写上「只允许发往」的地址就成了**受保护的密钥**——不进环境变量,agent 看不到值,只能用 `skill_request` 工具让 agent-loft 代发请求(在地址、请求头、请求体里写 `{{变量名}}`),只发往你确认过的地址、不跟随跳转、响应里出现的值会被抹掉;作者后来改了地址,要你再确认一次才放行。**机器上原有的技能**(`~/.claude/skills`、`~/.agents/skills`、插件市场和 claude.ai 同步的)只给管理员:成员的会话里看不到、调不了(Claude 用技能白名单,`/技能名` 也会被拦下;Codex 关掉它自己的技能清单),管理员在技能页的「本机技能」里能看到。
|
|
62
63
|
- **飞书账号绑定**:设置 → 账号 → 飞书账号 →「生成绑定码」,再在飞书私聊机器人发 `/bind 绑定码`(10 分钟内有效,群里发不算)。绑定后这个私聊就是你的个人会话:用你的记忆,`/sessions` 只列你的会话,`/web` 只能接上你自己的 Web 会话,管理员在 Web 上也看不到它。必须本人在两边各操作一次,管理员不能替人绑定(只能解除),否则就能借飞书读到别人的记忆。
|
|
63
64
|
|
|
64
65
|
## 安装
|
|
@@ -129,7 +130,7 @@ agent-loft service install
|
|
|
129
130
|
|
|
130
131
|
`aloft` 是 `agent-loft` 的简写。
|
|
131
132
|
|
|
132
|
-
聊天里的命令(飞书和 Web 通用;Web 输入框打 `/` 会弹出命令补全,飞书里打 `/` 会弹出机器人的指令面板(agent-loft 自动注册,约 5~8 分钟生效;只发一个 `/` 也会回一张可点击的命令卡片),顺序和显示项在 设置 → 命令 里调整):`/new` `/fork` `/sessions [all]` `/resume <id>` `/model [名称]` `/effort <程度>` `/agent [名称]`(或者直接发 `/claude`、`/codex`)`/stop` `/cd <目录>` `/pwd` `/status` `/autoedit [on|off]` `/approve <id> allow-once|allow-always|allow-session|deny` `/chat` `/work` `/help`;只在飞书里有的:`/web` `/detach` `/bind <绑定码>`
|
|
133
|
+
聊天里的命令(飞书和 Web 通用;Web 输入框打 `/` 会弹出命令补全,飞书里打 `/` 会弹出机器人的指令面板(agent-loft 自动注册,约 5~8 分钟生效;只发一个 `/` 也会回一张可点击的命令卡片),顺序和显示项在 设置 → 命令 里调整):`/new` `/fork` `/sessions [all]` `/resume <id>` `/model [名称]` `/effort <程度>` `/agent [名称]`(或者直接发 `/claude`、`/codex`)`/stop` `/cd <目录>` `/pwd` `/status` `/autoedit [on|off]` `/approve <id> allow-once|allow-always|allow-session|deny` `/chat` `/work` `/skills` `/help`;只在飞书里有的:`/web` `/detach` `/bind <绑定码>`
|
|
133
134
|
|
|
134
135
|
自定义命令:设置 → 命令 → 「添加命令」,填命令名、说明和一段提示词,之后发 `/命令名 参数` 就会把这段提示词发给 agent(提示词里的 `$ARGUMENTS` 换成参数,没写就把参数接在后面)。按 agent 分开存,可以编辑、删除,也会同步到飞书指令面板。
|
|
135
136
|
|
|
@@ -144,6 +145,7 @@ agent-loft service install
|
|
|
144
145
|
- 暴露到公网时,只通过 HTTPS 反向代理(比如 Lucky)或 Cloudflare Tunnel 访问,并把不在本机的反代 IP 加进 `web.trustedProxies`(只有从这些地址来的连接才采信 `X-Forwarded-*`)。不要直接把 8800 端口映射出去。局域网访问可以在 设置 → 系统 → 网络访问 里关掉。
|
|
145
146
|
- 审批超时一律按拒绝处理。Codex 默认沙箱为 `workspace-write`,不会因为 `~/.codex/config.toml` 里配了 `never` 或 `danger-full-access` 就绕过审批。
|
|
146
147
|
- agent 子进程拿不到本服务自己的凭据(`FEISHU_*`、`AGENT_LOFT_*` 等环境变量会被剔除)。
|
|
148
|
+
- 技能存在 `data/skills/`:技能本体在 `files/<技能 id>/`,每个人装了哪些、变量的值在 `users/<用户 id>.json`(0600)。普通变量注入到会话环境里,agent 能读到;受保护的密钥 agent 看不到值,但和多用户一样不是安全边界——工作模式下 agent 能直接读这些文件。别人分享的技能会在你的会话里照着执行,装之前在技能页点开看一眼它要做什么、要哪些变量。
|
|
147
149
|
- 记忆存在 `data/memory/<用户 id>.json`,聊天记录索引在 `data/history/<用户 id>.jsonl`。记给谁由 agent-loft 在拉起记忆工具时定死(工具参数里没有用户),模型想写别人的记忆也写不了;但和多用户一样不是安全边界:工作模式下 agent 能读到这些文件。聊天模式没有读写本机文件的工具,网页里的提示注入也骗不走本机文件。
|
|
148
150
|
|
|
149
151
|
## 结构
|
|
@@ -163,11 +165,14 @@ src/
|
|
|
163
165
|
chat-mode.ts 聊天模式告诉 agent 的话(Claude 换系统提示,Codex 附在消息后面)
|
|
164
166
|
tasks.ts 定时任务的存储(按人隔离);task-schedule.ts 频率和 cron;task-prompt.ts 每次运行附给 agent 的说明和静默暗号
|
|
165
167
|
task-runner.ts 定时任务的调度:到点核对主人和对话、送进对话、记下结果
|
|
166
|
-
tasks-bridge.ts 定时任务工具在主进程这头:核对签名、弹授权卡、增删改(tasks-mcp.ts
|
|
168
|
+
tasks-bridge.ts 定时任务工具在主进程这头:核对签名、弹授权卡、增删改(tasks-mcp.ts 定怎么拉起);技能工具也走这条通道
|
|
169
|
+
skills.ts 技能的存储:可见性、安装、停用、变量和受保护密钥确认过的地址;zip.ts 解技能包
|
|
170
|
+
skill-runtime.ts 技能怎么交给 agent:Claude 的插件目录和成员白名单、Codex 的清单、@ 技能、机器上原有的技能
|
|
171
|
+
skills-bridge.ts 技能工具在主进程这头:建 / 改技能(弹授权卡)、skill_request 代发带密钥的请求
|
|
167
172
|
pairing.ts uploads.ts commands.ts …
|
|
168
173
|
mcp/
|
|
169
174
|
memory-server.ts 记忆工具(remember / forget / search_past_chats)的 stdio MCP 服务,两个 agent 共用
|
|
170
|
-
tasks-server.ts 定时任务工具的 stdio MCP 服务:只传话,经 unix socket
|
|
175
|
+
tasks-server.ts 定时任务工具的 stdio MCP 服务:只传话,经 unix socket 转给主进程;AGENT_LOFT_TOOLSET=skills 时是技能工具
|
|
171
176
|
agents/
|
|
172
177
|
claude/ Claude Agent SDK 驱动
|
|
173
178
|
codex/ codex app-server 驱动
|
package/package.json
CHANGED
|
@@ -31,7 +31,7 @@ import type {
|
|
|
31
31
|
import { describeUsage, readUsage, toQuota } from "./account.js";
|
|
32
32
|
import { cachedProbe, withProbe } from "./probe.js";
|
|
33
33
|
import { diagnose } from "./errors.js";
|
|
34
|
-
import { ClaudeSession, describeToolUse, summarize, toModelInfo } from "./session.js";
|
|
34
|
+
import { claudeBuiltins, ClaudeSession, describeToolUse, summarize, toModelInfo } from "./session.js";
|
|
35
35
|
|
|
36
36
|
/** 模型列表几乎不变,缓存 10 分钟;读失败抛给设置页,让它退回手填 */
|
|
37
37
|
const readCatalog = cachedProbe(10 * 60_000, "模型列表", () => withProbe(async (q) => toModelInfo(await q.supportedModels())));
|
|
@@ -52,11 +52,15 @@ function readCommands(cwd: string): Promise<AgentCommand[]> {
|
|
|
52
52
|
() =>
|
|
53
53
|
withProbe(
|
|
54
54
|
async (q) =>
|
|
55
|
-
(await q.supportedCommands()).map((c) =>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
(await q.supportedCommands()).map((c) => {
|
|
56
|
+
if (c.builtin) claudeBuiltins.add(c.name);
|
|
57
|
+
return {
|
|
58
|
+
name: c.name,
|
|
59
|
+
description: c.description,
|
|
60
|
+
...(c.argumentHint ? { args: c.argumentHint } : {}),
|
|
61
|
+
...(c.builtin ? { builtin: true } : {}),
|
|
62
|
+
};
|
|
63
|
+
}),
|
|
60
64
|
cwd,
|
|
61
65
|
),
|
|
62
66
|
[] as AgentCommand[],
|
|
@@ -16,7 +16,8 @@ import {
|
|
|
16
16
|
import { agentEnvironment } from "../../core/agent-env.js";
|
|
17
17
|
import { chatSystemPrompt } from "../../core/chat-mode.js";
|
|
18
18
|
import { MEMORY_MCP_NAME, memoryMcpSpawn } from "../../core/memory-mcp.js";
|
|
19
|
-
import { TASKS_MCP_NAME, tasksMcpSpawn } from "../../core/tasks-mcp.js";
|
|
19
|
+
import { SKILLS_MCP_NAME, skillsMcpSpawn, TASKS_MCP_NAME, tasksMcpSpawn } from "../../core/tasks-mcp.js";
|
|
20
|
+
import { PLUGIN_NAME, projectCommandNames } from "../../core/skill-runtime.js";
|
|
20
21
|
import { describeAttachments, inlineTextBlocks, readTextAttachments } from "../../core/uploads.js";
|
|
21
22
|
import type {
|
|
22
23
|
AgentEvents,
|
|
@@ -130,6 +131,13 @@ function buildContent(input: UserInput, chat: boolean): string | ContentBlock[]
|
|
|
130
131
|
const MEMORY_TOOLS = new Set(["remember", "forget", "search_past_chats"].map((t) => `mcp__${MEMORY_MCP_NAME}__${t}`));
|
|
131
132
|
/** 定时任务工具:要用户确认的那几个由 agent-loft 自己弹卡(tasks-bridge.ts),这里不再问一遍 */
|
|
132
133
|
const TASK_TOOLS = new Set(["list_tasks", "create_task", "update_task", "delete_task"].map((t) => `mcp__${TASKS_MCP_NAME}__${t}`));
|
|
134
|
+
/** 技能工具:建、改技能前由 agent-loft 自己弹卡(skills-bridge.ts) */
|
|
135
|
+
const SKILL_TOOLS = new Set(["list_skills", "get_skill", "create_skill", "update_skill", "skill_request"].map((t) => `mcp__${SKILLS_MCP_NAME}__${t}`));
|
|
136
|
+
/**
|
|
137
|
+
* Claude Code 自带的命令和技能名(supportedCommands 里标了 builtin 的),driver 读命令表时顺手记下。
|
|
138
|
+
* 成员会话的技能白名单要带上它们;还没读到过就先不带(只少了 /simplify 这类自带技能)。
|
|
139
|
+
*/
|
|
140
|
+
export const claudeBuiltins = new Set<string>();
|
|
133
141
|
/** 聊天模式的全部工具:只能联网,碰不到本机 */
|
|
134
142
|
const CHAT_TOOLS = ["WebSearch", "WebFetch"];
|
|
135
143
|
|
|
@@ -152,7 +160,12 @@ export function modeOptions(opts: SessionOptions): Partial<Options> {
|
|
|
152
160
|
...(memory
|
|
153
161
|
? { [MEMORY_MCP_NAME]: { type: "stdio", ...memoryMcpSpawn(memory.userId, "claude", { search: !!memory.search, ...(memory.convKey ? { convKey: memory.convKey } : {}) }) } }
|
|
154
162
|
: {}),
|
|
155
|
-
...(opts.tasks
|
|
163
|
+
...(opts.tasks
|
|
164
|
+
? {
|
|
165
|
+
[TASKS_MCP_NAME]: { type: "stdio", ...tasksMcpSpawn(opts.tasks.userId, opts.tasks.convKey, "claude") },
|
|
166
|
+
[SKILLS_MCP_NAME]: { type: "stdio", ...skillsMcpSpawn(opts.tasks.userId, opts.tasks.convKey, "claude") },
|
|
167
|
+
}
|
|
168
|
+
: {}),
|
|
156
169
|
};
|
|
157
170
|
if (opts.mode === "chat") {
|
|
158
171
|
return {
|
|
@@ -168,13 +181,24 @@ export function modeOptions(opts: SessionOptions): Partial<Options> {
|
|
|
168
181
|
const switched = opts.announceMode
|
|
169
182
|
? "这条对话刚从「聊天模式」切到「工作模式」:之前聊天模式下不能执行命令、不能读写文件的限制已经解除,现在可以正常干活。不用为之前说做不到而道歉。"
|
|
170
183
|
: "";
|
|
171
|
-
const
|
|
184
|
+
const skills = opts.skills;
|
|
185
|
+
const append = [memory?.prompt, skills?.prompt, switched].filter(Boolean).join("\n\n");
|
|
172
186
|
return {
|
|
173
187
|
systemPrompt: { type: "preset", preset: "claude_code", ...(append ? { append } : {}), snapshot: false },
|
|
174
188
|
...(Object.keys(mcpServers).length ? { mcpServers } : {}),
|
|
189
|
+
...(skills?.pluginDir ? { plugins: [{ type: "local" as const, path: skills.pluginDir, skipMcpDiscovery: true }] } : {}),
|
|
190
|
+
...(skills && !skills.admin ? { skills: memberSkillAllowlist(skills, opts.cwd) } : {}),
|
|
175
191
|
};
|
|
176
192
|
}
|
|
177
193
|
|
|
194
|
+
/**
|
|
195
|
+
* 成员会话能用的技能:agent-loft 里启用的、Claude Code 自带的、项目目录里的。机器上另外装的
|
|
196
|
+
* (~/.claude/skills、插件市场、claude.ai 同步的)不在里面:模型看不到,Skill 工具也调不了。
|
|
197
|
+
*/
|
|
198
|
+
function memberSkillAllowlist(skills: NonNullable<SessionOptions["skills"]>, cwd: string): string[] {
|
|
199
|
+
return [...new Set([...skills.list.map((s) => `${PLUGIN_NAME}:${s.name}`), ...claudeBuiltins, ...projectCommandNames(cwd)])];
|
|
200
|
+
}
|
|
201
|
+
|
|
178
202
|
export class ClaudeSession implements AgentSession {
|
|
179
203
|
private queue = new AsyncQueue<SDKUserMessage>();
|
|
180
204
|
private q: Query | null = null;
|
|
@@ -228,7 +252,8 @@ export class ClaudeSession implements AgentSession {
|
|
|
228
252
|
prompt: input.stream(),
|
|
229
253
|
options: {
|
|
230
254
|
cwd: this.opts.cwd,
|
|
231
|
-
|
|
255
|
+
// 技能变量只在工作模式注入:聊天模式没有执行命令的工具,用不上
|
|
256
|
+
env: { ...agentEnvironment(), ...(this.opts.mode === "chat" ? {} : (this.opts.skills?.env ?? {})) },
|
|
232
257
|
// 只能是 default 或 acceptEdits:bypassPermissions 会让 canUseTool 变成死代码。
|
|
233
258
|
// acceptEdits 只放行工作目录(含附件目录)内的改文件,其它照样走 canUseTool
|
|
234
259
|
permissionMode: this.opts.autoEdit ? "acceptEdits" : "default",
|
|
@@ -357,7 +382,7 @@ export class ClaudeSession implements AgentSession {
|
|
|
357
382
|
// 自家的记忆工具,以及聊天模式下的联网工具:不问。
|
|
358
383
|
// 不用 allowedTools 放行 —— SDK 会对每个会话打一条「canUseTool 被绕过」的警告
|
|
359
384
|
if (MEMORY_TOOLS.has(toolName) && this.opts.memory) return { behavior: "allow", updatedInput: input };
|
|
360
|
-
if (TASK_TOOLS.has(toolName) && this.opts.tasks) return { behavior: "allow", updatedInput: input };
|
|
385
|
+
if ((TASK_TOOLS.has(toolName) || SKILL_TOOLS.has(toolName)) && this.opts.tasks) return { behavior: "allow", updatedInput: input };
|
|
361
386
|
if (this.opts.mode === "chat" && CHAT_TOOLS.includes(toolName)) return { behavior: "allow", updatedInput: input };
|
|
362
387
|
|
|
363
388
|
// 提问不是授权:它问的是「你选哪个」
|
|
@@ -7,7 +7,8 @@ import { refusalFor, UnsupportedRequest } from "./refusals.js";
|
|
|
7
7
|
import { config } from "../../core/config.js";
|
|
8
8
|
import { contextBlock } from "../../core/chat-mode.js";
|
|
9
9
|
import { MEMORY_MCP_NAME, memoryMcpSpawn } from "../../core/memory-mcp.js";
|
|
10
|
-
import { TASK_TOOL_TIMEOUT_SEC, TASKS_MCP_NAME, tasksMcpSpawn } from "../../core/tasks-mcp.js";
|
|
10
|
+
import { SKILLS_MCP_NAME, skillsMcpSpawn, TASK_TOOL_TIMEOUT_SEC, TASKS_MCP_NAME, tasksMcpSpawn } from "../../core/tasks-mcp.js";
|
|
11
|
+
import { codexSkillLines } from "../../core/skill-runtime.js";
|
|
11
12
|
import { describeAttachments, inlineTextBlocks, readTextAttachments } from "../../core/uploads.js";
|
|
12
13
|
import type {
|
|
13
14
|
AgentEvents,
|
|
@@ -131,7 +132,7 @@ export async function userMcpServers(): Promise<string[]> {
|
|
|
131
132
|
if (userMcpCache && Date.now() - userMcpCache.at < 60_000) return userMcpCache.names;
|
|
132
133
|
try {
|
|
133
134
|
const res = await appServer.call<{ config?: { mcp_servers?: Record<string, unknown> | null } }>("config/read", {});
|
|
134
|
-
const names = Object.keys(res.config?.mcp_servers ?? {}).filter((n) => n !== MEMORY_MCP_NAME && n !== TASKS_MCP_NAME);
|
|
135
|
+
const names = Object.keys(res.config?.mcp_servers ?? {}).filter((n) => n !== MEMORY_MCP_NAME && n !== TASKS_MCP_NAME && n !== SKILLS_MCP_NAME);
|
|
135
136
|
userMcpCache = { names, at: Date.now() };
|
|
136
137
|
return names;
|
|
137
138
|
} catch (err) {
|
|
@@ -368,13 +369,33 @@ export class CodexSession implements AgentSession {
|
|
|
368
369
|
const mcp = {
|
|
369
370
|
...(memory ? { [`mcp_servers.${MEMORY_MCP_NAME}`]: memoryMcpSpawn(memory.userId, "codex", { search: !!memory.search, ...(memory.convKey ? { convKey: memory.convKey } : {}) }) } : {}),
|
|
370
371
|
// 新建 / 修改任务要等用户点授权卡,Codex 默认 60 秒就算工具超时
|
|
371
|
-
...(tasks
|
|
372
|
+
...(tasks
|
|
373
|
+
? {
|
|
374
|
+
[`mcp_servers.${TASKS_MCP_NAME}`]: { ...tasksMcpSpawn(tasks.userId, tasks.convKey, "codex"), tool_timeout_sec: TASK_TOOL_TIMEOUT_SEC },
|
|
375
|
+
[`mcp_servers.${SKILLS_MCP_NAME}`]: { ...skillsMcpSpawn(tasks.userId, tasks.convKey, "codex"), tool_timeout_sec: TASK_TOOL_TIMEOUT_SEC },
|
|
376
|
+
}
|
|
377
|
+
: {}),
|
|
372
378
|
};
|
|
373
379
|
if (this.chat && !minimal) {
|
|
374
380
|
const off = Object.fromEntries((await userMcpServers()).map((n) => [`mcp_servers.${n}.enabled`, false]));
|
|
375
381
|
return { config: { ...CHAT_CONFIG, ...off, ...mcp } };
|
|
376
382
|
}
|
|
377
|
-
|
|
383
|
+
const work = { ...mcp, ...(minimal ? {} : this.skillConfig()) };
|
|
384
|
+
return Object.keys(work).length ? { config: work } : {};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* 工作模式的技能设置:变量逐个写进 shell_environment_policy.set(只作用于这个 thread 里跑的命令,
|
|
389
|
+
* 一个个键写不会冲掉用户 config.toml 里本来设的);成员关掉 Codex 自己的技能清单 ——
|
|
390
|
+
* 那里面是机器上装的技能,只给管理员。成员的技能走 contextBlock 里的清单。
|
|
391
|
+
*/
|
|
392
|
+
private skillConfig(): Record<string, unknown> {
|
|
393
|
+
const skills = this.opts.skills;
|
|
394
|
+
if (!skills) return {};
|
|
395
|
+
return {
|
|
396
|
+
...Object.fromEntries(Object.entries(skills.env).map(([k, v]) => [`shell_environment_policy.set.${k}`, v])),
|
|
397
|
+
...(skills.admin ? {} : { "skills.include_instructions": false, "features.skill_search": false }),
|
|
398
|
+
};
|
|
378
399
|
}
|
|
379
400
|
|
|
380
401
|
/**
|
|
@@ -385,7 +406,7 @@ export class CodexSession implements AgentSession {
|
|
|
385
406
|
private contextSent = false;
|
|
386
407
|
|
|
387
408
|
private get contextPending(): boolean {
|
|
388
|
-
return !this.contextSent && (this.chat || !!this.opts.memory || !!this.opts.announceMode);
|
|
409
|
+
return !this.contextSent && (this.chat || !!this.opts.memory || !!this.opts.announceMode || codexSkillLines(this.opts.skills).length > 0);
|
|
389
410
|
}
|
|
390
411
|
|
|
391
412
|
private async openOnce(): Promise<string> {
|
|
@@ -503,7 +524,7 @@ export class CodexSession implements AgentSession {
|
|
|
503
524
|
const note = input.attachments.length ? describeAttachments(input.attachments, inlined, chat) : "";
|
|
504
525
|
const texts = chat ? inlineTextBlocks(input.attachments, textMap) : "";
|
|
505
526
|
// 放在最后:Codex 拿第一条消息的开头当会话预览,不能被这段背景占掉
|
|
506
|
-
const context = this.contextPending ? contextBlock(chat ? "chat" : "work", this.opts.memory?.prompt, "联网搜索") : "";
|
|
527
|
+
const context = this.contextPending ? contextBlock(chat ? "chat" : "work", this.opts.memory?.prompt, "联网搜索", codexSkillLines(this.opts.skills)) : "";
|
|
507
528
|
const text = [input.text, note, texts, context].filter(Boolean).join("\n\n");
|
|
508
529
|
const images = input.attachments.filter((a) => a.kind === "image").map((a) => a.path);
|
|
509
530
|
let startedOn: string | undefined;
|
package/src/channels/web/api.ts
CHANGED
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
import { canSeeConv, pushOwnedBy, sessionVisibleTo } from "./access.js";
|
|
64
64
|
import { clientIp, cookies, HttpError, isHttps, readBody, readJsonBody, SECURITY_HEADERS, sendJson } from "./http.js";
|
|
65
65
|
import { handleTasksApi } from "./tasks-api.js";
|
|
66
|
+
import { handleSkillsApi } from "./skills-api.js";
|
|
66
67
|
import { addSubscription, listSubscriptions, pushToAll, removeSubscription, removeSubscriptionsOf, vapidPublicKey } from "./push.js";
|
|
67
68
|
|
|
68
69
|
export const COOKIE = "aim_session";
|
|
@@ -527,6 +528,7 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
527
528
|
|
|
528
529
|
// ----- 定时任务 -----
|
|
529
530
|
if (p.startsWith("/api/tasks") && (await handleTasksApi(req, res, p, user))) return;
|
|
531
|
+
if (p.startsWith("/api/skills") && (await handleSkillsApi(req, res, url, user))) return;
|
|
530
532
|
|
|
531
533
|
// ----- 目录选择器:列工作区内的子目录 -----
|
|
532
534
|
if (p === "/api/dirs" && method === "GET") {
|
|
@@ -844,6 +846,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
|
|
|
844
846
|
allowedAgents: hub.enabledAgents().map((d) => d.id),
|
|
845
847
|
// 带 key 是输入框补全:只列 Web 能用的;不带是设置页排序,全列
|
|
846
848
|
...(conv ? { channel: "web" } : {}),
|
|
849
|
+
// 成员看不到机器上装的技能
|
|
850
|
+
...(admin ? {} : { member: true }),
|
|
847
851
|
});
|
|
848
852
|
sendJson(res, 200, { commands });
|
|
849
853
|
return;
|
package/src/channels/web/http.ts
CHANGED
|
@@ -89,8 +89,8 @@ export async function readBody(req: IncomingMessage, limit: number): Promise<Buf
|
|
|
89
89
|
return Buffer.concat(chunks);
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
export async function readJsonBody<T = Record<string, unknown>>(req: IncomingMessage): Promise<T> {
|
|
93
|
-
const raw = await readBody(req,
|
|
92
|
+
export async function readJsonBody<T = Record<string, unknown>>(req: IncomingMessage, limit = 1024 * 1024): Promise<T> {
|
|
93
|
+
const raw = await readBody(req, limit);
|
|
94
94
|
if (raw.length === 0) return {} as T;
|
|
95
95
|
try {
|
|
96
96
|
return JSON.parse(raw.toString("utf8")) as T;
|
|
@@ -14,6 +14,7 @@ import { GLOBAL_ALWAYS_DENIED, hub, type EventEnvelope } from "../../core/hub.js
|
|
|
14
14
|
import { interactions, type Interaction } from "../../core/interactions.js";
|
|
15
15
|
import { getConversation } from "../../core/store.js";
|
|
16
16
|
import { taskEvents } from "../../core/tasks.js";
|
|
17
|
+
import { skillEvents } from "../../core/skills.js";
|
|
17
18
|
import { RUNNING_VERSION } from "../../core/upgrade.js";
|
|
18
19
|
import type { Settlement } from "../../core/types.js";
|
|
19
20
|
import { getUser, isAdmin, legacyOwnerId, type User } from "../../core/users.js";
|
|
@@ -186,6 +187,11 @@ function broadcastTasks(owner: string): void {
|
|
|
186
187
|
for (const client of clients) if (client.user.id === owner) send(client, { t: "tasks" });
|
|
187
188
|
}
|
|
188
189
|
|
|
190
|
+
/** 技能变了:技能广场谁都可能在看,通知所有人重新拉(列表很小) */
|
|
191
|
+
function broadcastSkills(): void {
|
|
192
|
+
for (const client of clients) send(client, { t: "skills" });
|
|
193
|
+
}
|
|
194
|
+
|
|
189
195
|
// ---------- 系统通知(Web Push) ----------
|
|
190
196
|
|
|
191
197
|
/** 有页面正在前台看着这条会话:人就在眼前,不用推 */
|
|
@@ -485,6 +491,7 @@ export async function startWeb(): Promise<void> {
|
|
|
485
491
|
interactions.on("updated", broadcastInteraction);
|
|
486
492
|
interactions.on("settled", broadcastInteraction);
|
|
487
493
|
taskEvents.on("changed", broadcastTasks);
|
|
494
|
+
skillEvents.on("changed", broadcastSkills);
|
|
488
495
|
|
|
489
496
|
await listen(server, host, port);
|
|
490
497
|
console.log(`[web] 已监听 http://${host}:${port}`);
|
|
@@ -515,6 +522,7 @@ export async function stopWeb(): Promise<void> {
|
|
|
515
522
|
interactions.off("updated", broadcastInteraction);
|
|
516
523
|
interactions.off("settled", broadcastInteraction);
|
|
517
524
|
taskEvents.off("changed", broadcastTasks);
|
|
525
|
+
skillEvents.off("changed", broadcastSkills);
|
|
518
526
|
if (heartbeat) clearInterval(heartbeat);
|
|
519
527
|
for (const client of clients) client.ws.close(1001, "服务重启");
|
|
520
528
|
clients.clear();
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// 技能页面的接口:/api/skills。看得到的技能(自己的、公开的、指名给自己的)、安装 / 停用、变量、上传。
|
|
2
|
+
// 只有作者能改;管理员能删别人公开或分享出来的(下架),但看不到、删不了别人私有的。
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
6
|
+
import { machineSkills, mentionTargets } from "../../core/skill-runtime.js";
|
|
7
|
+
import {
|
|
8
|
+
canDelete,
|
|
9
|
+
canSee,
|
|
10
|
+
createSkill,
|
|
11
|
+
declaredHosts,
|
|
12
|
+
deleteSkill,
|
|
13
|
+
getSkill,
|
|
14
|
+
installCount,
|
|
15
|
+
installSkill,
|
|
16
|
+
listAllSkills,
|
|
17
|
+
listFiles,
|
|
18
|
+
parseSkillMd,
|
|
19
|
+
prepareUpload,
|
|
20
|
+
readSkillFile,
|
|
21
|
+
readSkillMd,
|
|
22
|
+
setSkillEnabled,
|
|
23
|
+
setSkillVars,
|
|
24
|
+
SKILL_LIMITS,
|
|
25
|
+
updateSkill,
|
|
26
|
+
userPrefs,
|
|
27
|
+
type Skill,
|
|
28
|
+
type SkillInput,
|
|
29
|
+
} from "../../core/skills.js";
|
|
30
|
+
import { readZip } from "../../core/zip.js";
|
|
31
|
+
import { getUser, isAdmin, listUsers, type User } from "../../core/users.js";
|
|
32
|
+
import { HttpError, readJsonBody, sendJson } from "./http.js";
|
|
33
|
+
|
|
34
|
+
function view(s: Skill, user: User) {
|
|
35
|
+
const prefs = userPrefs(user.id);
|
|
36
|
+
const mine = s.owner === user.id;
|
|
37
|
+
return {
|
|
38
|
+
id: s.id,
|
|
39
|
+
name: s.name,
|
|
40
|
+
description: s.description,
|
|
41
|
+
owner: s.owner,
|
|
42
|
+
ownerName: getUser(s.owner)?.name ?? "(已删除的用户)",
|
|
43
|
+
mine,
|
|
44
|
+
visibility: s.visibility,
|
|
45
|
+
// 分享给了谁只有作者看得到
|
|
46
|
+
...(mine ? { sharedWith: s.sharedWith, installs: installCount(s.id) } : {}),
|
|
47
|
+
vars: s.vars,
|
|
48
|
+
source: s.source,
|
|
49
|
+
createdAt: s.createdAt,
|
|
50
|
+
updatedAt: s.updatedAt,
|
|
51
|
+
installed: mine || prefs.installed.includes(s.id),
|
|
52
|
+
enabled: !prefs.disabled.includes(s.id),
|
|
53
|
+
canDelete: canDelete({ id: user.id, role: user.role }, s),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 变量:值按名字存;密钥(任何一个看得到的技能把它标成密钥)不回显,只说填没填。
|
|
59
|
+
* 受保护的密钥再带上确认过的地址(hosts),作者后来加了没确认过的地址就列在 pending 里。
|
|
60
|
+
*/
|
|
61
|
+
function varsView(user: User, skills: Skill[]) {
|
|
62
|
+
const secret = new Set(skills.flatMap((s) => s.vars.filter((v) => v.secret).map((v) => v.name)));
|
|
63
|
+
const prefs = userPrefs(user.id);
|
|
64
|
+
const mineOrInstalled = skills.filter((s) => s.owner === user.id || prefs.installed.includes(s.id));
|
|
65
|
+
return Object.fromEntries(
|
|
66
|
+
Object.entries(prefs.vars).map(([k, v]) => {
|
|
67
|
+
const declared = declaredHosts(mineOrInstalled, k);
|
|
68
|
+
const approved = prefs.approved[k] ?? [];
|
|
69
|
+
const pending = declared.filter((h) => !approved.includes(h));
|
|
70
|
+
return [
|
|
71
|
+
k,
|
|
72
|
+
{
|
|
73
|
+
set: true,
|
|
74
|
+
...(secret.has(k) ? {} : { value: v }),
|
|
75
|
+
...(declared.length ? { hosts: approved } : {}),
|
|
76
|
+
...(pending.length ? { pending } : {}),
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type UploadBody = { zip?: string; files?: { path?: string; data?: string }[] };
|
|
84
|
+
|
|
85
|
+
/** 上传:zip(.zip / .skill)或者一堆文件(选文件夹 / 单个 SKILL.md),内容都是 base64 */
|
|
86
|
+
function uploadOf(raw: UploadBody | undefined): ReturnType<typeof prepareUpload> | undefined {
|
|
87
|
+
if (!raw) return undefined;
|
|
88
|
+
if (typeof raw.zip === "string") {
|
|
89
|
+
const entries = readZip(Buffer.from(raw.zip, "base64"), SKILL_LIMITS.totalBytes * 2, SKILL_LIMITS.files * 2);
|
|
90
|
+
return prepareUpload(entries);
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(raw.files)) {
|
|
93
|
+
if (raw.files.length > SKILL_LIMITS.files * 2) throw new Error(`文件太多(最多 ${SKILL_LIMITS.files} 个)`);
|
|
94
|
+
return prepareUpload(raw.files.map((f) => ({ path: String(f.path ?? ""), data: Buffer.from(String(f.data ?? ""), "base64") })));
|
|
95
|
+
}
|
|
96
|
+
throw new Error("上传的内容不对");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const bad = (err: unknown) => (err instanceof HttpError ? err : new HttpError(400, err instanceof Error ? err.message : String(err)));
|
|
100
|
+
|
|
101
|
+
/** 处理了就返回 true */
|
|
102
|
+
export async function handleSkillsApi(req: IncomingMessage, res: ServerResponse, url: URL, user: User): Promise<boolean> {
|
|
103
|
+
const method = req.method ?? "GET";
|
|
104
|
+
const p = url.pathname;
|
|
105
|
+
const viewer = { id: user.id, role: user.role };
|
|
106
|
+
|
|
107
|
+
if (p === "/api/skills" && method === "GET") {
|
|
108
|
+
const visible = listAllSkills().filter((s) => canSee(viewer, s));
|
|
109
|
+
sendJson(res, 200, {
|
|
110
|
+
skills: visible.map((s) => view(s, user)),
|
|
111
|
+
vars: varsView(user, visible),
|
|
112
|
+
// 分享给谁:列出其他还能登录的用户
|
|
113
|
+
users: listUsers()
|
|
114
|
+
.filter((u) => !u.disabled && u.id !== user.id)
|
|
115
|
+
.map((u) => ({ id: u.id, name: u.name })),
|
|
116
|
+
...(isAdmin(user) ? { machine: machineSkills().map((m) => ({ name: m.name, description: m.description, dir: m.dir, agents: m.agents })) } : {}),
|
|
117
|
+
limits: SKILL_LIMITS,
|
|
118
|
+
});
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 输入框 @ 的补全:自己能用的(管理员加上机器上原有的)
|
|
123
|
+
if (p === "/api/skills/mention" && method === "GET") {
|
|
124
|
+
sendJson(res, 200, { skills: mentionTargets(user.id).map((t) => ({ name: t.name, description: t.description, kind: t.kind })) });
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 管理员看机器上原有技能的 SKILL.md
|
|
129
|
+
if (p === "/api/skills/machine" && method === "GET") {
|
|
130
|
+
if (!isAdmin(user)) throw new HttpError(403, "只有管理员能看机器上的技能");
|
|
131
|
+
const m = machineSkills().find((x) => x.name === url.searchParams.get("name"));
|
|
132
|
+
if (!m) throw new HttpError(404, "没有这个技能");
|
|
133
|
+
let md = "";
|
|
134
|
+
try {
|
|
135
|
+
md = fs.readFileSync(path.join(m.dir, "SKILL.md"), "utf8");
|
|
136
|
+
} catch {
|
|
137
|
+
md = "(读不到 SKILL.md)";
|
|
138
|
+
}
|
|
139
|
+
sendJson(res, 200, { ...m, skillMd: md });
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (p === "/api/skills/vars" && method === "PUT") {
|
|
144
|
+
const body = await readJsonBody<{ values?: Record<string, unknown>; approve?: unknown }>(req);
|
|
145
|
+
try {
|
|
146
|
+
setSkillVars(user.id, body.values ?? {}, Array.isArray(body.approve) ? body.approve.map(String) : []);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
throw bad(err);
|
|
149
|
+
}
|
|
150
|
+
sendJson(res, 200, { vars: varsView(user, listAllSkills().filter((s) => canSee(viewer, s))) });
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (p === "/api/skills" && method === "POST") {
|
|
155
|
+
const body = await readJsonBody<SkillInput & { upload?: UploadBody }>(req, 16 * 1024 * 1024);
|
|
156
|
+
let skill: Skill;
|
|
157
|
+
try {
|
|
158
|
+
const upload = uploadOf(body.upload);
|
|
159
|
+
const { upload: _u, ...input } = body;
|
|
160
|
+
skill = createSkill(user.id, input, upload ? "upload" : "manual", upload);
|
|
161
|
+
} catch (err) {
|
|
162
|
+
throw bad(err);
|
|
163
|
+
}
|
|
164
|
+
sendJson(res, 200, view(skill, user));
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const m = /^\/api\/skills\/(s_[a-f0-9]+)(?:\/(install|enable|file))?$/.exec(p);
|
|
169
|
+
if (!m) return false;
|
|
170
|
+
const skill = getSkill(m[1]!);
|
|
171
|
+
if (!skill || !canSee(viewer, skill)) throw new HttpError(404, "这个技能不存在");
|
|
172
|
+
|
|
173
|
+
if (!m[2] && method === "GET") {
|
|
174
|
+
const md = parseSkillMd(readSkillMd(skill.id));
|
|
175
|
+
sendJson(res, 200, { ...view(skill, user), body: md.body, extra: md.extra, files: listFiles(skill.id) });
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
if (m[2] === "file" && method === "GET") {
|
|
179
|
+
const data = readSkillFile(skill.id, url.searchParams.get("path") ?? "");
|
|
180
|
+
if (!data) throw new HttpError(404, "没有这个文件");
|
|
181
|
+
// 只当文本看:二进制的给个提示
|
|
182
|
+
const text = data.includes(0) ? null : data.toString("utf8");
|
|
183
|
+
sendJson(res, 200, { text, size: data.length });
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (m[2] === "install" && method === "POST") {
|
|
187
|
+
const body = await readJsonBody<{ on?: boolean }>(req);
|
|
188
|
+
try {
|
|
189
|
+
installSkill(user.id, skill.id, body.on !== false);
|
|
190
|
+
} catch (err) {
|
|
191
|
+
throw bad(err);
|
|
192
|
+
}
|
|
193
|
+
sendJson(res, 200, view(skill, user));
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
if (m[2] === "enable" && method === "POST") {
|
|
197
|
+
const body = await readJsonBody<{ on?: boolean }>(req);
|
|
198
|
+
setSkillEnabled(user.id, skill.id, body.on !== false);
|
|
199
|
+
sendJson(res, 200, view(skill, user));
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
if (!m[2] && method === "PATCH") {
|
|
203
|
+
const body = await readJsonBody<SkillInput & { upload?: UploadBody }>(req, 16 * 1024 * 1024);
|
|
204
|
+
let updated: Skill;
|
|
205
|
+
try {
|
|
206
|
+
const upload = uploadOf(body.upload);
|
|
207
|
+
const { upload: _u, name: _n, ...input } = body;
|
|
208
|
+
updated = updateSkill(user.id, skill.id, input, upload ? { upload } : {});
|
|
209
|
+
} catch (err) {
|
|
210
|
+
throw bad(err);
|
|
211
|
+
}
|
|
212
|
+
sendJson(res, 200, view(updated, user));
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
if (!m[2] && method === "DELETE") {
|
|
216
|
+
try {
|
|
217
|
+
deleteSkill(viewer, skill.id);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
throw bad(err);
|
|
220
|
+
}
|
|
221
|
+
sendJson(res, 200, { ok: true });
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
package/src/core/chat-mode.ts
CHANGED
|
@@ -36,13 +36,13 @@ export function chatSystemPrompt(search: string, memory?: string): string {
|
|
|
36
36
|
const OPEN = "<agent-loft-context>";
|
|
37
37
|
const CLOSE = "</agent-loft-context>";
|
|
38
38
|
|
|
39
|
-
/** Codex:附在会话进程第一条消息后面的背景(模式 +
|
|
40
|
-
export function contextBlock(mode: "chat" | "work", memory: string | undefined, search: string): string {
|
|
39
|
+
/** Codex:附在会话进程第一条消息后面的背景(模式 + 记忆 + 工作模式下的技能清单) */
|
|
40
|
+
export function contextBlock(mode: "chat" | "work", memory: string | undefined, search: string, skills: string[] = []): string {
|
|
41
41
|
const head =
|
|
42
42
|
mode === "chat"
|
|
43
43
|
? ["当前是「聊天模式」,你作为用户的私人 AI 助手和他聊天:", ...chatRules(search)]
|
|
44
44
|
: ["当前是「工作模式」:可以执行命令、读写文件,按平常的方式干活。之前如果说过「聊天模式下不能做」,那个限制已经解除。"];
|
|
45
|
-
return wrapContext([...head, ...(memory ? ["", memory] : [])]);
|
|
45
|
+
return wrapContext([...head, ...(memory ? ["", memory] : []), ...(mode === "work" && skills.length ? ["", ...skills] : [])]);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/** 包成 agent-loft 附上的背景:用户看不到(读历史、实时显示时都剥掉),agent 照着做 */
|
|
@@ -24,6 +24,7 @@ export const BUILTIN_COMMANDS: BuiltinSpec[] = [
|
|
|
24
24
|
{ name: "effort", args: "[程度]", description: "切换思考程度" },
|
|
25
25
|
{ name: "chat", description: "切到聊天模式:精简、只能联网搜索,每轮开销小很多" },
|
|
26
26
|
{ name: "work", description: "切到工作模式:agent 原装的全部能力" },
|
|
27
|
+
{ name: "skills", description: "列出能用的技能(消息里写 @名字 点名使用)" },
|
|
27
28
|
{ name: "status", description: "查看当前会话、模型、目录、剩余额度" },
|
|
28
29
|
{ name: "sessions", args: "[all]", description: "列出历史会话,点按钮恢复;加 all 列所有目录" },
|
|
29
30
|
{ name: "resume", args: "<会话ID>", description: "恢复指定会话" },
|
|
@@ -42,7 +43,13 @@ export const BUILTIN_COMMANDS: BuiltinSpec[] = [
|
|
|
42
43
|
];
|
|
43
44
|
|
|
44
45
|
/** agent 自己报上来的命令 */
|
|
45
|
-
export type AgentCommand = {
|
|
46
|
+
export type AgentCommand = {
|
|
47
|
+
name: string;
|
|
48
|
+
description: string;
|
|
49
|
+
args?: string;
|
|
50
|
+
/** agent 自己带的(Claude Code 的 /compact、自带的技能),不是机器上另外装的 */
|
|
51
|
+
builtin?: boolean;
|
|
52
|
+
};
|
|
46
53
|
|
|
47
54
|
/**
|
|
48
55
|
* 用户自定义命令。prompt 里的 $ARGUMENTS 换成命令后面跟的参数;
|
package/src/core/commands.ts
CHANGED
|
@@ -20,6 +20,7 @@ export type ParsedCommand =
|
|
|
20
20
|
| { kind: "autoedit"; on?: boolean }
|
|
21
21
|
| { kind: "mode"; mode: "chat" | "work" }
|
|
22
22
|
| { kind: "help" }
|
|
23
|
+
| { kind: "skills" }
|
|
23
24
|
/** 只发了一个「/」:列出可用命令 */
|
|
24
25
|
| { kind: "commands" }
|
|
25
26
|
| { kind: "approve"; requestId: string; decision: ApprovalDecision }
|
|
@@ -75,6 +76,8 @@ export function parseCommand(raw: string): ParsedCommand {
|
|
|
75
76
|
return arg ? { kind: "chat", text } : { kind: "mode", mode: head.toLowerCase() as "chat" | "work" };
|
|
76
77
|
case "help":
|
|
77
78
|
return { kind: "help" };
|
|
79
|
+
case "skills":
|
|
80
|
+
return arg ? { kind: "chat", text } : { kind: "skills" };
|
|
78
81
|
case "approve": {
|
|
79
82
|
const [requestId, decision] = rest;
|
|
80
83
|
if (!requestId || !decision) return { kind: "chat", text };
|