mocode-ai 0.1.0
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/LICENSE +21 -0
- package/README.md +161 -0
- package/bin/mocode.js +4 -0
- package/dist/agent/index.js +156 -0
- package/dist/commands/config.js +61 -0
- package/dist/config/index.js +80 -0
- package/dist/index.js +75 -0
- package/dist/llm/index.js +162 -0
- package/dist/repl/index.js +530 -0
- package/dist/rollback/index.js +219 -0
- package/dist/session/compact.js +277 -0
- package/dist/session/index.js +8 -0
- package/dist/session/persist.js +109 -0
- package/dist/skills/discover.js +139 -0
- package/dist/skills/index.js +50 -0
- package/dist/tools/builtins/edit-file.js +34 -0
- package/dist/tools/builtins/glob.js +30 -0
- package/dist/tools/builtins/grep.js +62 -0
- package/dist/tools/builtins/index.js +24 -0
- package/dist/tools/builtins/read-file.js +34 -0
- package/dist/tools/builtins/run-command.js +49 -0
- package/dist/tools/builtins/use-skill.js +27 -0
- package/dist/tools/builtins/web-fetch.js +125 -0
- package/dist/tools/builtins/web-search.js +132 -0
- package/dist/tools/builtins/write-file.js +23 -0
- package/dist/tools/constants.js +11 -0
- package/dist/tools/registry.js +32 -0
- package/dist/tools/types.js +1 -0
- package/dist/ui/content.js +98 -0
- package/dist/ui/layout.js +609 -0
- package/dist/ui/prompt.js +414 -0
- package/dist/ui/render.js +204 -0
- package/dist/ui/spinner.js +63 -0
- package/dist/ui/theme.js +21 -0
- package/package.json +39 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// skills 发现子系统(对标 Claude Code 的 skill 自动发现)。
|
|
2
|
+
// 仅依赖 node 标准库,是叶子模块:不依赖 config/agent/llm/tools,避免环。
|
|
3
|
+
//
|
|
4
|
+
// 约定:每个 skill 是一个目录 <skill-name>/SKILL.md,顶部 YAML frontmatter
|
|
5
|
+
// (name / description 必需,version / license 可选)。元数据始终注入系统提示,
|
|
6
|
+
// 正文由 use_skill 工具按需加载(渐进式披露)。
|
|
7
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
/** 把开头的 ~/ 或 ~ 展开为 home 目录(Windows 与 POSIX 通用)。 */
|
|
11
|
+
function expandHome(p) {
|
|
12
|
+
if (p === '~')
|
|
13
|
+
return os.homedir();
|
|
14
|
+
if (p.startsWith('~/') || p.startsWith('~' + path.sep)) {
|
|
15
|
+
return path.join(os.homedir(), p.slice(2));
|
|
16
|
+
}
|
|
17
|
+
return p;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* 解析 skill 目录列表。
|
|
21
|
+
* env SKILLS_DIRS 设则覆盖默认(用 path.delimiter 切分,与 PATH 同语义:
|
|
22
|
+
* win32 用 ';',POSIX 用 ':',规避盘符冒号问题);未设则默认三目录,
|
|
23
|
+
* 按优先级升序(低→高):~/.claude/skills → ~/.mocode/skills → <cwd>/.mocode/skills。
|
|
24
|
+
*/
|
|
25
|
+
export function resolveSkillsDirs() {
|
|
26
|
+
const env = process.env.SKILLS_DIRS;
|
|
27
|
+
if (env && env.trim()) {
|
|
28
|
+
return env
|
|
29
|
+
.split(path.delimiter)
|
|
30
|
+
.map((d) => d.trim())
|
|
31
|
+
.filter((d) => d.length > 0)
|
|
32
|
+
.map(expandHome);
|
|
33
|
+
}
|
|
34
|
+
const home = os.homedir();
|
|
35
|
+
return [
|
|
36
|
+
path.join(home, '.claude', 'skills'),
|
|
37
|
+
path.join(home, '.mocode', 'skills'),
|
|
38
|
+
path.join(process.cwd(), '.mocode', 'skills'),
|
|
39
|
+
];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 极简 YAML frontmatter 解析(不引入 yaml 依赖)。
|
|
43
|
+
* 仅支持单行键值:按首个冒号切 key/value,trim,去首尾配对引号。
|
|
44
|
+
* 不支持块标量(| / >)与多行值——v1 限制(skill 的 description 实际多为单行)。
|
|
45
|
+
*
|
|
46
|
+
* 返回 { meta, body }:无 frontmatter 时 meta 为空、body 为原文。
|
|
47
|
+
*/
|
|
48
|
+
export function parseFrontmatter(content) {
|
|
49
|
+
const meta = {};
|
|
50
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
51
|
+
// 跳过前导空行,首行须是 ---
|
|
52
|
+
let i = 0;
|
|
53
|
+
while (i < lines.length && lines[i].trim() === '')
|
|
54
|
+
i++;
|
|
55
|
+
if (i >= lines.length || lines[i].trim() !== '---') {
|
|
56
|
+
return { meta, body: content };
|
|
57
|
+
}
|
|
58
|
+
i++; // 跳过开 ---
|
|
59
|
+
const fmLines = [];
|
|
60
|
+
let closed = false;
|
|
61
|
+
while (i < lines.length) {
|
|
62
|
+
if (lines[i].trim() === '---') {
|
|
63
|
+
closed = true;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
fmLines.push(lines[i]);
|
|
67
|
+
i++;
|
|
68
|
+
}
|
|
69
|
+
if (!closed)
|
|
70
|
+
return { meta, body: content }; // 无闭合:视为无 frontmatter
|
|
71
|
+
i++; // 跳过闭 ---
|
|
72
|
+
for (const line of fmLines) {
|
|
73
|
+
const trimmed = line.trim();
|
|
74
|
+
if (trimmed === '' || trimmed.startsWith('#'))
|
|
75
|
+
continue;
|
|
76
|
+
const colon = line.indexOf(':');
|
|
77
|
+
if (colon === -1)
|
|
78
|
+
continue;
|
|
79
|
+
const key = line.slice(0, colon).trim();
|
|
80
|
+
let value = line.slice(colon + 1).trim();
|
|
81
|
+
// 去首尾配对引号(" 或 ')
|
|
82
|
+
if (value.length >= 2 &&
|
|
83
|
+
((value[0] === '"' && value[value.length - 1] === '"') ||
|
|
84
|
+
(value[0] === "'" && value[value.length - 1] === "'"))) {
|
|
85
|
+
value = value.slice(1, -1);
|
|
86
|
+
}
|
|
87
|
+
if (key)
|
|
88
|
+
meta[key] = value;
|
|
89
|
+
}
|
|
90
|
+
const body = lines.slice(i).join('\n').replace(/^\n+/, '');
|
|
91
|
+
return { meta, body };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* 扫描所有 skill 目录,解析 frontmatter,按目录优先级去重。
|
|
95
|
+
* 同步 fs + 全程静默容错(目录不存在 / 读失败 / 解析失败 → 跳过,不抛),
|
|
96
|
+
* 风格对齐 src/session/persist.ts。按 resolveSkillsDirs 升序遍历,
|
|
97
|
+
* Map.set 后设覆盖先设 → 项目级优先。
|
|
98
|
+
*/
|
|
99
|
+
export function discoverSkills() {
|
|
100
|
+
const dirs = resolveSkillsDirs();
|
|
101
|
+
const byName = new Map();
|
|
102
|
+
for (const dir of dirs) {
|
|
103
|
+
let entries = [];
|
|
104
|
+
try {
|
|
105
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
106
|
+
.filter((e) => e.isDirectory())
|
|
107
|
+
.map((e) => e.name);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
continue; // 目录不存在或无权限,静默跳过
|
|
111
|
+
}
|
|
112
|
+
for (const name of entries) {
|
|
113
|
+
const skillDir = path.join(dir, name);
|
|
114
|
+
const skillMdPath = path.join(skillDir, 'SKILL.md');
|
|
115
|
+
try {
|
|
116
|
+
if (!existsSync(skillMdPath))
|
|
117
|
+
continue;
|
|
118
|
+
const content = readFileSync(skillMdPath, 'utf8');
|
|
119
|
+
const { meta } = parseFrontmatter(content);
|
|
120
|
+
const skillName = (meta.name || name).trim();
|
|
121
|
+
const description = (meta.description || '').trim();
|
|
122
|
+
if (!description)
|
|
123
|
+
continue; // 缺 description 跳过(它是最触发机制)
|
|
124
|
+
byName.set(skillName, {
|
|
125
|
+
name: skillName,
|
|
126
|
+
description,
|
|
127
|
+
version: meta.version?.trim() || undefined,
|
|
128
|
+
license: meta.license?.trim() || undefined,
|
|
129
|
+
dir: skillDir,
|
|
130
|
+
skillMdPath,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
continue; // 读 / 解析失败静默跳过
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return Array.from(byName.values());
|
|
139
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// skills barrel:缓存已发现的 skill、按需读正文、拼系统提示段。
|
|
2
|
+
// 被 repl(注入 systemPrompt)与 tools/builtins/use-skill(加载正文)依赖;
|
|
3
|
+
// 自身仅依赖 discover.ts + node:fs,是叶子级业务模块。
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { discoverSkills, parseFrontmatter } from './discover.js';
|
|
6
|
+
let cache = null;
|
|
7
|
+
/** 已发现的 skill 列表(懒加载,首次调用触发扫描;启动期 repl 调一次)。 */
|
|
8
|
+
export function listSkills() {
|
|
9
|
+
if (cache === null)
|
|
10
|
+
cache = discoverSkills();
|
|
11
|
+
return cache;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 读取某 skill 的 SKILL.md 正文(去掉 frontmatter)。
|
|
15
|
+
* 纯函数:找不到 / 读失败返 null(错误字符串交给调用方工具层生成)。
|
|
16
|
+
*/
|
|
17
|
+
export function getSkillBody(name) {
|
|
18
|
+
const skill = listSkills().find((s) => s.name === name);
|
|
19
|
+
if (!skill)
|
|
20
|
+
return null;
|
|
21
|
+
try {
|
|
22
|
+
if (!existsSync(skill.skillMdPath))
|
|
23
|
+
return null;
|
|
24
|
+
const content = readFileSync(skill.skillMdPath, 'utf8');
|
|
25
|
+
const { body } = parseFrontmatter(content);
|
|
26
|
+
const trimmed = body.trim();
|
|
27
|
+
return trimmed || '(skill 正文为空)';
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** 拼进系统提示的 skill 段;无 skill 返空串(零行为变化)。 */
|
|
34
|
+
export function buildSkillsSection() {
|
|
35
|
+
const skills = listSkills();
|
|
36
|
+
if (skills.length === 0)
|
|
37
|
+
return '';
|
|
38
|
+
const lines = skills.map((s) => `- ${s.name}: ${s.description}`);
|
|
39
|
+
return [
|
|
40
|
+
'',
|
|
41
|
+
'',
|
|
42
|
+
'## Skills(按需加载)',
|
|
43
|
+
'以下 skill 可用。只在任务相关时调用 use_skill 工具(传 skill 的 name)加载其完整指令,据此行动;不要无脑批量加载。',
|
|
44
|
+
...lines,
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
47
|
+
/** base 系统提示 + skill 段;无 skill 时 === base。 */
|
|
48
|
+
export function effectiveSystemPrompt(base) {
|
|
49
|
+
return base + buildSkillsSection();
|
|
50
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
// ---------- edit_file ----------
|
|
4
|
+
export const editFileTool = {
|
|
5
|
+
name: 'edit_file',
|
|
6
|
+
description: '对文件做精确字符串替换。old_string 必须在文件中唯一出现且完全匹配(含缩进/换行)。新建文件请用 write_file。',
|
|
7
|
+
parameters: {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
path: { type: 'string' },
|
|
11
|
+
old_string: { type: 'string', description: '要被替换的原文,须精确匹配' },
|
|
12
|
+
new_string: { type: 'string', description: '替换后的新文本' },
|
|
13
|
+
},
|
|
14
|
+
required: ['path', 'old_string', 'new_string'],
|
|
15
|
+
},
|
|
16
|
+
async execute(args) {
|
|
17
|
+
const path = String(args.path);
|
|
18
|
+
const oldStr = String(args.old_string);
|
|
19
|
+
const newStr = String(args.new_string);
|
|
20
|
+
const full = resolve(path);
|
|
21
|
+
const data = await readFile(full, 'utf8');
|
|
22
|
+
const count = data.split(oldStr).length - 1;
|
|
23
|
+
if (count === 0) {
|
|
24
|
+
return `错误:在 ${path} 中未找到 old_string。请先 read_file 确认实际内容。`;
|
|
25
|
+
}
|
|
26
|
+
if (count > 1) {
|
|
27
|
+
return `错误:old_string 在 ${path} 中出现 ${count} 次,不唯一。请加入更多上下文使其唯一。`;
|
|
28
|
+
}
|
|
29
|
+
// 用函数形式替换,避免 new_string 里的 $ 被当特殊模式
|
|
30
|
+
const updated = data.replace(oldStr, () => newStr);
|
|
31
|
+
await writeFile(full, updated, 'utf8');
|
|
32
|
+
return `已在 ${path} 中完成 1 处替换。`;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fg from 'fast-glob';
|
|
2
|
+
import { IGNORE } from '../constants.js';
|
|
3
|
+
// ---------- glob ----------
|
|
4
|
+
export const globTool = {
|
|
5
|
+
name: 'glob',
|
|
6
|
+
description: '按 glob 模式查找文件路径(如 **/*.ts)。返回匹配列表(自动排除 node_modules / .git)。',
|
|
7
|
+
parameters: {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
pattern: { type: 'string', description: 'glob 模式,如 **/*.ts 或 src/**/*.json' },
|
|
11
|
+
},
|
|
12
|
+
required: ['pattern'],
|
|
13
|
+
},
|
|
14
|
+
async execute(args) {
|
|
15
|
+
const pattern = String(args.pattern);
|
|
16
|
+
const files = await fg(pattern, {
|
|
17
|
+
cwd: process.cwd(),
|
|
18
|
+
onlyFiles: true,
|
|
19
|
+
dot: true,
|
|
20
|
+
ignore: IGNORE,
|
|
21
|
+
});
|
|
22
|
+
if (files.length === 0)
|
|
23
|
+
return '无匹配文件';
|
|
24
|
+
const shown = files.slice(0, 200);
|
|
25
|
+
let out = shown.join('\n');
|
|
26
|
+
if (files.length > 200)
|
|
27
|
+
out += `\n... (共 ${files.length} 个,仅显示前 200)`;
|
|
28
|
+
return out;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
import { MAX_RESULTS, IGNORE } from '../constants.js';
|
|
5
|
+
// ---------- grep ----------
|
|
6
|
+
export const grepTool = {
|
|
7
|
+
name: 'grep',
|
|
8
|
+
description: '在文件内容里按正则搜索,返回 file:line: 匹配行。默认递归搜索当前目录(排除 node_modules/.git)。可选 glob 限定文件类型。',
|
|
9
|
+
parameters: {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: {
|
|
12
|
+
pattern: { type: 'string', description: '正则表达式' },
|
|
13
|
+
glob: { type: 'string', description: '可选,限定文件 glob,如 *.ts' },
|
|
14
|
+
},
|
|
15
|
+
required: ['pattern'],
|
|
16
|
+
},
|
|
17
|
+
async execute(args) {
|
|
18
|
+
const pattern = String(args.pattern);
|
|
19
|
+
const g = String(args.glob ?? '**/*');
|
|
20
|
+
let re;
|
|
21
|
+
try {
|
|
22
|
+
re = new RegExp(pattern);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
return `错误:非法正则 ${pattern}: ${e instanceof Error ? e.message : String(e)}`;
|
|
26
|
+
}
|
|
27
|
+
const files = await fg(g, {
|
|
28
|
+
cwd: process.cwd(),
|
|
29
|
+
onlyFiles: true,
|
|
30
|
+
dot: true,
|
|
31
|
+
ignore: IGNORE,
|
|
32
|
+
});
|
|
33
|
+
const results = [];
|
|
34
|
+
let scanned = 0;
|
|
35
|
+
for (const f of files) {
|
|
36
|
+
if (results.length >= MAX_RESULTS)
|
|
37
|
+
break;
|
|
38
|
+
let content;
|
|
39
|
+
try {
|
|
40
|
+
content = await readFile(resolve(f), 'utf8');
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
continue; // 跳过无法读的文件(二进制/权限)
|
|
44
|
+
}
|
|
45
|
+
scanned++;
|
|
46
|
+
const lines = content.split(/\r?\n/);
|
|
47
|
+
for (let i = 0; i < lines.length; i++) {
|
|
48
|
+
if (re.test(lines[i])) {
|
|
49
|
+
results.push(`${f}:${i + 1}: ${lines[i].trim()}`);
|
|
50
|
+
if (results.length >= MAX_RESULTS)
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (results.length === 0)
|
|
56
|
+
return `无匹配(扫描了 ${scanned} 个文件)`;
|
|
57
|
+
let out = results.join('\n');
|
|
58
|
+
if (results.length >= MAX_RESULTS)
|
|
59
|
+
out += `\n...(结果达到 ${MAX_RESULTS} 条上限)`;
|
|
60
|
+
return out;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readFileTool } from './read-file.js';
|
|
2
|
+
import { writeFileTool } from './write-file.js';
|
|
3
|
+
import { editFileTool } from './edit-file.js';
|
|
4
|
+
import { runCommandTool } from './run-command.js';
|
|
5
|
+
import { globTool } from './glob.js';
|
|
6
|
+
import { grepTool } from './grep.js';
|
|
7
|
+
import { webSearchTool } from './web-search.js';
|
|
8
|
+
import { webFetchTool } from './web-fetch.js';
|
|
9
|
+
import { useSkillTool } from './use-skill.js';
|
|
10
|
+
/**
|
|
11
|
+
* 所有内置工具,按注册顺序排列。
|
|
12
|
+
* 加新工具:在本目录新建 `xxx.ts` 导出一个 Tool,再在下面数组里加一行。
|
|
13
|
+
*/
|
|
14
|
+
export const builtinTools = [
|
|
15
|
+
readFileTool,
|
|
16
|
+
writeFileTool,
|
|
17
|
+
editFileTool,
|
|
18
|
+
runCommandTool,
|
|
19
|
+
globTool,
|
|
20
|
+
grepTool,
|
|
21
|
+
webSearchTool,
|
|
22
|
+
webFetchTool,
|
|
23
|
+
useSkillTool,
|
|
24
|
+
];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { MAX_FILE_LINES } from '../constants.js';
|
|
4
|
+
// ---------- read_file ----------
|
|
5
|
+
export const readFileTool = {
|
|
6
|
+
name: 'read_file',
|
|
7
|
+
description: '读取文件内容,返回带行号的文本。改代码前先读。可选 offset(起始行,1-based,默认1)和 limit(行数,默认2000)。',
|
|
8
|
+
parameters: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
path: { type: 'string', description: '文件路径,相对工作目录' },
|
|
12
|
+
offset: { type: 'integer', description: '起始行号(1-based),默认1' },
|
|
13
|
+
limit: { type: 'integer', description: '最大读取行数,默认2000' },
|
|
14
|
+
},
|
|
15
|
+
required: ['path'],
|
|
16
|
+
},
|
|
17
|
+
async execute(args) {
|
|
18
|
+
const path = String(args.path);
|
|
19
|
+
const offset = Number(args.offset ?? 1);
|
|
20
|
+
const limit = Number(args.limit ?? MAX_FILE_LINES);
|
|
21
|
+
const data = await readFile(resolve(path), 'utf8');
|
|
22
|
+
const lines = data.split(/\r?\n/);
|
|
23
|
+
const start = Math.max(0, offset - 1);
|
|
24
|
+
const end = Math.min(lines.length, start + limit);
|
|
25
|
+
const body = lines
|
|
26
|
+
.slice(start, end)
|
|
27
|
+
.map((l, i) => `${String(start + i + 1).padStart(6, ' ')}\t${l}`)
|
|
28
|
+
.join('\n');
|
|
29
|
+
if (end < lines.length) {
|
|
30
|
+
return body + `\n\n... (${lines.length - end} 行未显示,共 ${lines.length} 行)`;
|
|
31
|
+
}
|
|
32
|
+
return body || '(空文件)';
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { MAX_OUTPUT } from '../constants.js';
|
|
3
|
+
// ---------- run_command ----------
|
|
4
|
+
export const runCommandTool = {
|
|
5
|
+
name: 'run_command',
|
|
6
|
+
description: '执行 shell 命令,返回合并的 stdout+stderr。默认超时 120 秒。用于跑测试、构建、git 等。',
|
|
7
|
+
parameters: {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
command: { type: 'string', description: '要执行的命令(单行)' },
|
|
11
|
+
timeout: { type: 'integer', description: '超时毫秒,默认120000' },
|
|
12
|
+
},
|
|
13
|
+
required: ['command'],
|
|
14
|
+
},
|
|
15
|
+
async execute(args) {
|
|
16
|
+
const command = String(args.command);
|
|
17
|
+
const timeout = Number(args.timeout ?? 120000);
|
|
18
|
+
return new Promise((done) => {
|
|
19
|
+
const isWin = process.platform === 'win32';
|
|
20
|
+
const child = spawn(isWin ? 'cmd.exe' : 'bash', isWin ? ['/c', command] : ['-c', command], { cwd: process.cwd() });
|
|
21
|
+
let out = '';
|
|
22
|
+
let finished = false;
|
|
23
|
+
const finish = (s) => {
|
|
24
|
+
if (finished)
|
|
25
|
+
return;
|
|
26
|
+
finished = true;
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
done(s);
|
|
29
|
+
};
|
|
30
|
+
const onChunk = (chunk) => {
|
|
31
|
+
if (out.length < MAX_OUTPUT)
|
|
32
|
+
out += chunk.toString('utf8');
|
|
33
|
+
};
|
|
34
|
+
child.stdout.on('data', onChunk);
|
|
35
|
+
child.stderr.on('data', onChunk);
|
|
36
|
+
child.on('error', (e) => finish(`执行失败: ${e.message}`));
|
|
37
|
+
child.on('close', (code) => {
|
|
38
|
+
let r = out.trim();
|
|
39
|
+
if (out.length >= MAX_OUTPUT)
|
|
40
|
+
r += '\n...(输出已截断)';
|
|
41
|
+
finish(`[退出码 ${code}]\n${r || '(无输出)'}`);
|
|
42
|
+
});
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
child.kill();
|
|
45
|
+
finish(`[超时,已终止]\n${out.trim()}`);
|
|
46
|
+
}, timeout);
|
|
47
|
+
});
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { getSkillBody } from '../../skills/index.js';
|
|
2
|
+
// ---------- use_skill ----------
|
|
3
|
+
// 模型按需加载某 skill 的 SKILL.md 正文(渐进式披露第②层)。
|
|
4
|
+
// 系统提示里已列出可用 skill 的 name + description(何时用),模型据此决定调用。
|
|
5
|
+
export const useSkillTool = {
|
|
6
|
+
name: 'use_skill',
|
|
7
|
+
description: '加载并返回某个 skill 的完整 SKILL.md 指令。系统提示里列出了可用 skill(name + 何时用的 description)。只在任务相关时调用本工具传 skill 的 name,拿到完整指令后据此行动;不要无脑批量加载。',
|
|
8
|
+
parameters: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
name: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
description: '要加载的 skill 名(见系统提示里的 skill 列表,或 /skills 命令)',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
required: ['name'],
|
|
17
|
+
},
|
|
18
|
+
async execute(args) {
|
|
19
|
+
const name = String(args.name ?? '').trim();
|
|
20
|
+
if (!name)
|
|
21
|
+
return '错误:缺少 skill 名。用 /skills 查看可用 skill 列表。';
|
|
22
|
+
const body = getSkillBody(name);
|
|
23
|
+
if (body === null)
|
|
24
|
+
return `错误:未找到 skill "${name}"。用 /skills 查看可用 skill 列表。`;
|
|
25
|
+
return `# Skill: ${name}\n\n${body}`;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { MAX_OUTPUT } from '../constants.js';
|
|
2
|
+
const FETCH_TIMEOUT_MS = 30000;
|
|
3
|
+
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
|
|
4
|
+
// ---------- web_fetch ----------
|
|
5
|
+
export const webFetchTool = {
|
|
6
|
+
name: 'web_fetch',
|
|
7
|
+
description: '抓取指定 URL 的网页内容并清洗成纯文本(去 HTML 标签/脚本/样式,保留正文)。用于读取搜索结果里的某个链接、或用户给出的具体 URL。注意:只能抓静态 HTML,JS 渲染的页面(正文靠脚本填充)可能拿不到内容——那种情况改用 web_search(其结果自带清洗后的 content)。',
|
|
8
|
+
parameters: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
url: { type: 'string', description: '要抓取的完整 URL,须 http/https' },
|
|
12
|
+
},
|
|
13
|
+
required: ['url'],
|
|
14
|
+
},
|
|
15
|
+
async execute(args) {
|
|
16
|
+
const rawUrl = String(args.url ?? '').trim();
|
|
17
|
+
if (!rawUrl)
|
|
18
|
+
return '错误:url 不能为空。';
|
|
19
|
+
let url;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(rawUrl);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return `错误:URL 不合法: ${rawUrl}`;
|
|
25
|
+
}
|
|
26
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
27
|
+
return `错误:仅支持 http/https,收到 ${url.protocol}`;
|
|
28
|
+
}
|
|
29
|
+
const ctrl = new AbortController();
|
|
30
|
+
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
31
|
+
try {
|
|
32
|
+
const resp = await fetch(url.href, {
|
|
33
|
+
method: 'GET',
|
|
34
|
+
headers: {
|
|
35
|
+
'User-Agent': UA,
|
|
36
|
+
Accept: 'text/html,application/xhtml+xml,text/plain,application/json,*/*',
|
|
37
|
+
},
|
|
38
|
+
signal: ctrl.signal,
|
|
39
|
+
});
|
|
40
|
+
const contentType = resp.headers.get('content-type') ?? '';
|
|
41
|
+
const text = await resp.text();
|
|
42
|
+
if (!resp.ok) {
|
|
43
|
+
return `错误:抓取失败 HTTP ${resp.status} ${resp.statusText}\n${text.slice(0, 500)}`;
|
|
44
|
+
}
|
|
45
|
+
const isHtml = /html/i.test(contentType) ||
|
|
46
|
+
/^\s*<!doctype html/i.test(text) ||
|
|
47
|
+
/<html[\s>]/i.test(text.slice(0, 1000));
|
|
48
|
+
const body = isHtml ? htmlToText(text) : text;
|
|
49
|
+
const ct = contentType.split(';')[0].trim();
|
|
50
|
+
const prefix = `${url.href} (HTTP ${resp.status}${ct ? ', ' + ct : ''})\n\n`;
|
|
51
|
+
let out = prefix + body;
|
|
52
|
+
if (out.length > MAX_OUTPUT) {
|
|
53
|
+
out = out.slice(0, MAX_OUTPUT) + `\n...(已截断,原文 ${body.length} 字符)`;
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
if (ctrl.signal.aborted) {
|
|
59
|
+
return `错误:抓取超时(${FETCH_TIMEOUT_MS}ms): ${url.href}`;
|
|
60
|
+
}
|
|
61
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
62
|
+
return `错误:抓取失败: ${msg}`;
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* 轻量 HTML→纯文本:优先取 <main>/<article> 正文区,再去 nav/header/footer/aside/form
|
|
71
|
+
* 等非正文块与脚本样式,块级/列表标签转换行,去剩余标签,解码实体,压缩空白。
|
|
72
|
+
* 不求精确解析,只取可读正文。
|
|
73
|
+
*/
|
|
74
|
+
function htmlToText(html) {
|
|
75
|
+
let s = html;
|
|
76
|
+
// 优先正文区:有 <main>/<article> 就只取其内容,避开整页 nav/header/footer 噪音
|
|
77
|
+
const main = s.match(/<main\b[^>]*>[\s\S]*?<\/main>/i);
|
|
78
|
+
if (main) {
|
|
79
|
+
s = main[0];
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const art = s.match(/<article\b[^>]*>[\s\S]*?<\/article>/i);
|
|
83
|
+
if (art)
|
|
84
|
+
s = art[0];
|
|
85
|
+
}
|
|
86
|
+
s = s.replace(/<!--[\s\S]*?-->/g, '');
|
|
87
|
+
s = s.replace(/<script[\s\S]*?<\/script>/gi, '');
|
|
88
|
+
s = s.replace(/<style[\s\S]*?<\/style>/gi, '');
|
|
89
|
+
s = s.replace(/<noscript[\s\S]*?<\/noscript>/gi, '');
|
|
90
|
+
s = s.replace(/<nav[\s\S]*?<\/nav>/gi, '');
|
|
91
|
+
s = s.replace(/<header[\s\S]*?<\/header>/gi, '');
|
|
92
|
+
s = s.replace(/<footer[\s\S]*?<\/footer>/gi, '');
|
|
93
|
+
s = s.replace(/<aside[\s\S]*?<\/aside>/gi, '');
|
|
94
|
+
s = s.replace(/<form[\s\S]*?<\/form>/gi, '');
|
|
95
|
+
s = s.replace(/<\/(p|div|li|tr|h[1-6]|section|article|header|footer|nav|aside|ul|ol|table|blockquote|pre|br)>/gi, '\n');
|
|
96
|
+
s = s.replace(/<br\b[^>]*>/gi, '\n');
|
|
97
|
+
s = s.replace(/<li\b[^>]*>/gi, '\n');
|
|
98
|
+
s = s.replace(/<[^>]+>/g, '');
|
|
99
|
+
s = decodeEntities(s);
|
|
100
|
+
s = s.replace(/[ \t\f\v]+/g, ' ');
|
|
101
|
+
s = s.replace(/\n[ \t]*/g, '\n');
|
|
102
|
+
s = s.replace(/\n{3,}/g, '\n\n');
|
|
103
|
+
return s.trim();
|
|
104
|
+
}
|
|
105
|
+
function decodeEntities(s) {
|
|
106
|
+
return s
|
|
107
|
+
.replace(/ /gi, ' ')
|
|
108
|
+
.replace(/&/gi, '&')
|
|
109
|
+
.replace(/</gi, '<')
|
|
110
|
+
.replace(/>/gi, '>')
|
|
111
|
+
.replace(/"/gi, '"')
|
|
112
|
+
.replace(/'|'/gi, "'")
|
|
113
|
+
.replace(/&#(\d+);/g, (_m, n) => safeFromCodePoint(parseInt(n, 10)))
|
|
114
|
+
.replace(/&#x([0-9a-f]+);/gi, (_m, h) => safeFromCodePoint(parseInt(h, 16)));
|
|
115
|
+
}
|
|
116
|
+
function safeFromCodePoint(n) {
|
|
117
|
+
if (!Number.isFinite(n) || n < 0 || n > 0x10ffff)
|
|
118
|
+
return '';
|
|
119
|
+
try {
|
|
120
|
+
return String.fromCodePoint(n);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
}
|