mocode-ai 1.2.3 → 1.2.4
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/dist/agent/index.js +3 -2
- package/dist/agent/spawn.js +25 -1
- package/dist/context/pipeline.js +1 -1
- package/dist/i18n/index.js +8 -0
- package/dist/repl/index.js +69 -3
- package/dist/skills/activation.js +26 -0
- package/dist/skills/builtin-skills.js +5 -0
- package/dist/skills/discover.js +167 -21
- package/dist/skills/index.js +32 -7
- package/dist/skills/runner.js +191 -33
- package/dist/skills/toolmap.js +56 -0
- package/dist/skills/trust.js +134 -0
- package/dist/tools/builtins/index.js +3 -0
- package/dist/tools/builtins/run-skill.js +42 -0
- package/dist/tools/builtins/use-skill.js +43 -5
- package/dist/tools/constants.js +8 -0
- package/package.json +1 -1
package/dist/agent/index.js
CHANGED
|
@@ -118,8 +118,9 @@ function writeToolHeader(tc) {
|
|
|
118
118
|
}
|
|
119
119
|
return;
|
|
120
120
|
}
|
|
121
|
-
// sub-agent
|
|
122
|
-
|
|
121
|
+
// sub-agent 组还在跑,或组刚收口但尚未写入分隔空行时,
|
|
122
|
+
// 普通/mutation 工具先来了:先 flush 补一条分隔空行,避免摘要行粘在一起。
|
|
123
|
+
if (subAgentGroupId || subAgentGroupPendingSeparator) {
|
|
123
124
|
flushToolBatch();
|
|
124
125
|
}
|
|
125
126
|
if (isMutationTool(tc.name)) {
|
package/dist/agent/spawn.js
CHANGED
|
@@ -74,6 +74,8 @@ export async function spawnAgent(opts) {
|
|
|
74
74
|
const requested = opts.tools?.length ? new Set(opts.tools) : null;
|
|
75
75
|
const readOnly = new Set(['read_file', 'glob', 'grep', 'web_search', 'web_fetch', 'use_skill', 'memory_search', 'memory_list']);
|
|
76
76
|
toolsOverride = chatTools.filter((tool) => tool.function.name !== 'sub-agent' &&
|
|
77
|
+
// run_skill 会再次 spawn,禁止递归(避免 fork skill 里又 run_skill 套娃)。
|
|
78
|
+
tool.function.name !== 'run_skill' &&
|
|
77
79
|
// plan_update 直写主会话 notes.md(不走 overlay),子代理不应改动主计划——统一排除。
|
|
78
80
|
tool.function.name !== 'plan_update' &&
|
|
79
81
|
(!requested || requested.has(tool.function.name)) &&
|
|
@@ -93,7 +95,11 @@ export async function spawnAgent(opts) {
|
|
|
93
95
|
// 主屏实时渲染(子 agent 透明化):TUI 激活时把子 agent 内部步骤实时写入主内容区,
|
|
94
96
|
// 复用 batch 折叠机制(mouse 点击摘要行可展开/收起)。TUI 未激活(host/非 TTY)时
|
|
95
97
|
// 保持纯静默——只缓冲 transcript,不写屏,兼容嵌入宿主。
|
|
96
|
-
|
|
98
|
+
//
|
|
99
|
+
// quiet 模式(供 run_skill 等 opaque workflow 用):不产可展开 batch,
|
|
100
|
+
// 只在首次工具调用时写一行「◐ 执行 skill…」,完成后替换为「● skill 完成: 摘要」。
|
|
101
|
+
const live = isTuiActive() && !opts.quiet;
|
|
102
|
+
const quiet = isTuiActive() && !!opts.quiet;
|
|
97
103
|
let liveBatchId = null;
|
|
98
104
|
const liveLayout = () => ({
|
|
99
105
|
contentWrite: (s) => layout.contentWrite(s),
|
|
@@ -106,6 +112,16 @@ export async function spawnAgent(opts) {
|
|
|
106
112
|
});
|
|
107
113
|
/** 本批是否挂在主侧调用行下(挂上了就由主侧负责分隔空行,自己不能往 buffer 末尾追加)。 */
|
|
108
114
|
let nested = false;
|
|
115
|
+
// quiet 模式:完全静默,不写任何行(连 spinner 都没有);
|
|
116
|
+
// 子 agent 的执行过程与结果只回灌为主 agent 的 run_skill 工具结果。
|
|
117
|
+
const ensureQuietLine = (_label) => {
|
|
118
|
+
if (!quiet)
|
|
119
|
+
return; // 静默:什么都不输出
|
|
120
|
+
};
|
|
121
|
+
const replaceQuietLine = (_status, _detail) => {
|
|
122
|
+
if (!quiet)
|
|
123
|
+
return; // 静默:什么都不输出
|
|
124
|
+
};
|
|
109
125
|
const ensureLiveBatch = () => {
|
|
110
126
|
if (!live || liveBatchId)
|
|
111
127
|
return;
|
|
@@ -163,6 +179,7 @@ export async function spawnAgent(opts) {
|
|
|
163
179
|
const summary = summarizeToolCall(tc.name, tc.arguments);
|
|
164
180
|
writeBuf(` ● ${tc.name} ${summary}\n`);
|
|
165
181
|
// 实时写入主内容区:子 agent 的每次工具调用累计到摘要行计数。
|
|
182
|
+
ensureQuietLine(opts.quietLabel ?? t('skill.executing', { name: opts.prompt.slice(0, 40) }));
|
|
166
183
|
ensureLiveBatch();
|
|
167
184
|
if (liveBatchId) {
|
|
168
185
|
batch.recordCall(liveBatchId, tc.name, summary);
|
|
@@ -198,6 +215,8 @@ export async function spawnAgent(opts) {
|
|
|
198
215
|
onMaxSteps: () => {
|
|
199
216
|
writeBuf(` ● 达到最大步数(${maxSteps}),子 agent 停止。\n`);
|
|
200
217
|
finishLiveBatch('failed');
|
|
218
|
+
if (quiet)
|
|
219
|
+
replaceQuietLine('failed', t('skill.maxSteps', { max: String(maxSteps) }));
|
|
201
220
|
},
|
|
202
221
|
onDone: (elapsedMs, usage) => {
|
|
203
222
|
const tok = usage && usage.totalTokens
|
|
@@ -205,10 +224,15 @@ export async function spawnAgent(opts) {
|
|
|
205
224
|
: '';
|
|
206
225
|
writeBuf(` ✻ 子 agent 耗时 ${(elapsedMs / 1000).toFixed(1)}s${tok}\n`);
|
|
207
226
|
finishLiveBatch('complete');
|
|
227
|
+
if (quiet) {
|
|
228
|
+
replaceQuietLine('complete', `${t('skill.complete')} ${(elapsedMs / 1000).toFixed(1)}s${tok}`);
|
|
229
|
+
}
|
|
208
230
|
},
|
|
209
231
|
onAbort: () => {
|
|
210
232
|
// 中断:子 agent 未完成,收尾批(不折叠——用户可能想看中断前做了什么)。
|
|
211
233
|
finishLiveBatch('aborted');
|
|
234
|
+
if (quiet)
|
|
235
|
+
replaceQuietLine('aborted', t('skill.aborted'));
|
|
212
236
|
},
|
|
213
237
|
// onStepStart / onChatDone / onToolStart / onToolDone:子 agent 静默,无需 spinner 渲染。
|
|
214
238
|
// abort 还原(history 还原 + 模式还原)由 core 的 abortRestore 处理,hooks 只管展示。
|
package/dist/context/pipeline.js
CHANGED
|
@@ -37,7 +37,7 @@ function tryParseArgs(raw) {
|
|
|
37
37
|
* 仅作 encoder 软目标;最终裁剪仍由末尾 capToolResultForHistory 兜底,故两处常量偶有漂移不致命。
|
|
38
38
|
*/
|
|
39
39
|
function budgetFor(name) {
|
|
40
|
-
if (name === 'use_skill')
|
|
40
|
+
if (name === 'use_skill' || name === 'run_skill')
|
|
41
41
|
return MAX_SKILL_RESULT;
|
|
42
42
|
if (name === 'memory_search')
|
|
43
43
|
return MAX_MEMORY_RESULT;
|
package/dist/i18n/index.js
CHANGED
|
@@ -217,6 +217,10 @@ const zhCN = {
|
|
|
217
217
|
'subagent.stateOff': '关闭',
|
|
218
218
|
'subagent.changedOn': '已开启子 Agent;sub-agent 将从下一次模型请求起可用。',
|
|
219
219
|
'subagent.changedOff': '已关闭子 Agent;sub-agent 已从模型工具表移除。',
|
|
220
|
+
'skill.executing': '执行 skill: {name}…',
|
|
221
|
+
'skill.complete': 'Skill 执行完成',
|
|
222
|
+
'skill.aborted': 'Skill 执行已中断',
|
|
223
|
+
'skill.maxSteps': 'Skill 达到最大步数({max})',
|
|
220
224
|
'subagent.usage': '用法:/subagent on|off|status',
|
|
221
225
|
'fe.status': '前端工具簇:{state}',
|
|
222
226
|
'fe.stateOn': '开启',
|
|
@@ -472,6 +476,10 @@ const en = {
|
|
|
472
476
|
'subagent.stateOff': 'disabled',
|
|
473
477
|
'subagent.changedOn': 'Sub-agents enabled; sub-agent will be available from the next model request.',
|
|
474
478
|
'subagent.changedOff': 'Sub-agents disabled; sub-agent has been removed from the model tool list.',
|
|
479
|
+
'skill.executing': 'Executing skill: {name}…',
|
|
480
|
+
'skill.complete': 'Skill complete',
|
|
481
|
+
'skill.aborted': 'Skill aborted',
|
|
482
|
+
'skill.maxSteps': 'Skill reached max steps ({max})',
|
|
475
483
|
'subagent.usage': 'Usage: /subagent on|off|status',
|
|
476
484
|
'fe.status': 'Frontend tools: {state}',
|
|
477
485
|
'fe.stateOn': 'enabled',
|
package/dist/repl/index.js
CHANGED
|
@@ -26,7 +26,10 @@ import { computePruneStats } from '../context/relevance.js';
|
|
|
26
26
|
import { formatArtifactTokenSources } from '../context/artifacts.js';
|
|
27
27
|
import { manualCompact, contextState, newSessionId, saveSession, loadSession, listSessions, appendCurrentSessionRuntimeEvent, hashTraceValue, } from '../session/index.js';
|
|
28
28
|
import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, getCurrentTurnId, } from '../rollback/index.js';
|
|
29
|
-
import { listSkills, effectiveSystemPrompt, } from '../skills/index.js';
|
|
29
|
+
import { listSkills, effectiveSystemPrompt, findSkill, } from '../skills/index.js';
|
|
30
|
+
import { clearSkillActivation } from '../skills/activation.js';
|
|
31
|
+
import { runSkill, renderSkillBody } from '../skills/runner.js';
|
|
32
|
+
import { isSkillTrusted } from '../skills/trust.js';
|
|
30
33
|
import { buildMemoryIndexSection, kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, loadAll, } from '../memory/index.js';
|
|
31
34
|
import fs from 'node:fs';
|
|
32
35
|
import path from 'node:path';
|
|
@@ -50,6 +53,7 @@ function buildSlashCommands() {
|
|
|
50
53
|
{ name: '/clear', desc: d('commands.clear') },
|
|
51
54
|
{ name: '/context', desc: d('commands.context') },
|
|
52
55
|
{ name: '/skills', desc: d('commands.skills') },
|
|
56
|
+
{ name: '/skill', desc: '执行某个 skill(/skill <name> [args-json])' },
|
|
53
57
|
{ name: '/compact', desc: d('commands.compact') },
|
|
54
58
|
{ name: '/resume', desc: d('commands.sessionResume') },
|
|
55
59
|
{ name: '/sessions', desc: d('commands.sessionBrowse') },
|
|
@@ -998,6 +1002,8 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
998
1002
|
// 入口设定本轮初始模式(合成执行轮传 false→auto;用户轮传当前 mode)。
|
|
999
1003
|
// setAgentMode 触发 listener 重写 history[0];LLM 可在轮中调 switch_mode 切模式,runAgent 每步读实时值。
|
|
1000
1004
|
setAgentMode(planMode ? 'plan' : 'auto');
|
|
1005
|
+
// 新用户轮开始:清除上一轮 inline skill 的激活态(允许/disallowed 约束一 turn 有效)。
|
|
1006
|
+
clearSkillActivation();
|
|
1001
1007
|
// 运行中每步 chat() 返回后刷新状态行 context 用量条(用 fresh lastUsage / 估算),
|
|
1002
1008
|
// 否则整轮冻结在轮首 refreshStatusBase 的值,「执行 grep」时 2k/1000k 不动。
|
|
1003
1009
|
const result = await runAgent(history, userInput, signal, () => {
|
|
@@ -1455,9 +1461,69 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
|
|
|
1455
1461
|
else {
|
|
1456
1462
|
layout.contentWrite(`${ui.dim}已发现 ${skills.length} 个 skill:${ui.reset}\n`);
|
|
1457
1463
|
for (const s of skills) {
|
|
1458
|
-
|
|
1464
|
+
const badges = [];
|
|
1465
|
+
if (s.context === 'fork')
|
|
1466
|
+
badges.push('fork');
|
|
1467
|
+
if (!s.modelInvocable)
|
|
1468
|
+
badges.push('manual-only');
|
|
1469
|
+
badges.push(s.origin);
|
|
1470
|
+
if (s.origin === 'project') {
|
|
1471
|
+
badges.push(isSkillTrusted(s) ? 'trusted' : 'untrusted');
|
|
1472
|
+
}
|
|
1473
|
+
const badgeStr = badges.length ? ` ${ui.dim}[${badges.join('|')}]${ui.reset}` : '';
|
|
1474
|
+
layout.contentWrite(` ${ui.accent}${s.name}${ui.reset}${badgeStr} ${ui.dim}${s.description}${ui.reset}\n`);
|
|
1475
|
+
if (s.allowedTools?.length) {
|
|
1476
|
+
layout.contentWrite(` ${ui.dim}allowed: ${s.allowedTools.join(', ')}${ui.reset}\n`);
|
|
1477
|
+
}
|
|
1478
|
+
if (s.disallowedTools?.length) {
|
|
1479
|
+
layout.contentWrite(` ${ui.dim}disallowed: ${s.disallowedTools.join(', ')}${ui.reset}\n`);
|
|
1480
|
+
}
|
|
1481
|
+
if (s.warnings.length) {
|
|
1482
|
+
layout.contentWrite(` ${ui.dim}warnings: ${s.warnings.join('; ')}${ui.reset}\n`);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
layout.contentWrite(`${ui.dim}(用 use_skill 加载指令; fork 类用 run_skill 执行; 也支持 /skill <name> [args-json])${ui.reset}\n`);
|
|
1486
|
+
}
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
// /skill <name> [args-json]:直接执行一个 skill(fork 走 run_skill,inline 打印渲染后正文)。
|
|
1490
|
+
if (line === '/skill' || line.startsWith('/skill ')) {
|
|
1491
|
+
const rest = line.slice('/skill'.length).trim();
|
|
1492
|
+
const sp = rest.indexOf(' ');
|
|
1493
|
+
const name = sp === -1 ? rest : rest.slice(0, sp);
|
|
1494
|
+
const argStr = sp === -1 ? '' : rest.slice(sp + 1).trim();
|
|
1495
|
+
if (!name) {
|
|
1496
|
+
layout.contentWrite(`${ui.dim}用法: /skill <name> [args-json]${ui.reset}\n`);
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
let args;
|
|
1500
|
+
if (argStr) {
|
|
1501
|
+
try {
|
|
1502
|
+
args = JSON.parse(argStr);
|
|
1503
|
+
}
|
|
1504
|
+
catch {
|
|
1505
|
+
layout.contentWrite(`${ui.dim}args 不是合法 JSON,已忽略。${ui.reset}\n`);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
const skill = findSkill(name);
|
|
1509
|
+
if (!skill) {
|
|
1510
|
+
layout.contentWrite(`错误:未找到 skill "${name}"。\n`);
|
|
1511
|
+
continue;
|
|
1512
|
+
}
|
|
1513
|
+
if (skill.context === 'fork') {
|
|
1514
|
+
layout.contentWrite(`${ui.dim}执行 fork skill "${name}"…${ui.reset}\n`);
|
|
1515
|
+
const out = await runSkill({ name, args });
|
|
1516
|
+
layout.contentWrite((out.status === 'success' ? '' : `[${out.status}] `) + out.output + '\n');
|
|
1517
|
+
}
|
|
1518
|
+
else {
|
|
1519
|
+
const body = await renderSkillBody(skill, args);
|
|
1520
|
+
if (body === null)
|
|
1521
|
+
layout.contentWrite(`错误:未找到 skill "${name}" 的正文。\n`);
|
|
1522
|
+
else {
|
|
1523
|
+
// 仅预览:inline 正文未进入模型 history,不激活工具面约束;
|
|
1524
|
+
// 要让模型按此 skill 工作,请让它调 use_skill(name) 或在下条消息里提及该 skill。
|
|
1525
|
+
layout.contentWrite(`# Skill: ${name}(预览,模型尚未看到此内容)\n\n${body}\n`);
|
|
1459
1526
|
}
|
|
1460
|
-
layout.contentWrite(`${ui.dim}(用 use_skill 工具加载某 skill 的完整指令)${ui.reset}\n`);
|
|
1461
1527
|
}
|
|
1462
1528
|
continue;
|
|
1463
1529
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// inline skill 激活态(设计 §3.6):use_skill 成功加载 inline skill 时置位,
|
|
2
|
+
// 该 turn 内模型的 disallowed-tools 约束生效(constants.getRuntimeDisabledTools 消费)。
|
|
3
|
+
// 一 turn 语义:下一次用户轮开始时由 repl 调 clearSkillActivation() 清除。
|
|
4
|
+
//
|
|
5
|
+
// 刻意不设 allowed 授权:inline 加载不过信任门禁,自动放行 confirm 级工具会让
|
|
6
|
+
// 未信任的项目 skill 绕过权限确认。allowed-tools 只在 fork 模式下经 spawnAgent
|
|
7
|
+
// 的工具白名单生效(runner.runSkill)。
|
|
8
|
+
//
|
|
9
|
+
// 叶子模块:仅依赖 toolmap(亦叶子),不引 tools/permissions/agent,避免环。
|
|
10
|
+
import { mapSkillToolName } from './toolmap.js';
|
|
11
|
+
let active = null;
|
|
12
|
+
/** use_skill 成功加载 inline skill 时调用。 */
|
|
13
|
+
export function activateSkill(skill) {
|
|
14
|
+
const disallowed = skill.disallowedTools?.map(mapSkillToolName).filter((t) => t != null);
|
|
15
|
+
active = {
|
|
16
|
+
name: skill.name,
|
|
17
|
+
disallowed: disallowed && disallowed.length ? new Set(disallowed) : null,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** 用户新轮开始时清激活态。 */
|
|
21
|
+
export function clearSkillActivation() {
|
|
22
|
+
active = null;
|
|
23
|
+
}
|
|
24
|
+
export function getActiveSkill() {
|
|
25
|
+
return active;
|
|
26
|
+
}
|
|
@@ -50,6 +50,11 @@ export const builtinSkills = [
|
|
|
50
50
|
body: CODEGRAPH_BODY,
|
|
51
51
|
dir: 'builtin',
|
|
52
52
|
skillMdPath: 'builtin',
|
|
53
|
+
// 内置 skill:恒信任、内联、模型可自动触发。
|
|
54
|
+
context: 'inline',
|
|
55
|
+
modelInvocable: true,
|
|
56
|
+
origin: 'builtin',
|
|
57
|
+
warnings: [],
|
|
53
58
|
},
|
|
54
59
|
];
|
|
55
60
|
/** 内置 skill 名字集,供 discoverSkills 跳过/覆盖时使用。 */
|
package/dist/skills/discover.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// skills 发现子系统。
|
|
2
2
|
// 仅依赖 node 标准库,是叶子模块:不依赖 config/agent/llm/tools,避免环。
|
|
3
3
|
//
|
|
4
|
-
// 约定:每个 skill 是一个目录 <skill-name>/SKILL.md,顶部 YAML frontmatter
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
4
|
+
// 约定:每个 skill 是一个目录 <skill-name>/SKILL.md,顶部 YAML frontmatter。
|
|
5
|
+
// 元数据始终注入系统提示,正文由 use_skill 工具按需加载(渐进式披露)。
|
|
6
|
+
// frontmatter 兼容 Agent Skills 开放标准(name/description/allowed-tools/context/agent…),
|
|
7
|
+
// 解析器是受限 YAML 子集(自写,不引 yaml 依赖),越界字段一律忽略 + 记 warning。
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
8
9
|
import os from 'node:os';
|
|
9
10
|
import path from 'node:path';
|
|
10
11
|
import { builtinSkillNames } from './builtin-skills.js';
|
|
@@ -39,10 +40,85 @@ export function resolveSkillsDirs() {
|
|
|
39
40
|
path.join(process.cwd(), '.mocode', 'skills'),
|
|
40
41
|
];
|
|
41
42
|
}
|
|
43
|
+
/** 数组型 frontmatter 键(这些键的整行值按空白切分为 token 列表)。 */
|
|
44
|
+
const ARRAY_KEYS = new Set(['allowed-tools', 'disallowed-tools']);
|
|
45
|
+
/** 行内数组 / 方括号数组:按空白或逗号切分,去空。 */
|
|
46
|
+
function splitList(s) {
|
|
47
|
+
return s
|
|
48
|
+
.split(/[\s,]+/)
|
|
49
|
+
.map((x) => x.trim())
|
|
50
|
+
.filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
/** 取标量值(数组值返回 undefined)。 */
|
|
53
|
+
function scalar(meta, key) {
|
|
54
|
+
const v = meta[key];
|
|
55
|
+
return typeof v === 'string' ? v : undefined;
|
|
56
|
+
}
|
|
57
|
+
/** 取可能为数组的值,统一成 string[] | undefined。 */
|
|
58
|
+
function asArray(meta, key) {
|
|
59
|
+
const v = meta[key];
|
|
60
|
+
if (Array.isArray(v))
|
|
61
|
+
return v.length ? v : undefined;
|
|
62
|
+
if (typeof v === 'string' && v)
|
|
63
|
+
return splitList(v);
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
function truthy(meta, key) {
|
|
67
|
+
const v = scalar(meta, key);
|
|
68
|
+
return v != null && (v.toLowerCase() === 'true' || v === '1');
|
|
69
|
+
}
|
|
70
|
+
function num(meta, key) {
|
|
71
|
+
const v = scalar(meta, key);
|
|
72
|
+
if (v == null)
|
|
73
|
+
return undefined;
|
|
74
|
+
const n = Number(v);
|
|
75
|
+
return Number.isFinite(n) ? n : undefined;
|
|
76
|
+
}
|
|
77
|
+
/** 去掉首尾配对引号(YAML 标量基本语义;`"use when: foo"` → `use when: foo`)。 */
|
|
78
|
+
function unquote(s) {
|
|
79
|
+
if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[s.length - 1] === s[0]) {
|
|
80
|
+
return s.slice(1, -1);
|
|
81
|
+
}
|
|
82
|
+
return s;
|
|
83
|
+
}
|
|
84
|
+
/** `context: fork` 或兼容别名 `mode: fork` → 'fork';其余 'inline'。 */
|
|
85
|
+
function resolveContext(meta) {
|
|
86
|
+
if (scalar(meta, 'context') === 'fork')
|
|
87
|
+
return 'fork';
|
|
88
|
+
if (scalar(meta, 'mode') === 'fork')
|
|
89
|
+
return 'fork'; // 旧内部别名
|
|
90
|
+
return 'inline';
|
|
91
|
+
}
|
|
92
|
+
/** `agent:` 映射到 mocode 的 read/write 双态;未知值记 warning 并保守按 read。 */
|
|
93
|
+
function resolveAgentMode(meta, warnings) {
|
|
94
|
+
const a = scalar(meta, 'agent');
|
|
95
|
+
if (!a)
|
|
96
|
+
return undefined;
|
|
97
|
+
const low = a.toLowerCase();
|
|
98
|
+
if (['explore', 'plan', 'research', 'read'].includes(low))
|
|
99
|
+
return 'read';
|
|
100
|
+
if (['general-purpose', 'write'].includes(low))
|
|
101
|
+
return 'write';
|
|
102
|
+
warnings.push(`未知 agent 值 "${a}",按 read(只读,保守)处理`);
|
|
103
|
+
return 'read';
|
|
104
|
+
}
|
|
105
|
+
/** 收集本轮不支持、已忽略的字段,用于 /skills 提示(不阻断加载)。 */
|
|
106
|
+
function collectWarnings(meta) {
|
|
107
|
+
const warnings = [];
|
|
108
|
+
for (const k of ['hooks', 'model', 'effort', 'paths']) {
|
|
109
|
+
if (k in meta)
|
|
110
|
+
warnings.push(`字段 "${k}" 当前不支持,已忽略`);
|
|
111
|
+
}
|
|
112
|
+
return warnings;
|
|
113
|
+
}
|
|
42
114
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
115
|
+
* 受限 YAML 子集解析(不引入 yaml 依赖)。
|
|
116
|
+
* 支持四种值形态:
|
|
117
|
+
* - 标量 `key: value`
|
|
118
|
+
* - 行内数组 `allowed-tools: a, b` / 方括号 `allowed-tools: [a, b]`
|
|
119
|
+
* - 块序列(下一行起 ` - item`)
|
|
120
|
+
* - 空格分隔字符串(标准形态 `allowed-tools: Read grep run_command`,仅数组型键)
|
|
121
|
+
* 其余(嵌套 map / 块标量)一律忽略,保持攻击面与维护成本可控。
|
|
46
122
|
*
|
|
47
123
|
* 返回 { meta, body }:无 frontmatter 时 meta 为空、body 为原文。
|
|
48
124
|
*/
|
|
@@ -70,7 +146,16 @@ export function parseFrontmatter(content) {
|
|
|
70
146
|
if (!closed)
|
|
71
147
|
return { meta, body: content }; // 无闭合:视为无 frontmatter
|
|
72
148
|
i++; // 跳过闭 ---
|
|
73
|
-
|
|
149
|
+
const isListItem = (l) => {
|
|
150
|
+
const t = l.trim();
|
|
151
|
+
return t === '-' || t.startsWith('- ');
|
|
152
|
+
};
|
|
153
|
+
const itemText = (l) => {
|
|
154
|
+
const t = l.trim();
|
|
155
|
+
return t === '-' ? '' : t.slice(2).trim();
|
|
156
|
+
};
|
|
157
|
+
for (let k = 0; k < fmLines.length; k++) {
|
|
158
|
+
const line = fmLines[k];
|
|
74
159
|
const trimmed = line.trim();
|
|
75
160
|
if (trimmed === '' || trimmed.startsWith('#'))
|
|
76
161
|
continue;
|
|
@@ -78,15 +163,30 @@ export function parseFrontmatter(content) {
|
|
|
78
163
|
if (colon === -1)
|
|
79
164
|
continue;
|
|
80
165
|
const key = line.slice(0, colon).trim();
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
if (value.length
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
166
|
+
const value = line.slice(colon + 1).trim();
|
|
167
|
+
// 块序列:当前值为空且下一行是列表项
|
|
168
|
+
if (value === '' && k + 1 < fmLines.length && isListItem(fmLines[k + 1])) {
|
|
169
|
+
const arr = [];
|
|
170
|
+
let j = k + 1;
|
|
171
|
+
while (j < fmLines.length && isListItem(fmLines[j])) {
|
|
172
|
+
arr.push(itemText(fmLines[j]));
|
|
173
|
+
j++;
|
|
174
|
+
}
|
|
175
|
+
meta[key] = arr;
|
|
176
|
+
k = j - 1; // for 循环会再 +1
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// 方括号数组
|
|
180
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
181
|
+
meta[key] = splitList(value.slice(1, -1));
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
// 数组型键:整行按空白切分为 token(标准形态 Bash(git:*) Read)
|
|
185
|
+
if (ARRAY_KEYS.has(key)) {
|
|
186
|
+
meta[key] = value ? splitList(value) : [];
|
|
187
|
+
continue;
|
|
87
188
|
}
|
|
88
|
-
|
|
89
|
-
meta[key] = value;
|
|
189
|
+
meta[key] = unquote(value);
|
|
90
190
|
}
|
|
91
191
|
const body = lines.slice(i).join('\n').replace(/^\n+/, '');
|
|
92
192
|
return { meta, body };
|
|
@@ -99,6 +199,10 @@ export function parseFrontmatter(content) {
|
|
|
99
199
|
*/
|
|
100
200
|
export function discoverSkills() {
|
|
101
201
|
const dirs = resolveSkillsDirs();
|
|
202
|
+
// 记录本次发现的所有 SKILL.md 路径,供 skillsScanSignature 做廉价热重载探测
|
|
203
|
+
// (只 stat 已知文件,不每次全量重扫)。新增 skill 目录会改目录 mtime → 触发全量重扫。
|
|
204
|
+
const seenMdPaths = [];
|
|
205
|
+
const projDir = path.join(process.cwd(), '.mocode', 'skills');
|
|
102
206
|
const byName = new Map();
|
|
103
207
|
for (const dir of dirs) {
|
|
104
208
|
let entries = [];
|
|
@@ -110,6 +214,8 @@ export function discoverSkills() {
|
|
|
110
214
|
catch {
|
|
111
215
|
continue; // 目录不存在或无权限,静默跳过
|
|
112
216
|
}
|
|
217
|
+
// SKILLS_DIRS 自定义目录无法归类 project,统一按 user(免门禁)处理。
|
|
218
|
+
const origin = dir === projDir ? 'project' : 'user';
|
|
113
219
|
for (const name of entries) {
|
|
114
220
|
// 跳过内置 skill 同名目录(项目级用户版可放在 ~/.mocode/skills/<name>/
|
|
115
221
|
// 覆盖内置版;cwd/.mocode/skills/<name>/ 也算项目级覆盖)。discoverSkills 按
|
|
@@ -122,19 +228,32 @@ export function discoverSkills() {
|
|
|
122
228
|
try {
|
|
123
229
|
if (!existsSync(skillMdPath))
|
|
124
230
|
continue;
|
|
231
|
+
seenMdPaths.push(skillMdPath);
|
|
125
232
|
const content = readFileSync(skillMdPath, 'utf8');
|
|
126
|
-
const { meta } = parseFrontmatter(content);
|
|
127
|
-
const skillName = (meta
|
|
128
|
-
const description = (meta
|
|
233
|
+
const { meta, body } = parseFrontmatter(content);
|
|
234
|
+
const skillName = (scalar(meta, 'name') || name).trim();
|
|
235
|
+
const description = (scalar(meta, 'description') || '').trim();
|
|
129
236
|
if (!description)
|
|
130
237
|
continue; // 缺 description 跳过(它是最触发机制)
|
|
238
|
+
const warnings = collectWarnings(meta);
|
|
131
239
|
byName.set(skillName, {
|
|
132
240
|
name: skillName,
|
|
133
241
|
description,
|
|
134
|
-
version: meta
|
|
135
|
-
license: meta
|
|
242
|
+
version: scalar(meta, 'version')?.trim() || undefined,
|
|
243
|
+
license: scalar(meta, 'license')?.trim() || undefined,
|
|
136
244
|
dir: skillDir,
|
|
137
245
|
skillMdPath,
|
|
246
|
+
// body 留空:用户/项目 skill 由 getSkillBody 走文件读取(保留热重载语义)。
|
|
247
|
+
context: resolveContext(meta),
|
|
248
|
+
agentMode: resolveAgentMode(meta, warnings),
|
|
249
|
+
allowedTools: asArray(meta, 'allowed-tools'),
|
|
250
|
+
disallowedTools: asArray(meta, 'disallowed-tools'),
|
|
251
|
+
modelInvocable: !truthy(meta, 'disable-model-invocation'),
|
|
252
|
+
maxSteps: num(meta, 'max-steps'),
|
|
253
|
+
argumentHint: scalar(meta, 'argument-hint')?.trim() || undefined,
|
|
254
|
+
whenToUse: scalar(meta, 'when_to_use')?.trim() || undefined,
|
|
255
|
+
origin,
|
|
256
|
+
warnings,
|
|
138
257
|
});
|
|
139
258
|
}
|
|
140
259
|
catch {
|
|
@@ -142,5 +261,32 @@ export function discoverSkills() {
|
|
|
142
261
|
}
|
|
143
262
|
}
|
|
144
263
|
}
|
|
264
|
+
// 首次全量扫描后,把发现的 SKILL.md 路径交给热重载探测器,后续只 stat 这些已知文件。
|
|
265
|
+
lastKnownMdPaths = seenMdPaths;
|
|
145
266
|
return Array.from(byName.values());
|
|
146
267
|
}
|
|
268
|
+
/** 上一次 discoverSkills 发现的 SKILL.md 路径;skillsScanSignature 复用,避免每次全量重扫。 */
|
|
269
|
+
let lastKnownMdPaths = [];
|
|
270
|
+
/** 供热重载签名计算:各 skills 目录 mtime + 已知 SKILL.md 的 mtime。
|
|
271
|
+
* 目录 mtime 变化(新增/删除 skill 目录)或任一已知文件变动 → 签名变更 → 重扫。
|
|
272
|
+
* 首轮 lastKnownMdPaths 为空时退化为只测目录 mtime(首轮 listSkills 后即填充)。 */
|
|
273
|
+
export function skillsScanSignature() {
|
|
274
|
+
const parts = [];
|
|
275
|
+
for (const dir of resolveSkillsDirs()) {
|
|
276
|
+
try {
|
|
277
|
+
parts.push('D' + dir + statSync(dir).mtimeMs);
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
parts.push('D' + dir + 'x');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
for (const p of lastKnownMdPaths) {
|
|
284
|
+
try {
|
|
285
|
+
parts.push('F' + p + statSync(p).mtimeMs);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
parts.push('F' + p + 'x');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return parts.join('\0');
|
|
292
|
+
}
|
package/dist/skills/index.js
CHANGED
|
@@ -5,13 +5,20 @@
|
|
|
5
5
|
// 优先级:内置 skill < 用户/系统 skill < 项目级 skill(后注册覆盖先注册,
|
|
6
6
|
// 项目级最后扫描,自然在 Map 中胜出)。同名用户 skill 完全替换内置版。
|
|
7
7
|
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
-
import { discoverSkills, parseFrontmatter } from './discover.js';
|
|
8
|
+
import { discoverSkills, parseFrontmatter, skillsScanSignature, } from './discover.js';
|
|
9
9
|
import { builtinSkills } from './builtin-skills.js';
|
|
10
10
|
let cache = null;
|
|
11
|
+
let cacheKey = null;
|
|
12
|
+
/** 当前扫描签名(各 skills 目录 + 各 SKILL.md 的 mtime 拼接);用于热重载失效判断。 */
|
|
13
|
+
function currentKey() {
|
|
14
|
+
return skillsScanSignature();
|
|
15
|
+
}
|
|
11
16
|
/** 已发现的 skill 列表(懒加载,首次调用触发扫描;启动期 repl 调一次)。
|
|
12
|
-
* 合并策略:内置 skill 作兜底,discoverSkills 的输出(按目录优先级后写覆盖)优先生效。
|
|
17
|
+
* 合并策略:内置 skill 作兜底,discoverSkills 的输出(按目录优先级后写覆盖)优先生效。
|
|
18
|
+
* 热重载:签名变更(任一 SKILL.md 或目录被增删/改动)即重扫,无需重启。 */
|
|
13
19
|
export function listSkills() {
|
|
14
|
-
|
|
20
|
+
const key = currentKey();
|
|
21
|
+
if (cache !== null && cacheKey === key)
|
|
15
22
|
return cache;
|
|
16
23
|
const discovered = discoverSkills();
|
|
17
24
|
const byName = new Map();
|
|
@@ -22,15 +29,25 @@ export function listSkills() {
|
|
|
22
29
|
for (const skill of discovered)
|
|
23
30
|
byName.set(skill.name, skill);
|
|
24
31
|
cache = Array.from(byName.values());
|
|
32
|
+
cacheKey = key;
|
|
25
33
|
return cache;
|
|
26
34
|
}
|
|
35
|
+
/** 强制下次 listSkills 重扫(外部触发热重载用)。 */
|
|
36
|
+
export function invalidateSkillsCache() {
|
|
37
|
+
cache = null;
|
|
38
|
+
cacheKey = null;
|
|
39
|
+
}
|
|
40
|
+
/** 按名字取单个 skill(区分大小写;找不到返 null)。 */
|
|
41
|
+
export function findSkill(name) {
|
|
42
|
+
return listSkills().find((s) => s.name === name) ?? null;
|
|
43
|
+
}
|
|
27
44
|
/**
|
|
28
45
|
* 读取某 skill 的 SKILL.md 正文(去掉 frontmatter)。
|
|
29
46
|
* 纯函数:找不到 / 读失败返 null(错误字符串交给调用方工具层生成)。
|
|
30
47
|
* 内置 skill 直接返回 body 字段(无 fs 依赖)。
|
|
31
48
|
*/
|
|
32
49
|
export function getSkillBody(name) {
|
|
33
|
-
const skill =
|
|
50
|
+
const skill = findSkill(name);
|
|
34
51
|
if (!skill)
|
|
35
52
|
return null;
|
|
36
53
|
if (skill.body) {
|
|
@@ -49,12 +66,20 @@ export function getSkillBody(name) {
|
|
|
49
66
|
return null;
|
|
50
67
|
}
|
|
51
68
|
}
|
|
52
|
-
/** 拼进系统提示的 skill 段;无 skill 返空串(零行为变化)。
|
|
69
|
+
/** 拼进系统提示的 skill 段;无 skill 返空串(零行为变化)。
|
|
70
|
+
* - disable-model-invocation: true 的 skill 不进列表(模型看不见,仅 /skill 可触发)。
|
|
71
|
+
* - context: fork 的 skill 追加 [fork] 徽标 + 一句调用指引,提示模型它可被「执行」而非「阅读」。 */
|
|
53
72
|
export function buildSkillsSection() {
|
|
54
|
-
const skills = listSkills();
|
|
73
|
+
const skills = listSkills().filter((s) => s.modelInvocable);
|
|
55
74
|
if (skills.length === 0)
|
|
56
75
|
return '';
|
|
57
|
-
const lines = skills.map((s) =>
|
|
76
|
+
const lines = skills.map((s) => {
|
|
77
|
+
const badge = s.context === 'fork' ? ' [fork]' : '';
|
|
78
|
+
const hint = s.context === 'fork'
|
|
79
|
+
? ' (call run_skill to execute it as an isolated workflow)'
|
|
80
|
+
: ' (call use_skill to load its instructions)';
|
|
81
|
+
return `- ${s.name}${badge}: ${s.description}${hint}`;
|
|
82
|
+
});
|
|
58
83
|
return [
|
|
59
84
|
'',
|
|
60
85
|
'',
|
package/dist/skills/runner.js
CHANGED
|
@@ -1,43 +1,201 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// 的 prompt(协议/操作规范),子 agent 用受控工具子集在隔离上下文里执行,结果摘要回灌。
|
|
1
|
+
// skill 执行内核(L2-①/②/③):占位符渲染 + 动态命令注入 + fork 子 agent 执行。
|
|
2
|
+
// 被 use_skill(inline 渲染 / fork 引导)与 run_skill(fork 执行)共用。
|
|
4
3
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// 设计要点(对齐 docs/skill-system-design.md §3.3–3.5):
|
|
5
|
+
// - 不为 scripts/ 做任何新机制:作者用 ${SKILL_DIR} 拼出绝对路径,模型自行 run_command。
|
|
6
|
+
// - 不为参数做 shell 插值:参数渲染进 prompt 文本,落到命令行时是模型写 run_command,
|
|
7
|
+
// 走既有 denylist + 权限确认。转义器本身就是注入面,不写它比写对它更安全。
|
|
8
|
+
// - !`cmd` 注入仅在非 project 或已信任 skill 上允许;单 skill 最多 4 处,单处输出截 4KB。
|
|
9
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
10
|
+
import { resolve, sep } from 'node:path';
|
|
11
|
+
import { getSkillBody, findSkill } from './index.js';
|
|
12
|
+
import { isSkillTrusted, ensureSkillTrust } from './trust.js';
|
|
13
|
+
import { mapSkillTools } from './toolmap.js';
|
|
7
14
|
import { spawnAgent } from '../agent/spawn.js';
|
|
8
|
-
|
|
9
|
-
|
|
15
|
+
import { runCommandRaw } from '../tools/builtins/run-command.js';
|
|
16
|
+
/** 把 raw ChangeSet 折成 ToolOutcome 需要的 ChangeSetSummary(哈希缺失位填 null,仅用于展示)。 */
|
|
17
|
+
function toChangeSetSummary(cs) {
|
|
18
|
+
return {
|
|
19
|
+
id: cs.id,
|
|
20
|
+
changedFiles: cs.changes.map((c) => c.path),
|
|
21
|
+
changes: cs.changes.map((c) => ({
|
|
22
|
+
path: c.path,
|
|
23
|
+
operation: c.operation,
|
|
24
|
+
beforeHash: null,
|
|
25
|
+
afterHash: null,
|
|
26
|
+
})),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const MAX_INJECTIONS = 4;
|
|
30
|
+
const MAX_INJECTION_OUTPUT = 4096;
|
|
31
|
+
const INJECT_TIMEOUT = 10_000;
|
|
32
|
+
/** fork 子 agent 收到的协议头:明确它是「执行某个 skill」,并要求最终给一句话摘要。 */
|
|
33
|
+
const SKILL_PROTOCOL_HEADER = `You are executing a packaged skill workflow. Follow the instructions below literally and completely. When done, end your reply with a concise summary of what you did, the files changed, and any issues. Do not ask the user questions unless blocked.`;
|
|
34
|
+
/** 把 $ARGUMENTS / $1..$9 / ${SKILL_DIR} 渲染进正文。 */
|
|
35
|
+
function substituteArgs(body, args, skillDir) {
|
|
36
|
+
const values = args && typeof args === 'object' ? Object.values(args) : [];
|
|
37
|
+
return body.replace(/\$(?:ARGUMENTS|(\d)|\{SKILL_DIR\})/g, (_m, digit) => {
|
|
38
|
+
if (digit != null) {
|
|
39
|
+
const idx = Number(digit) - 1;
|
|
40
|
+
const v = values[idx];
|
|
41
|
+
return v === undefined ? '' : typeof v === 'string' ? v : JSON.stringify(v);
|
|
42
|
+
}
|
|
43
|
+
return skillDir;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
/** 执行 !`cmd` 注入:逐个跑命令,用 fenced 输出替换;失败降级为提示而非中断。 */
|
|
47
|
+
async function injectCommands(body, skillDir, signal) {
|
|
48
|
+
const re = /!`([^`]+)`/g;
|
|
49
|
+
let count = 0;
|
|
50
|
+
const out = [];
|
|
51
|
+
let m;
|
|
52
|
+
while ((m = re.exec(body)) !== null && count < MAX_INJECTIONS) {
|
|
53
|
+
count++;
|
|
54
|
+
const cmd = m[1];
|
|
55
|
+
let replacement;
|
|
56
|
+
try {
|
|
57
|
+
const res = await runCommandRaw(cmd, INJECT_TIMEOUT, signal, skillDir);
|
|
58
|
+
if (res.status === 'denied') {
|
|
59
|
+
replacement = `(command denied: ${res.output})`;
|
|
60
|
+
}
|
|
61
|
+
else if (res.status === 'timed_out') {
|
|
62
|
+
replacement = '(command timed out)';
|
|
63
|
+
}
|
|
64
|
+
else if (res.status === 'aborted') {
|
|
65
|
+
replacement = '(command aborted)';
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
const text = res.output.length > MAX_INJECTION_OUTPUT
|
|
69
|
+
? res.output.slice(0, MAX_INJECTION_OUTPUT) + '\n…(truncated)'
|
|
70
|
+
: res.output;
|
|
71
|
+
replacement = text.trim() || '(no output)';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (e) {
|
|
75
|
+
replacement = `(command failed: ${e instanceof Error ? e.message : String(e)})`;
|
|
76
|
+
}
|
|
77
|
+
out.push({ full: m[0], replacement: '```\n' + replacement + '\n```' });
|
|
78
|
+
}
|
|
79
|
+
let result = body;
|
|
80
|
+
for (const { full, replacement } of out) {
|
|
81
|
+
result = result.replace(full, replacement);
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* 渲染 skill 正文(占位符 + 可选命令注入)。
|
|
87
|
+
* 非 project 或已信任的 project skill 才执行注入;否则先尝试一次性确认,未通过则跳过注入。
|
|
88
|
+
* 返回 null 表示正文缺失(调用方生成「未找到」错误)。
|
|
89
|
+
*/
|
|
90
|
+
export async function renderSkillBody(skill, args, signal) {
|
|
91
|
+
const raw = getSkillBody(skill.name);
|
|
92
|
+
if (raw === null)
|
|
93
|
+
return null;
|
|
94
|
+
let body = substituteArgs(raw, args, skill.dir);
|
|
95
|
+
if (/!`[^`]+`/.test(body)) {
|
|
96
|
+
// 非 project 恒信任;project 依次查信任记录、再弹一次性确认。
|
|
97
|
+
// ensureSkillTrust 的 true 覆盖 'trusted'(已记录)与 'once'(仅本次)两种,直接据此注入。
|
|
98
|
+
const trusted = skill.origin !== 'project' || isSkillTrusted(skill) || (await ensureSkillTrust(skill));
|
|
99
|
+
if (trusted) {
|
|
100
|
+
body = await injectCommands(body, skill.dir, signal);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
// 未信任:清掉注入标记,避免把未授权命令留在提示里。
|
|
104
|
+
body = body.replace(/!`[^`]+`/g, '_(command injection skipped: skill not trusted)_');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return body;
|
|
108
|
+
}
|
|
109
|
+
/** 把 SpawnResult 转成 ToolOutcome,汇总/计费/变更集透传,不丢回滚信息。 */
|
|
110
|
+
function toOutcome(res) {
|
|
111
|
+
const status = res.status === 'completed' ? 'success' : res.status === 'aborted' ? 'aborted' : 'error';
|
|
112
|
+
return {
|
|
113
|
+
status,
|
|
114
|
+
code: res.status === 'completed' ? 'OK'
|
|
115
|
+
: res.status === 'aborted' ? 'ABORTED'
|
|
116
|
+
: 'EXECUTION_ERROR',
|
|
117
|
+
retryable: false,
|
|
118
|
+
output: res.summary ?? (res.status === 'failed' ? '(skill failed with no output)' : ''),
|
|
119
|
+
changeSet: res.changeSet ? toChangeSetSummary(res.changeSet) : undefined,
|
|
120
|
+
usage: res.usage,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** 读取 skill 目录内附属文件(L2 渐进式披露);越界 / 不存在 / 过大返 null。 */
|
|
124
|
+
export function readSkillFile(skill, file, maxBytes) {
|
|
125
|
+
// 归一为绝对路径,并强制留在 skill.dir 内(禁止 ../ 逃逸)。
|
|
126
|
+
const base = resolve(skill.dir);
|
|
127
|
+
const abs = resolve(base, file);
|
|
128
|
+
if (abs !== base && !abs.startsWith(base + sep))
|
|
129
|
+
return null;
|
|
130
|
+
if (!existsSync(abs))
|
|
131
|
+
return null;
|
|
132
|
+
try {
|
|
133
|
+
if (statSync(abs).size > maxBytes)
|
|
134
|
+
return `(file too large: ${file})`;
|
|
135
|
+
return readFileSync(abs, 'utf8');
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** 能产生副作用的 mocode 工具;用于在未显式声明 agent: 时推断 fork 子 agent 的模式。 */
|
|
142
|
+
const WRITE_TOOLS = new Set(['write_file', 'edit_file', 'run_command']);
|
|
10
143
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
144
|
+
* fork 子 agent 模式:`agent:` 显式声明优先;否则按工具面推断——
|
|
145
|
+
* 未声明 allowed-tools(完整工具集)或白名单含写工具 → 'write',纯只读白名单 → 'read'。
|
|
146
|
+
* 避免写类 skill 因缺省字段被静默降级为只读。
|
|
147
|
+
* 导出仅供 scripts/check-skills.ts 离线断言。
|
|
13
148
|
*/
|
|
14
|
-
export function
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const positional = Array.isArray(arg)
|
|
21
|
-
? arg.map((v) => String(v))
|
|
22
|
-
: (Object.values(arg).map((v) => String(v)));
|
|
23
|
-
const at = (i) => positional[i] ?? '';
|
|
24
|
-
return body
|
|
25
|
-
.replace(/\$ARGUMENTS\b/gi, named)
|
|
26
|
-
.replace(/\$\{?(\d+)\}?/g, (_m, idx) => at(Number(idx) - 1))
|
|
27
|
-
.replace(/\$\{ARGUMENTS\}/gi, named);
|
|
149
|
+
export function resolveSpawnMode(skill, tools) {
|
|
150
|
+
if (skill.agentMode)
|
|
151
|
+
return skill.agentMode;
|
|
152
|
+
if (tools === null || tools.some((t) => WRITE_TOOLS.has(t)))
|
|
153
|
+
return 'write';
|
|
154
|
+
return 'read';
|
|
28
155
|
}
|
|
29
156
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
157
|
+
* 执行一个 skill(无论 inline 还是 fork 都走隔离子 agent,保证「可执行」语义统一):
|
|
158
|
+
* - 找不到 → UNKNOWN_TOOL 语义 error
|
|
159
|
+
* - 执行面门禁(ensureSkillTrust)未过 → denied
|
|
160
|
+
* - 渲染正文 → spawnAgent(白名单工具 / mode / maxSteps / signal)
|
|
161
|
+
* - 子 agent 摘要回灌为 output,usage / changeSet 透传
|
|
32
162
|
*/
|
|
33
|
-
export async function
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
163
|
+
export async function runSkill(a, ctx) {
|
|
164
|
+
const name = String(a.name ?? '').trim();
|
|
165
|
+
if (!name) {
|
|
166
|
+
return { status: 'error', code: 'INVALID_ARGUMENTS', retryable: false, output: '错误:缺少 skill 名。' };
|
|
167
|
+
}
|
|
168
|
+
const skill = findSkill(name);
|
|
169
|
+
if (!skill) {
|
|
170
|
+
return { status: 'error', code: 'UNKNOWN_TOOL', retryable: false, output: `错误:未找到 skill "${name}"。` };
|
|
171
|
+
}
|
|
172
|
+
if (!(await ensureSkillTrust(skill))) {
|
|
173
|
+
return {
|
|
174
|
+
status: 'denied',
|
|
175
|
+
code: 'PERMISSION_DENIED',
|
|
176
|
+
retryable: false,
|
|
177
|
+
output: `拒绝:skill "${name}" 未获信任,已取消执行。`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
let body = await renderSkillBody(skill, a.args, ctx?.signal);
|
|
181
|
+
if (body === null) {
|
|
182
|
+
return { status: 'error', code: 'UNKNOWN_TOOL', retryable: false, output: `错误:未找到 skill "${name}" 的正文。` };
|
|
183
|
+
}
|
|
184
|
+
const { tools, unknown } = mapSkillTools(skill.allowedTools);
|
|
185
|
+
if (unknown.length) {
|
|
186
|
+
// 仅记到正文前导,模型可感知哪些 allowed-tools 被忽略(不阻断执行)。
|
|
187
|
+
body = `> 注意:以下 allowed-tools 无法映射到 mocode 工具,已忽略: ${unknown.join(', ')}\n\n` + body;
|
|
188
|
+
}
|
|
189
|
+
const res = await spawnAgent({
|
|
190
|
+
prompt: SKILL_PROTOCOL_HEADER + '\n\n' + body,
|
|
191
|
+
tools: tools ?? undefined,
|
|
192
|
+
mode: resolveSpawnMode(skill, tools),
|
|
193
|
+
maxSteps: skill.maxSteps,
|
|
40
194
|
signal: ctx?.signal,
|
|
41
|
-
context:
|
|
195
|
+
context: a.context,
|
|
196
|
+
systemPromptSuffix: `You are executing the "${skill.name}" skill. SKILL_DIR=${skill.dir}`,
|
|
197
|
+
quiet: true, // fork skill 是 opaque workflow,不产可展开 batch
|
|
198
|
+
quietLabel: `执行 ${skill.name}…`,
|
|
42
199
|
});
|
|
200
|
+
return toOutcome(res);
|
|
43
201
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// skill 工具名映射(叶子模块,零依赖,避免激活态/runner 与 tools/constants 之间的循环依赖)。
|
|
2
|
+
// 把 Agent Skills 开放标准里的工具 token 归一为 mocode 工具名。
|
|
3
|
+
//
|
|
4
|
+
// 关键约束:只做「名字翻译」,绝不把 `Bash(git:*)` 的括号内容当作命令白名单——
|
|
5
|
+
// mocode 的权限是 run_command 粒度(permissions/index.ts 的 permissionFingerprint),
|
|
6
|
+
// 不解析 shell 前缀。`Bash(git:*)` 在 mocode 里等同于「允许子 agent 用 run_command」,
|
|
7
|
+
// 具体命令仍走 denylist + 沙箱。
|
|
8
|
+
/** 标准 token(大小写不敏感)→ mocode 工具名。带括号的 `Bash(...)` 也按 Bash 前缀归并。
|
|
9
|
+
* 同时接受 mocode 原生工具名(write_file / web_fetch…),即标准名与原生名两种写法。 */
|
|
10
|
+
const TOKEN_MAP = [
|
|
11
|
+
{ re: /^read(_file)?$/i, tool: 'read_file' },
|
|
12
|
+
{ re: /^grep$/i, tool: 'grep' },
|
|
13
|
+
{ re: /^glob$/i, tool: 'glob' },
|
|
14
|
+
{ re: /^bash$/i, tool: 'run_command' }, // Bash(...) 也落到 run_command
|
|
15
|
+
{ re: /^run_command$/i, tool: 'run_command' },
|
|
16
|
+
{ re: /^write(_file)?$/i, tool: 'write_file' },
|
|
17
|
+
{ re: /^edit(_file)?$/i, tool: 'edit_file' },
|
|
18
|
+
{ re: /^web_?search$/i, tool: 'web_search' },
|
|
19
|
+
{ re: /^web_?fetch$/i, tool: 'web_fetch' },
|
|
20
|
+
{ re: /^use_skill$/i, tool: 'use_skill' },
|
|
21
|
+
{ re: /^run_skill$/i, tool: 'run_skill' },
|
|
22
|
+
{ re: /^memory_search$/i, tool: 'memory_search' },
|
|
23
|
+
{ re: /^memory_list$/i, tool: 'memory_list' },
|
|
24
|
+
];
|
|
25
|
+
/** 把单个标准 token 映射成 mocode 工具名;未知 token 返 null(调用方记 warning + 忽略)。 */
|
|
26
|
+
export function mapSkillToolName(token) {
|
|
27
|
+
const t = token.trim();
|
|
28
|
+
if (!t)
|
|
29
|
+
return null;
|
|
30
|
+
// 去掉可能的括号后缀(Bash(git:*) → Bash)
|
|
31
|
+
const base = t.replace(/\(.*\)$/, '').trim();
|
|
32
|
+
for (const { re, tool } of TOKEN_MAP) {
|
|
33
|
+
if (re.test(base))
|
|
34
|
+
return tool;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
/** 把 allowed-tools / disallowed-tools 的 token 列表映射为 mocode 工具名集合;
|
|
39
|
+
* 未知 token 收集到 unknown 返回,供调用方记 warning。返回 null 表示未声明(不约束)。 */
|
|
40
|
+
export function mapSkillTools(tokens) {
|
|
41
|
+
if (!tokens || tokens.length === 0)
|
|
42
|
+
return { tools: null, unknown: [] };
|
|
43
|
+
const tools = [];
|
|
44
|
+
const unknown = [];
|
|
45
|
+
for (const tk of tokens) {
|
|
46
|
+
const mapped = mapSkillToolName(tk);
|
|
47
|
+
if (mapped) {
|
|
48
|
+
if (!tools.includes(mapped))
|
|
49
|
+
tools.push(mapped);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
unknown.push(tk);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { tools: tools.length ? tools : null, unknown };
|
|
56
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// skill 信任门禁(执行面的安全前置)。
|
|
2
|
+
// 来源策略:
|
|
3
|
+
// - builtin:恒信任(随 mocode 发布)。
|
|
4
|
+
// - user(~/.claude|.mocode/skills):免门禁(用户自己放的)。
|
|
5
|
+
// - project(<cwd>/.mocode/skills):很可能来自 git clone,首次使用执行面时一次性确认;
|
|
6
|
+
// 记录 sha256(SKILL.md + scripts/** + references/**),内容变更 → 失效重问。
|
|
7
|
+
//
|
|
8
|
+
// 非 TTY(管道 / CI / host 嵌入)严格失败关闭:未信任的 project skill 执行面一律拒绝,
|
|
9
|
+
// 对齐 Snyk 关于注册表 skill 携带恶意载荷的数据(设计文档 §1.4)。
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { dirname, join } from 'node:path';
|
|
13
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { promptIntervention } from '../ui/intervention.js';
|
|
15
|
+
import * as layout from '../ui/layout.js';
|
|
16
|
+
const TRUST_PATH = join(homedir(), '.mocode', 'skill-trust.json');
|
|
17
|
+
let trustCache = null;
|
|
18
|
+
function loadTrust() {
|
|
19
|
+
if (trustCache)
|
|
20
|
+
return trustCache;
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(readFileSync(TRUST_PATH, 'utf8'));
|
|
23
|
+
trustCache = parsed && typeof parsed === 'object' ? parsed : {};
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
trustCache = {};
|
|
27
|
+
}
|
|
28
|
+
return trustCache;
|
|
29
|
+
}
|
|
30
|
+
function saveTrust(rec) {
|
|
31
|
+
trustCache = rec;
|
|
32
|
+
try {
|
|
33
|
+
mkdirSync(dirname(TRUST_PATH), { recursive: true });
|
|
34
|
+
writeFileSync(TRUST_PATH, JSON.stringify(rec, null, 2));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// 写入失败(权限 / 只读 home)不阻断,仅本次会话内存态生效。
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** 递归收集目录下所有文件(绝对路径)。 */
|
|
41
|
+
function collectFiles(dir, out) {
|
|
42
|
+
let ents;
|
|
43
|
+
try {
|
|
44
|
+
ents = readdirSync(dir, { withFileTypes: true });
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
for (const e of ents) {
|
|
50
|
+
const p = join(dir, e.name);
|
|
51
|
+
if (e.isDirectory())
|
|
52
|
+
collectFiles(p, out);
|
|
53
|
+
else
|
|
54
|
+
out.push(p);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** 计算 skill 内容哈希(SKILL.md + scripts/** + references/**)。 */
|
|
58
|
+
export function computeSkillHash(skill) {
|
|
59
|
+
const h = createHash('sha256');
|
|
60
|
+
const files = [skill.skillMdPath];
|
|
61
|
+
for (const sub of ['scripts', 'references']) {
|
|
62
|
+
const d = join(skill.dir, sub);
|
|
63
|
+
if (existsSync(d))
|
|
64
|
+
collectFiles(d, files);
|
|
65
|
+
}
|
|
66
|
+
files.sort();
|
|
67
|
+
for (const f of files) {
|
|
68
|
+
try {
|
|
69
|
+
h.update(f + '\0');
|
|
70
|
+
h.update(readFileSync(f));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// 单文件读失败:跳过该项(哈希覆盖其余内容,足够检测变更)。
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return h.digest('hex');
|
|
77
|
+
}
|
|
78
|
+
/** 该 skill 当前是否已受信任(内容未变)。非 project 来源恒 true。 */
|
|
79
|
+
export function isSkillTrusted(skill) {
|
|
80
|
+
if (skill.origin !== 'project')
|
|
81
|
+
return true;
|
|
82
|
+
const rec = loadTrust()[skill.name];
|
|
83
|
+
if (!rec)
|
|
84
|
+
return false;
|
|
85
|
+
return rec.hash === computeSkillHash(skill);
|
|
86
|
+
}
|
|
87
|
+
function recordTrust(skill) {
|
|
88
|
+
const rec = loadTrust();
|
|
89
|
+
rec[skill.name] = { hash: computeSkillHash(skill), trustedAt: Date.now() };
|
|
90
|
+
saveTrust(rec);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* 面向用户的信任确认。非 TTY 直接拒绝(失败关闭)。
|
|
94
|
+
* 返回 'trusted'(记录哈希) / 'once'(仅本次) / 'deny'。
|
|
95
|
+
*/
|
|
96
|
+
export async function promptTrust(skill) {
|
|
97
|
+
if (!layout.isActive())
|
|
98
|
+
return 'deny';
|
|
99
|
+
const res = await promptIntervention({
|
|
100
|
+
type: 'choice',
|
|
101
|
+
title: `信任并运行 skill "${skill.name}"?`,
|
|
102
|
+
detail: `${skill.dir}\n` +
|
|
103
|
+
`该 skill 配置了执行面(fork / scripts / allowed-tools)。首次执行需确认;` +
|
|
104
|
+
`其 SKILL.md / scripts / references 内容变更后将重新询问。`,
|
|
105
|
+
options: [
|
|
106
|
+
{ label: '信任并运行', detail: '记录内容哈希,今后自动信任' },
|
|
107
|
+
{ label: '仅本次运行', detail: '本会话执行一次,不记录' },
|
|
108
|
+
{ label: '拒绝', detail: '不执行' },
|
|
109
|
+
],
|
|
110
|
+
allowCustom: false,
|
|
111
|
+
});
|
|
112
|
+
if (res.action === 'cancelled')
|
|
113
|
+
return 'deny';
|
|
114
|
+
const v = res.value ?? '拒绝';
|
|
115
|
+
if (v.startsWith('信任')) {
|
|
116
|
+
recordTrust(skill);
|
|
117
|
+
return 'trusted';
|
|
118
|
+
}
|
|
119
|
+
if (v.startsWith('仅本次'))
|
|
120
|
+
return 'once';
|
|
121
|
+
return 'deny';
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* 执行面前的信任检查:已信任返回 true;未信任则弹确认。
|
|
125
|
+
* 非 project / 已信任 → true;未信任 project 在非 TTY → false;用户拒绝 → false。
|
|
126
|
+
*/
|
|
127
|
+
export async function ensureSkillTrust(skill) {
|
|
128
|
+
if (skill.origin !== 'project')
|
|
129
|
+
return true;
|
|
130
|
+
if (isSkillTrusted(skill))
|
|
131
|
+
return true;
|
|
132
|
+
const decision = await promptTrust(skill);
|
|
133
|
+
return decision !== 'deny';
|
|
134
|
+
}
|
|
@@ -11,6 +11,7 @@ import { grepTool } from './grep.js';
|
|
|
11
11
|
import { webSearchTool } from './web-search.js';
|
|
12
12
|
import { webFetchTool } from './web-fetch.js';
|
|
13
13
|
import { useSkillTool } from './use-skill.js';
|
|
14
|
+
import { runSkillTool } from './run-skill.js';
|
|
14
15
|
import { askHumanTool } from './ask-human.js';
|
|
15
16
|
import { planUpdateTool } from './plan-update.js';
|
|
16
17
|
import { memorySaveTool } from './memory-save.js';
|
|
@@ -60,6 +61,7 @@ const CAPABILITIES = {
|
|
|
60
61
|
web_search: { effect: 'network', concurrency: 'parallel', supportsAbort: true },
|
|
61
62
|
web_fetch: { effect: 'network', concurrency: 'parallel', supportsAbort: true },
|
|
62
63
|
use_skill: { effect: 'read', concurrency: 'serial' },
|
|
64
|
+
run_skill: { effect: 'process', concurrency: 'serial', delegatesResourceLocks: true, supportsAbort: true },
|
|
63
65
|
ask_human: { effect: 'read', concurrency: 'serial' },
|
|
64
66
|
// plan_update 只写内部 notes.md(session 工作面),不作为用户代码 mutation 追踪/回滚/diff;
|
|
65
67
|
// 串行即可(调用不频繁),固定资源键让并发调用排队。
|
|
@@ -94,6 +96,7 @@ const rawBuiltinTools = [
|
|
|
94
96
|
webSearchTool,
|
|
95
97
|
webFetchTool,
|
|
96
98
|
useSkillTool,
|
|
99
|
+
runSkillTool,
|
|
97
100
|
askHumanTool,
|
|
98
101
|
planUpdateTool,
|
|
99
102
|
..._memoryTools,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { runSkill } from '../../skills/runner.js';
|
|
2
|
+
// ---------- run_skill ----------
|
|
3
|
+
// 唯一新增的常驻工具(L2-①):把某个 skill 作为隔离工作流(fork 子 agent)执行并返回摘要。
|
|
4
|
+
// 无论装 100 个还是 1000 个 skill,常驻工具表只多这 1 个。上下文 / 工具面 / 副作用 / 中断
|
|
5
|
+
// 全部由 spawnAgent 现成能力承接(设计 §3.4)。
|
|
6
|
+
export const runSkillTool = {
|
|
7
|
+
name: 'run_skill',
|
|
8
|
+
description: 'Execute a skill as an isolated workflow (forked sub-agent) and return its summary. ' +
|
|
9
|
+
'Use for skills marked [fork] in the skill list. Args are rendered into the skill body.',
|
|
10
|
+
parameters: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
name: {
|
|
14
|
+
type: 'string',
|
|
15
|
+
description: 'Name of the skill to execute (see the skill list in the system prompt).',
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
type: 'object',
|
|
19
|
+
description: 'Arguments rendered into the skill body ($ARGUMENTS, $1..$9). Optional.',
|
|
20
|
+
},
|
|
21
|
+
context: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'Optional extra context/facts to inject into the sub-agent (authoritative; not rediscovered).',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
required: ['name'],
|
|
27
|
+
},
|
|
28
|
+
risk: 'confirm',
|
|
29
|
+
capabilities: {
|
|
30
|
+
effect: 'process',
|
|
31
|
+
concurrency: 'serial',
|
|
32
|
+
delegatesResourceLocks: true, // 与 sub-agent 一致:锁由内层工具取,避免父子自锁
|
|
33
|
+
supportsAbort: true,
|
|
34
|
+
},
|
|
35
|
+
async execute(args, ctx) {
|
|
36
|
+
return runSkill({
|
|
37
|
+
name: String(args.name ?? ''),
|
|
38
|
+
args: args.args,
|
|
39
|
+
context: typeof args.context === 'string' ? args.context : undefined,
|
|
40
|
+
}, ctx);
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -1,10 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { findSkill } from '../../skills/index.js';
|
|
2
|
+
import { renderSkillBody, readSkillFile } from '../../skills/runner.js';
|
|
3
|
+
import { activateSkill } from '../../skills/activation.js';
|
|
2
4
|
// ---------- use_skill ----------
|
|
3
5
|
// 模型按需加载某 skill 的 SKILL.md 正文(渐进式披露第②层)。
|
|
4
6
|
// 系统提示里已列出可用 skill 的 name + description(何时用),模型据此决定调用。
|
|
7
|
+
//
|
|
8
|
+
// 设计 §3.3 升级:
|
|
9
|
+
// - args:渲染 $ARGUMENTS / $1..$9 / ${SKILL_DIR}
|
|
10
|
+
// - file:读 skill 目录内附属文件(L2 披露,jail 约束)
|
|
11
|
+
// - context: fork 的 skill 不返回正文,改为引导调 run_skill(隔离白做才是真隔离)
|
|
12
|
+
// - inline skill 成功加载后激活会话级工具面约束(allowed/disallowed-tools)
|
|
13
|
+
const MAX_SKILL_FILE = 200_000;
|
|
5
14
|
export const useSkillTool = {
|
|
6
15
|
name: 'use_skill',
|
|
7
|
-
description: 'Load the full SKILL.md instructions for a given skill. See the skill list in the system prompt for when to use each.'
|
|
16
|
+
description: 'Load the full SKILL.md instructions for a given skill. See the skill list in the system prompt for when to use each. ' +
|
|
17
|
+
'Supports args (renders $ARGUMENTS / $1.. / ${SKILL_DIR}) and file (reads a bundled reference file). ' +
|
|
18
|
+
'For skills marked [fork], this returns a guide to call run_skill instead of loading the body inline.',
|
|
8
19
|
parameters: {
|
|
9
20
|
type: 'object',
|
|
10
21
|
properties: {
|
|
@@ -12,16 +23,43 @@ export const useSkillTool = {
|
|
|
12
23
|
type: 'string',
|
|
13
24
|
description: 'Name of the skill to load (see the skill list in the system prompt, or the /skills command)',
|
|
14
25
|
},
|
|
26
|
+
args: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
description: 'Arguments rendered into the skill body ($ARGUMENTS, $1..$9). Optional.',
|
|
29
|
+
},
|
|
30
|
+
file: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
description: 'Optional bundled file inside the skill directory to read (e.g. references/api.md). Subject to jail bounds.',
|
|
33
|
+
},
|
|
15
34
|
},
|
|
16
35
|
required: ['name'],
|
|
17
36
|
},
|
|
18
|
-
async execute(args) {
|
|
37
|
+
async execute(args, ctx) {
|
|
19
38
|
const name = String(args.name ?? '').trim();
|
|
20
39
|
if (!name)
|
|
21
40
|
return '错误:缺少 skill 名。用 /skills 查看可用 skill 列表。';
|
|
22
|
-
const
|
|
23
|
-
if (
|
|
41
|
+
const skill = findSkill(name);
|
|
42
|
+
if (!skill)
|
|
24
43
|
return `错误:未找到 skill "${name}"。用 /skills 查看可用 skill 列表。`;
|
|
44
|
+
// fork skill:不把正文读进主上下文,引导走 run_skill。
|
|
45
|
+
if (skill.context === 'fork') {
|
|
46
|
+
return (`# Skill: ${name}\n\n` +
|
|
47
|
+
`该 skill 以隔离工作流(fork)形式执行。请勿在此加载其正文——调用 ` +
|
|
48
|
+
`\`run_skill({ name: "${name}"${Object.keys(args.args ?? {}).length ? ', args: {...}' : ''} })\` ` +
|
|
49
|
+
`即可在隔离子 agent 中执行并返回摘要。`);
|
|
50
|
+
}
|
|
51
|
+
// file 优先:L2 渐进式披露
|
|
52
|
+
if (typeof args.file === 'string' && args.file.trim()) {
|
|
53
|
+
const content = readSkillFile(skill, args.file.trim(), MAX_SKILL_FILE);
|
|
54
|
+
if (content === null)
|
|
55
|
+
return `错误:无法读取 skill "${name}" 的文件 "${args.file}"(不存在 / 越界 / 过大)。`;
|
|
56
|
+
return `# Skill: ${name} · ${args.file}\n\n${content}`;
|
|
57
|
+
}
|
|
58
|
+
const body = await renderSkillBody(skill, args.args, ctx?.signal);
|
|
59
|
+
if (body === null)
|
|
60
|
+
return `错误:未找到 skill "${name}" 的正文。用 /skills 查看可用 skill 列表。`;
|
|
61
|
+
// 激活 inline skill 的工具面约束(allowed/disallowed),本轮内生效。
|
|
62
|
+
activateSkill(skill);
|
|
25
63
|
return `# Skill: ${name}\n\n${body}`;
|
|
26
64
|
},
|
|
27
65
|
};
|
package/dist/tools/constants.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** 工具共享的截断 / 上限 / 忽略规则。 */
|
|
2
2
|
import { isMemoryEnabled, isSubAgentEnabled, isFrontendToolsEnabled } from '../config/index.js';
|
|
3
|
+
import { getActiveSkill } from '../skills/activation.js';
|
|
3
4
|
export const MAX_FILE_LINES = 2000;
|
|
4
5
|
export const MAX_OUTPUT = 20000;
|
|
5
6
|
export const MAX_RESULTS = 100;
|
|
@@ -56,6 +57,7 @@ export const PLAN_DISABLED_TOOLS = new Set([
|
|
|
56
57
|
'memory_update',
|
|
57
58
|
'memory_forget',
|
|
58
59
|
'sub-agent',
|
|
60
|
+
'run_skill', // fork 子 agent 执行面;plan 模式不应派生子工作流
|
|
59
61
|
]);
|
|
60
62
|
/**
|
|
61
63
|
* 按当前 isMemoryEnabled() 现算 plan 模式应屏蔽的工具。
|
|
@@ -89,5 +91,11 @@ export function getRuntimeDisabledTools() {
|
|
|
89
91
|
for (const name of FRONTEND_TOOLS)
|
|
90
92
|
disabled.add(name);
|
|
91
93
|
}
|
|
94
|
+
// inline skill 激活态的 disallowed-tools:即便模型幻觉调用也执行不了(设计 §3.6)。
|
|
95
|
+
const active = getActiveSkill();
|
|
96
|
+
if (active?.disallowed) {
|
|
97
|
+
for (const name of active.disallowed)
|
|
98
|
+
disabled.add(name);
|
|
99
|
+
}
|
|
92
100
|
return disabled;
|
|
93
101
|
}
|