hcent 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 +160 -0
- package/dist/agent/context-manager.d.ts +14 -0
- package/dist/agent/context-manager.js +109 -0
- package/dist/agent/context-manager.js.map +1 -0
- package/dist/agent/index.d.ts +27 -0
- package/dist/agent/index.js +120 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +40 -0
- package/dist/bin.js.map +1 -0
- package/dist/config/index.d.ts +25 -0
- package/dist/config/index.js +109 -0
- package/dist/config/index.js.map +1 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +43 -0
- package/dist/main.js.map +1 -0
- package/dist/provider/index.d.ts +23 -0
- package/dist/provider/index.js +124 -0
- package/dist/provider/index.js.map +1 -0
- package/dist/provider/provider-manager.d.ts +9 -0
- package/dist/provider/provider-manager.js +43 -0
- package/dist/provider/provider-manager.js.map +1 -0
- package/dist/session/index.d.ts +39 -0
- package/dist/session/index.js +69 -0
- package/dist/session/index.js.map +1 -0
- package/dist/session/session-store.d.ts +19 -0
- package/dist/session/session-store.js +109 -0
- package/dist/session/session-store.js.map +1 -0
- package/dist/setup/index.d.ts +11 -0
- package/dist/setup/index.js +97 -0
- package/dist/setup/index.js.map +1 -0
- package/dist/skill/command-palette.d.ts +12 -0
- package/dist/skill/command-palette.js +62 -0
- package/dist/skill/command-palette.js.map +1 -0
- package/dist/skill/index.d.ts +15 -0
- package/dist/skill/index.js +145 -0
- package/dist/skill/index.js.map +1 -0
- package/dist/skill/skill-pointer.d.ts +18 -0
- package/dist/skill/skill-pointer.js +70 -0
- package/dist/skill/skill-pointer.js.map +1 -0
- package/dist/tools/index.d.ts +26 -0
- package/dist/tools/index.js +745 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/permission.d.ts +11 -0
- package/dist/tools/permission.js +11 -0
- package/dist/tools/permission.js.map +1 -0
- package/dist/tui/aligned-text.d.ts +14 -0
- package/dist/tui/aligned-text.js +46 -0
- package/dist/tui/aligned-text.js.map +1 -0
- package/dist/tui/builtin-commands.d.ts +40 -0
- package/dist/tui/builtin-commands.js +137 -0
- package/dist/tui/builtin-commands.js.map +1 -0
- package/dist/tui/editor-history.d.ts +6 -0
- package/dist/tui/editor-history.js +11 -0
- package/dist/tui/editor-history.js.map +1 -0
- package/dist/tui/editor-placeholder.d.ts +2 -0
- package/dist/tui/editor-placeholder.js +16 -0
- package/dist/tui/editor-placeholder.js.map +1 -0
- package/dist/tui/editor-with-placeholder.d.ts +8 -0
- package/dist/tui/editor-with-placeholder.js +49 -0
- package/dist/tui/editor-with-placeholder.js.map +1 -0
- package/dist/tui/hcent-autocomplete.d.ts +19 -0
- package/dist/tui/hcent-autocomplete.js +63 -0
- package/dist/tui/hcent-autocomplete.js.map +1 -0
- package/dist/tui/help-text.d.ts +1 -0
- package/dist/tui/help-text.js +15 -0
- package/dist/tui/help-text.js.map +1 -0
- package/dist/tui/index.d.ts +18 -0
- package/dist/tui/index.js +520 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/tui/message-display.d.ts +8 -0
- package/dist/tui/message-display.js +41 -0
- package/dist/tui/message-display.js.map +1 -0
- package/dist/tui/pi-themes.d.ts +4 -0
- package/dist/tui/pi-themes.js +30 -0
- package/dist/tui/pi-themes.js.map +1 -0
- package/dist/tui/render-message.d.ts +3 -0
- package/dist/tui/render-message.js +32 -0
- package/dist/tui/render-message.js.map +1 -0
- package/dist/tui/thinking-loader.d.ts +17 -0
- package/dist/tui/thinking-loader.js +108 -0
- package/dist/tui/thinking-loader.js.map +1 -0
- package/dist/tui/user-message.d.ts +12 -0
- package/dist/tui/user-message.js +35 -0
- package/dist/tui/user-message.js.map +1 -0
- package/package.json +75 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, writeFileSync, statSync, } from 'node:fs';
|
|
2
|
+
import { resolve, relative, dirname, join } from 'node:path';
|
|
3
|
+
import { execFile as execFileCB } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
const execFile = promisify(execFileCB);
|
|
6
|
+
// ── 路径安全 ──
|
|
7
|
+
function safeResolve(cwd, target) {
|
|
8
|
+
// 拒绝空路径
|
|
9
|
+
if (!target || target.length === 0) {
|
|
10
|
+
return { ok: false, error: '路径不能为空' };
|
|
11
|
+
}
|
|
12
|
+
const resolved = resolve(cwd, target);
|
|
13
|
+
// 拒绝越界访问
|
|
14
|
+
const rel = relative(cwd, resolved);
|
|
15
|
+
if (rel.startsWith('..') || resolve(rel) !== resolve(rel)) {
|
|
16
|
+
return { ok: false, error: `路径越界: ${target}` };
|
|
17
|
+
}
|
|
18
|
+
// Ensure we don't leave via absolute path tricks on Windows
|
|
19
|
+
const rel2 = relative(resolve(cwd), resolved);
|
|
20
|
+
if (rel2.startsWith('..')) {
|
|
21
|
+
return { ok: false, error: `路径越界: ${target}` };
|
|
22
|
+
}
|
|
23
|
+
return { ok: true, path: resolved };
|
|
24
|
+
}
|
|
25
|
+
function ensureDir(cwd, target) {
|
|
26
|
+
const resolved = safeResolve(cwd, target);
|
|
27
|
+
if (!resolved.ok)
|
|
28
|
+
return resolved;
|
|
29
|
+
try {
|
|
30
|
+
const st = statSync(resolved.path);
|
|
31
|
+
if (!st.isDirectory()) {
|
|
32
|
+
return { ok: false, error: `不是目录: ${target}` };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return { ok: false, error: `目录不存在: ${target}` };
|
|
37
|
+
}
|
|
38
|
+
return resolved;
|
|
39
|
+
}
|
|
40
|
+
// ── 工具实现 ──
|
|
41
|
+
// 1. ls — 目录浏览
|
|
42
|
+
const lsTool = {
|
|
43
|
+
schema: {
|
|
44
|
+
name: 'ls',
|
|
45
|
+
description: '列出目录内容。返回目录中的文件和子目录名(每行一个),失败返回错误信息。',
|
|
46
|
+
parameters: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: {
|
|
49
|
+
path: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: '目录路径,相对于仓库根目录。默认 "."',
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
permission: 'auto',
|
|
57
|
+
async run(args, ctx) {
|
|
58
|
+
const dirPath = typeof args.path === 'string' && args.path.length > 0 ? args.path : '.';
|
|
59
|
+
const resolved = ensureDir(ctx.cwd, dirPath);
|
|
60
|
+
if (!resolved.ok)
|
|
61
|
+
return { ok: false, output: '', error: resolved.error };
|
|
62
|
+
try {
|
|
63
|
+
const entries = readdirSync(resolved.path);
|
|
64
|
+
const lines = [];
|
|
65
|
+
for (const entry of entries.sort()) {
|
|
66
|
+
try {
|
|
67
|
+
const st = statSync(resolve(resolved.path, entry));
|
|
68
|
+
const suffix = st.isDirectory() ? '/' : '';
|
|
69
|
+
lines.push(`${entry}${suffix}`);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
lines.push(entry);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { ok: true, output: lines.join('\n') };
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
return { ok: false, output: '', error: `ls 失败: ${String(err)}` };
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
// 2. read — 文件读取
|
|
83
|
+
const readTool = {
|
|
84
|
+
schema: {
|
|
85
|
+
name: 'read',
|
|
86
|
+
description: '读取文件内容。返回文件的完整文本内容,失败返回错误信息。',
|
|
87
|
+
parameters: {
|
|
88
|
+
type: 'object',
|
|
89
|
+
properties: {
|
|
90
|
+
path: {
|
|
91
|
+
type: 'string',
|
|
92
|
+
description: '文件路径,相对于仓库根目录。',
|
|
93
|
+
},
|
|
94
|
+
limit: {
|
|
95
|
+
type: 'number',
|
|
96
|
+
description: '最大读取行数,默认不限制。',
|
|
97
|
+
},
|
|
98
|
+
offset: {
|
|
99
|
+
type: 'number',
|
|
100
|
+
description: '从第几行开始读取(0-based),默认 0。',
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
required: ['path'],
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
permission: 'auto',
|
|
107
|
+
async run(args, ctx) {
|
|
108
|
+
const filePath = String(args.path ?? '');
|
|
109
|
+
const resolved = safeResolve(ctx.cwd, filePath);
|
|
110
|
+
if (!resolved.ok)
|
|
111
|
+
return { ok: false, output: '', error: resolved.error };
|
|
112
|
+
try {
|
|
113
|
+
const st = statSync(resolved.path);
|
|
114
|
+
if (st.isDirectory()) {
|
|
115
|
+
return { ok: false, output: '', error: `是目录而非文件: ${filePath}` };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return { ok: false, output: '', error: `文件不存在: ${filePath}` };
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const content = readFileSync(resolved.path, 'utf-8');
|
|
123
|
+
const lines = content.split('\n');
|
|
124
|
+
const offset = typeof args.offset === 'number' ? Math.max(0, Math.floor(args.offset)) : 0;
|
|
125
|
+
const limit = typeof args.limit === 'number' ? Math.max(1, Math.floor(args.limit)) : undefined;
|
|
126
|
+
const sliced = limit ? lines.slice(offset, offset + limit) : lines.slice(offset);
|
|
127
|
+
return { ok: true, output: sliced.join('\n') };
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
return { ok: false, output: '', error: `读取失败: ${String(err)}` };
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
// 3. glob — 文件名匹配
|
|
135
|
+
const globTool = {
|
|
136
|
+
schema: {
|
|
137
|
+
name: 'glob',
|
|
138
|
+
description: '查找匹配指定通配符模式的文件。返回匹配的文件路径列表(每行一个,相对于根目录),失败返回错误信息。',
|
|
139
|
+
parameters: {
|
|
140
|
+
type: 'object',
|
|
141
|
+
properties: {
|
|
142
|
+
pattern: {
|
|
143
|
+
type: 'string',
|
|
144
|
+
description: '通配符模式,如 "**/*.ts" 或 "src/*.test.ts"。',
|
|
145
|
+
},
|
|
146
|
+
path: {
|
|
147
|
+
type: 'string',
|
|
148
|
+
description: '搜索起始目录,相对于仓库根目录。默认 "."',
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
required: ['pattern'],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
permission: 'auto',
|
|
155
|
+
async run(args, ctx) {
|
|
156
|
+
const pattern = String(args.pattern ?? '');
|
|
157
|
+
const basePath = typeof args.path === 'string' && args.path.length > 0 ? args.path : '.';
|
|
158
|
+
const resolved = ensureDir(ctx.cwd, basePath);
|
|
159
|
+
if (!resolved.ok)
|
|
160
|
+
return { ok: false, output: '', error: resolved.error };
|
|
161
|
+
try {
|
|
162
|
+
const results = [];
|
|
163
|
+
globWalk(resolved.path, ctx.cwd, pattern, results);
|
|
164
|
+
results.sort();
|
|
165
|
+
return { ok: true, output: results.join('\n') };
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
return { ok: false, output: '', error: `glob 失败: ${String(err)}` };
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
// 简易 glob 递归遍历
|
|
173
|
+
function globWalk(dir, root, pattern, results) {
|
|
174
|
+
let entries;
|
|
175
|
+
try {
|
|
176
|
+
entries = readdirSync(dir);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
for (const entry of entries) {
|
|
182
|
+
const full = resolve(dir, entry);
|
|
183
|
+
const rel = relative(root, full);
|
|
184
|
+
// 拒绝越界
|
|
185
|
+
if (rel.startsWith('..'))
|
|
186
|
+
continue;
|
|
187
|
+
let isDir = false;
|
|
188
|
+
try {
|
|
189
|
+
isDir = statSync(full).isDirectory();
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (matchGlob(rel, pattern)) {
|
|
195
|
+
results.push(rel.replace(/\\/g, '/'));
|
|
196
|
+
}
|
|
197
|
+
if (isDir) {
|
|
198
|
+
// 跳过 node_modules / .git
|
|
199
|
+
if (entry === 'node_modules' || entry === '.git')
|
|
200
|
+
continue;
|
|
201
|
+
globWalk(full, root, pattern, results);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// 简易 glob 匹配(支持 * 和 **)
|
|
206
|
+
function matchGlob(str, pattern) {
|
|
207
|
+
// 将 pattern 转为 regex
|
|
208
|
+
const regexStr = pattern
|
|
209
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape specials
|
|
210
|
+
.replace(/\*\*\//g, '<<<GLOBSTAR>>>') // placeholder for **/
|
|
211
|
+
.replace(/\*\*/g, '<<<GLOBSTAR>>>') // ** at end
|
|
212
|
+
.replace(/\*/g, '[^/]*')
|
|
213
|
+
.replace(/<<<GLOBSTAR>>>/g, '.*');
|
|
214
|
+
const re = new RegExp(`^${regexStr}$`);
|
|
215
|
+
// Normalize to forward slashes for matching
|
|
216
|
+
const normalized = str.replace(/\\/g, '/');
|
|
217
|
+
return re.test(normalized);
|
|
218
|
+
}
|
|
219
|
+
// 4. grep — 内容搜索
|
|
220
|
+
function parseGrepPattern(pattern) {
|
|
221
|
+
try {
|
|
222
|
+
return new RegExp(pattern, 'g');
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return { error: `无效正则表达式: ${pattern}` };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function collectGrepResults(targetPath, cwd, re, fileFilter) {
|
|
229
|
+
const results = [];
|
|
230
|
+
const st = statSync(targetPath);
|
|
231
|
+
if (st.isFile()) {
|
|
232
|
+
grepInFile(targetPath, cwd, re, results);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
grepWalk(targetPath, cwd, re, fileFilter, results);
|
|
236
|
+
}
|
|
237
|
+
return results;
|
|
238
|
+
}
|
|
239
|
+
async function runGrep(args, ctx) {
|
|
240
|
+
const pattern = String(args.pattern ?? '');
|
|
241
|
+
const targetPath = typeof args.path === 'string' && args.path.length > 0 ? args.path : '.';
|
|
242
|
+
const fileFilter = typeof args.glob === 'string' && args.glob.length > 0 ? args.glob : undefined;
|
|
243
|
+
const resolved = safeResolve(ctx.cwd, targetPath);
|
|
244
|
+
if (!resolved.ok)
|
|
245
|
+
return { ok: false, output: '', error: resolved.error };
|
|
246
|
+
const reOrError = parseGrepPattern(pattern);
|
|
247
|
+
if ('error' in reOrError)
|
|
248
|
+
return { ok: false, output: '', error: reOrError.error };
|
|
249
|
+
try {
|
|
250
|
+
const results = collectGrepResults(resolved.path, ctx.cwd, reOrError, fileFilter);
|
|
251
|
+
return { ok: true, output: results.length === 0 ? '' : results.join('\n') };
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
return { ok: false, output: '', error: `grep 失败: ${String(err)}` };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const grepTool = {
|
|
258
|
+
schema: {
|
|
259
|
+
name: 'grep',
|
|
260
|
+
description: '在文件内容中搜索匹配正则表达式的行。返回匹配的文件路径与行内容,失败返回错误信息。',
|
|
261
|
+
parameters: {
|
|
262
|
+
type: 'object',
|
|
263
|
+
properties: {
|
|
264
|
+
pattern: {
|
|
265
|
+
type: 'string',
|
|
266
|
+
description: '正则表达式模式。',
|
|
267
|
+
},
|
|
268
|
+
path: {
|
|
269
|
+
type: 'string',
|
|
270
|
+
description: '搜索起始目录或文件路径,相对于仓库根目录。默认 "."',
|
|
271
|
+
},
|
|
272
|
+
glob: {
|
|
273
|
+
type: 'string',
|
|
274
|
+
description: '过滤文件的通配符模式,如 "*.ts"。',
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
required: ['pattern'],
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
permission: 'auto',
|
|
281
|
+
run: runGrep,
|
|
282
|
+
};
|
|
283
|
+
function grepInFile(filePath, root, re, results) {
|
|
284
|
+
let content;
|
|
285
|
+
try {
|
|
286
|
+
content = readFileSync(filePath, 'utf-8');
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const relPath = relative(root, filePath).replace(/\\/g, '/');
|
|
292
|
+
const lines = content.split('\n');
|
|
293
|
+
for (let i = 0; i < lines.length; i++) {
|
|
294
|
+
if (re.test(lines[i])) {
|
|
295
|
+
// 重置 lastIndex(因为带 g flag 的 regex 有状态)
|
|
296
|
+
re.lastIndex = 0;
|
|
297
|
+
results.push(`${relPath}:${i + 1}: ${lines[i]}`);
|
|
298
|
+
}
|
|
299
|
+
else {
|
|
300
|
+
re.lastIndex = 0;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
function grepWalk(dir, root, re, fileFilter, results) {
|
|
305
|
+
let entries;
|
|
306
|
+
try {
|
|
307
|
+
entries = readdirSync(dir);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
for (const entry of entries) {
|
|
313
|
+
if (entry === 'node_modules' || entry === '.git')
|
|
314
|
+
continue;
|
|
315
|
+
const full = resolve(dir, entry);
|
|
316
|
+
const rel = relative(root, full);
|
|
317
|
+
if (rel.startsWith('..'))
|
|
318
|
+
continue;
|
|
319
|
+
let isDir = false;
|
|
320
|
+
try {
|
|
321
|
+
isDir = statSync(full).isDirectory();
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (isDir) {
|
|
327
|
+
grepWalk(full, root, re, fileFilter, results);
|
|
328
|
+
}
|
|
329
|
+
else if (!fileFilter || matchGlob(rel, fileFilter)) {
|
|
330
|
+
grepInFile(full, root, re, results);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// 5. write — 写入/覆盖文件
|
|
335
|
+
const writeTool = {
|
|
336
|
+
schema: {
|
|
337
|
+
name: 'write',
|
|
338
|
+
description: '写入或覆盖文件。将指定内容写入文件(覆盖已有内容),成功返回确认信息,失败返回错误。',
|
|
339
|
+
parameters: {
|
|
340
|
+
type: 'object',
|
|
341
|
+
properties: {
|
|
342
|
+
path: {
|
|
343
|
+
type: 'string',
|
|
344
|
+
description: '目标文件路径,相对于仓库根目录。',
|
|
345
|
+
},
|
|
346
|
+
content: {
|
|
347
|
+
type: 'string',
|
|
348
|
+
description: '要写入的文件内容。',
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
required: ['path', 'content'],
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
permission: 'confirm',
|
|
355
|
+
async run(args, ctx) {
|
|
356
|
+
const filePath = String(args.path ?? '');
|
|
357
|
+
const content = String(args.content ?? '');
|
|
358
|
+
const resolved = safeResolve(ctx.cwd, filePath);
|
|
359
|
+
if (!resolved.ok)
|
|
360
|
+
return { ok: false, output: '', error: resolved.error };
|
|
361
|
+
// 检查父目录
|
|
362
|
+
try {
|
|
363
|
+
const parent = dirname(resolved.path);
|
|
364
|
+
const { mkdirSync } = await import('node:fs');
|
|
365
|
+
mkdirSync(parent, { recursive: true });
|
|
366
|
+
}
|
|
367
|
+
catch (err) {
|
|
368
|
+
return { ok: false, output: '', error: `无法创建父目录: ${String(err)}` };
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
writeFileSync(resolved.path, content, 'utf-8');
|
|
372
|
+
return { ok: true, output: `已写入: ${filePath}` };
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
return { ok: false, output: '', error: `写入失败: ${String(err)}` };
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
// 6. edit — 按字符串替换编辑文件
|
|
380
|
+
function replaceFileContent(content, oldStr, newStr, replaceAll) {
|
|
381
|
+
const index = content.indexOf(oldStr);
|
|
382
|
+
if (index === -1)
|
|
383
|
+
return { error: '未找到匹配的 old_string' };
|
|
384
|
+
if (!replaceAll) {
|
|
385
|
+
const secondIndex = content.indexOf(oldStr, index + oldStr.length);
|
|
386
|
+
if (secondIndex !== -1) {
|
|
387
|
+
return { error: 'old_string 出现多次,请使用 replace_all: true 或提供更精确的匹配字符串' };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return replaceAll
|
|
391
|
+
? content.split(oldStr).join(newStr)
|
|
392
|
+
: content.slice(0, index) + newStr + content.slice(index + oldStr.length);
|
|
393
|
+
}
|
|
394
|
+
async function runEdit(args, ctx) {
|
|
395
|
+
const filePath = String(args.path ?? '');
|
|
396
|
+
const oldStr = String(args.old_string ?? '');
|
|
397
|
+
const newStr = String(args.new_string ?? '');
|
|
398
|
+
const replaceAll = args.replace_all === true;
|
|
399
|
+
const resolved = safeResolve(ctx.cwd, filePath);
|
|
400
|
+
if (!resolved.ok)
|
|
401
|
+
return { ok: false, output: '', error: resolved.error };
|
|
402
|
+
let content;
|
|
403
|
+
try {
|
|
404
|
+
content = readFileSync(resolved.path, 'utf-8');
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
return { ok: false, output: '', error: `文件不存在: ${filePath}` };
|
|
408
|
+
}
|
|
409
|
+
const nextContent = replaceFileContent(content, oldStr, newStr, replaceAll);
|
|
410
|
+
if (typeof nextContent !== 'string') {
|
|
411
|
+
return { ok: false, output: '', error: nextContent.error };
|
|
412
|
+
}
|
|
413
|
+
try {
|
|
414
|
+
writeFileSync(resolved.path, nextContent, 'utf-8');
|
|
415
|
+
return { ok: true, output: `已编辑: ${filePath}` };
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
return { ok: false, output: '', error: `编辑失败: ${String(err)}` };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const editTool = {
|
|
422
|
+
schema: {
|
|
423
|
+
name: 'edit',
|
|
424
|
+
description: '在文件中执行精确字符串替换。找到 old_string 的精确匹配并替换为 new_string。仅替换首次出现(或使用 replace_all 替换全部)。',
|
|
425
|
+
parameters: {
|
|
426
|
+
type: 'object',
|
|
427
|
+
properties: {
|
|
428
|
+
path: {
|
|
429
|
+
type: 'string',
|
|
430
|
+
description: '目标文件路径,相对于仓库根目录。',
|
|
431
|
+
},
|
|
432
|
+
old_string: {
|
|
433
|
+
type: 'string',
|
|
434
|
+
description: '要被替换的原字符串(必须精确匹配,包括缩进)。',
|
|
435
|
+
},
|
|
436
|
+
new_string: {
|
|
437
|
+
type: 'string',
|
|
438
|
+
description: '替换后的新字符串。',
|
|
439
|
+
},
|
|
440
|
+
replace_all: {
|
|
441
|
+
type: 'boolean',
|
|
442
|
+
description: '是否替换所有匹配项,默认仅替换首次出现。',
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
required: ['path', 'old_string', 'new_string'],
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
permission: 'confirm',
|
|
449
|
+
run: runEdit,
|
|
450
|
+
};
|
|
451
|
+
// 7. bash — 执行 Shell 命令
|
|
452
|
+
const MAX_ERROR_LENGTH = 500;
|
|
453
|
+
function sanitizeOutput(text) {
|
|
454
|
+
// 移除不可打印控制字符(保留换行/制表符),清理乱码残留
|
|
455
|
+
return text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '');
|
|
456
|
+
}
|
|
457
|
+
function truncateError(text) {
|
|
458
|
+
const cleaned = sanitizeOutput(text);
|
|
459
|
+
if (cleaned.length <= MAX_ERROR_LENGTH)
|
|
460
|
+
return cleaned;
|
|
461
|
+
return cleaned.slice(0, MAX_ERROR_LENGTH) + '\n…(输出已截断)';
|
|
462
|
+
}
|
|
463
|
+
const bashTool = {
|
|
464
|
+
schema: {
|
|
465
|
+
name: 'bash',
|
|
466
|
+
description: '执行 Shell 命令。Windows 下使用 PowerShell(UTF-8),Linux/macOS 使用 /bin/sh。返回 stdout 输出,失败返回简洁错误。',
|
|
467
|
+
parameters: {
|
|
468
|
+
type: 'object',
|
|
469
|
+
properties: {
|
|
470
|
+
command: {
|
|
471
|
+
type: 'string',
|
|
472
|
+
description: '要执行的 Shell 命令。',
|
|
473
|
+
},
|
|
474
|
+
timeout: {
|
|
475
|
+
type: 'number',
|
|
476
|
+
description: '超时毫秒数,默认 30000(30 秒)。',
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
required: ['command'],
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
permission: 'confirm',
|
|
483
|
+
async run(args, ctx) {
|
|
484
|
+
const command = String(args.command ?? '');
|
|
485
|
+
const timeoutMs = typeof args.timeout === 'number' ? args.timeout : 30_000;
|
|
486
|
+
if (command.trim().length === 0) {
|
|
487
|
+
return { ok: false, output: '', error: '命令不能为空' };
|
|
488
|
+
}
|
|
489
|
+
try {
|
|
490
|
+
let shell;
|
|
491
|
+
let shellArgs;
|
|
492
|
+
if (process.platform === 'win32') {
|
|
493
|
+
shell = 'powershell.exe';
|
|
494
|
+
shellArgs = [
|
|
495
|
+
'-NoProfile', '-NonInteractive',
|
|
496
|
+
'-Command',
|
|
497
|
+
// 强制 UTF-8 输出编码,避免 GBK 乱码
|
|
498
|
+
`[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ${command}`,
|
|
499
|
+
];
|
|
500
|
+
}
|
|
501
|
+
else {
|
|
502
|
+
shell = '/bin/sh';
|
|
503
|
+
shellArgs = ['-c', command];
|
|
504
|
+
}
|
|
505
|
+
const { stdout, stderr } = await execFile(shell, shellArgs, {
|
|
506
|
+
cwd: ctx.cwd,
|
|
507
|
+
timeout: Math.min(timeoutMs, 600_000),
|
|
508
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
509
|
+
signal: ctx.signal,
|
|
510
|
+
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
|
511
|
+
});
|
|
512
|
+
const cleanOut = sanitizeOutput(stdout);
|
|
513
|
+
const cleanErr = sanitizeOutput(stderr);
|
|
514
|
+
const output = cleanErr
|
|
515
|
+
? `${cleanOut}\n[stderr]\n${cleanErr}`
|
|
516
|
+
: cleanOut;
|
|
517
|
+
return { ok: true, output: output.trim() || '(无输出)' };
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
521
|
+
return { ok: false, output: '', error: `命令执行失败: ${truncateError(msg)}` };
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
// ── fetch 工具 ──
|
|
526
|
+
function stripHtmlToText(text, contentType) {
|
|
527
|
+
if (!contentType.includes('text/html') && !text.includes('<html'))
|
|
528
|
+
return text;
|
|
529
|
+
return text
|
|
530
|
+
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
|
531
|
+
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
|
532
|
+
.replace(/<[^>]+>/g, ' ')
|
|
533
|
+
.replace(/\s+/g, ' ')
|
|
534
|
+
.trim();
|
|
535
|
+
}
|
|
536
|
+
async function runFetch(args) {
|
|
537
|
+
const url = typeof args.url === 'string' ? args.url : '';
|
|
538
|
+
const maxLength = typeof args.maxLength === 'number' ? args.maxLength : 8000;
|
|
539
|
+
if (!url)
|
|
540
|
+
return { ok: false, output: '', error: 'url 参数不能为空' };
|
|
541
|
+
try {
|
|
542
|
+
const controller = new AbortController();
|
|
543
|
+
const timer = setTimeout(() => controller.abort(), 10_000);
|
|
544
|
+
const resp = await fetch(url, { signal: controller.signal });
|
|
545
|
+
clearTimeout(timer);
|
|
546
|
+
if (!resp.ok) {
|
|
547
|
+
return { ok: false, output: '', error: `HTTP ${resp.status} ${resp.statusText}` };
|
|
548
|
+
}
|
|
549
|
+
let text = stripHtmlToText(await resp.text(), resp.headers.get('content-type') ?? '');
|
|
550
|
+
if (text.length > maxLength) {
|
|
551
|
+
text = text.slice(0, maxLength) + '\n...[截断]';
|
|
552
|
+
}
|
|
553
|
+
return { ok: true, output: text };
|
|
554
|
+
}
|
|
555
|
+
catch (err) {
|
|
556
|
+
return { ok: false, output: '', error: err instanceof Error ? err.message : String(err) };
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const fetchTool = {
|
|
560
|
+
schema: {
|
|
561
|
+
name: 'fetch',
|
|
562
|
+
description: '获取指定 URL 的内容,返回纯文本(自动提取正文,去除 HTML 标签)',
|
|
563
|
+
parameters: {
|
|
564
|
+
type: 'object',
|
|
565
|
+
properties: {
|
|
566
|
+
url: { type: 'string', description: '要获取的 URL' },
|
|
567
|
+
maxLength: { type: 'number', description: '最大返回字符数,默认 8000' },
|
|
568
|
+
},
|
|
569
|
+
required: ['url'],
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
permission: 'auto',
|
|
573
|
+
run: runFetch,
|
|
574
|
+
};
|
|
575
|
+
// ── tree 工具 ──
|
|
576
|
+
const IGNORE_DIRS = new Set(['node_modules', '.git', 'dist', '.next', 'coverage', '__pycache__']);
|
|
577
|
+
function treeDir(dir, prefix, maxDepth, showHidden, count) {
|
|
578
|
+
if (maxDepth <= 0 || count.n >= 500)
|
|
579
|
+
return '';
|
|
580
|
+
let entries;
|
|
581
|
+
try {
|
|
582
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
583
|
+
.filter((e) => {
|
|
584
|
+
if (!showHidden && e.name.startsWith('.'))
|
|
585
|
+
return false;
|
|
586
|
+
if (e.isDirectory() && IGNORE_DIRS.has(e.name))
|
|
587
|
+
return false;
|
|
588
|
+
return true;
|
|
589
|
+
})
|
|
590
|
+
.sort((a, b) => {
|
|
591
|
+
if (a.isDirectory() !== b.isDirectory())
|
|
592
|
+
return a.isDirectory() ? -1 : 1;
|
|
593
|
+
return a.name.localeCompare(b.name);
|
|
594
|
+
})
|
|
595
|
+
.map((e) => e.name);
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
return '';
|
|
599
|
+
}
|
|
600
|
+
let result = '';
|
|
601
|
+
for (let i = 0; i < entries.length; i++) {
|
|
602
|
+
if (count.n >= 500) {
|
|
603
|
+
result += `${prefix}...[截断,超过 500 条目]\n`;
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
const isLast = i === entries.length - 1;
|
|
607
|
+
const connector = isLast ? '└── ' : '├── ';
|
|
608
|
+
const childPrefix = prefix + (isLast ? ' ' : '│ ');
|
|
609
|
+
const entry = entries[i];
|
|
610
|
+
count.n++;
|
|
611
|
+
const fullPath = join(dir, entry);
|
|
612
|
+
const isDir = statSync(fullPath).isDirectory();
|
|
613
|
+
result += `${prefix}${connector}${entry}${isDir ? '/' : ''}\n`;
|
|
614
|
+
if (isDir) {
|
|
615
|
+
result += treeDir(fullPath, childPrefix, maxDepth - 1, showHidden, count);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return result;
|
|
619
|
+
}
|
|
620
|
+
const treeTool = {
|
|
621
|
+
schema: {
|
|
622
|
+
name: 'tree',
|
|
623
|
+
description: '以树形结构展示目录内容',
|
|
624
|
+
parameters: {
|
|
625
|
+
type: 'object',
|
|
626
|
+
properties: {
|
|
627
|
+
path: { type: 'string', description: '目录路径(相对于项目根目录),默认 "."' },
|
|
628
|
+
maxDepth: { type: 'number', description: '最大递归深度,默认 3' },
|
|
629
|
+
showHidden: { type: 'boolean', description: '是否显示隐藏文件,默认 false' },
|
|
630
|
+
},
|
|
631
|
+
required: [],
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
permission: 'auto',
|
|
635
|
+
async run(args, ctx) {
|
|
636
|
+
const target = typeof args.path === 'string' ? args.path : '.';
|
|
637
|
+
const maxDepth = typeof args.maxDepth === 'number' ? args.maxDepth : 3;
|
|
638
|
+
const showHidden = args.showHidden === true;
|
|
639
|
+
const safe = safeResolve(ctx.cwd, target);
|
|
640
|
+
if (!safe.ok)
|
|
641
|
+
return { ok: false, output: '', error: safe.error };
|
|
642
|
+
try {
|
|
643
|
+
const stat = statSync(safe.path);
|
|
644
|
+
if (!stat.isDirectory()) {
|
|
645
|
+
return { ok: false, output: '', error: `${target} 不是目录` };
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
catch {
|
|
649
|
+
return { ok: false, output: '', error: `目录 ${target} 不存在` };
|
|
650
|
+
}
|
|
651
|
+
const count = { n: 0 };
|
|
652
|
+
const rootName = target === '.' ? '.' : target;
|
|
653
|
+
let output = `${rootName}/\n`;
|
|
654
|
+
output += treeDir(safe.path, '', maxDepth, showHidden, count);
|
|
655
|
+
if (count.n >= 500) {
|
|
656
|
+
output += `\n[已截断,共 ${count.n}+ 条目]`;
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
output += `\n[${count.n} 个条目]`;
|
|
660
|
+
}
|
|
661
|
+
return { ok: true, output };
|
|
662
|
+
},
|
|
663
|
+
};
|
|
664
|
+
// ── websearch 工具 ──
|
|
665
|
+
function formatWebsearchResults(data) {
|
|
666
|
+
let output = '';
|
|
667
|
+
if (data.answer) {
|
|
668
|
+
output += `摘要: ${data.answer}\n\n`;
|
|
669
|
+
}
|
|
670
|
+
if (data.results && data.results.length > 0) {
|
|
671
|
+
output += '搜索结果:\n';
|
|
672
|
+
for (const r of data.results) {
|
|
673
|
+
output += `- ${r.title}\n ${r.url}\n ${r.content.slice(0, 200)}\n\n`;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return output || '无搜索结果';
|
|
677
|
+
}
|
|
678
|
+
async function runWebsearch(args) {
|
|
679
|
+
const query = typeof args.query === 'string' ? args.query : '';
|
|
680
|
+
const maxResults = typeof args.maxResults === 'number' ? args.maxResults : 5;
|
|
681
|
+
if (!query)
|
|
682
|
+
return { ok: false, output: '', error: 'query 参数不能为空' };
|
|
683
|
+
const apiKey = process.env.HCENT_SEARCH_API_KEY ?? '';
|
|
684
|
+
if (!apiKey) {
|
|
685
|
+
return { ok: false, output: '', error: '搜索 API 未配置(设置 HCENT_SEARCH_API_KEY 环境变量)' };
|
|
686
|
+
}
|
|
687
|
+
try {
|
|
688
|
+
const resp = await fetch('https://api.tavily.com/search', {
|
|
689
|
+
method: 'POST',
|
|
690
|
+
headers: { 'Content-Type': 'application/json' },
|
|
691
|
+
body: JSON.stringify({
|
|
692
|
+
api_key: apiKey,
|
|
693
|
+
query,
|
|
694
|
+
max_results: maxResults,
|
|
695
|
+
include_answer: true,
|
|
696
|
+
}),
|
|
697
|
+
signal: AbortSignal.timeout(15_000),
|
|
698
|
+
});
|
|
699
|
+
if (!resp.ok) {
|
|
700
|
+
return { ok: false, output: '', error: `搜索 API 返回 ${resp.status}` };
|
|
701
|
+
}
|
|
702
|
+
const data = await resp.json();
|
|
703
|
+
return { ok: true, output: formatWebsearchResults(data) };
|
|
704
|
+
}
|
|
705
|
+
catch (err) {
|
|
706
|
+
return { ok: false, output: '', error: err instanceof Error ? err.message : String(err) };
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
const websearchTool = {
|
|
710
|
+
schema: {
|
|
711
|
+
name: 'websearch',
|
|
712
|
+
description: '搜索互联网获取实时信息,返回搜索结果摘要',
|
|
713
|
+
parameters: {
|
|
714
|
+
type: 'object',
|
|
715
|
+
properties: {
|
|
716
|
+
query: { type: 'string', description: '搜索关键词' },
|
|
717
|
+
maxResults: { type: 'number', description: '最大返回结果数,默认 5' },
|
|
718
|
+
},
|
|
719
|
+
required: ['query'],
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
permission: 'auto',
|
|
723
|
+
run: runWebsearch,
|
|
724
|
+
};
|
|
725
|
+
// ── ToolRegistry ──
|
|
726
|
+
const ALL_TOOLS = [lsTool, readTool, globTool, grepTool, writeTool, editTool, bashTool, fetchTool, treeTool, websearchTool];
|
|
727
|
+
export function createToolRegistry() {
|
|
728
|
+
const map = new Map();
|
|
729
|
+
for (const tool of ALL_TOOLS) {
|
|
730
|
+
map.set(tool.schema.name, tool);
|
|
731
|
+
}
|
|
732
|
+
const toolList = [...ALL_TOOLS];
|
|
733
|
+
return {
|
|
734
|
+
list() {
|
|
735
|
+
return toolList;
|
|
736
|
+
},
|
|
737
|
+
schemas() {
|
|
738
|
+
return toolList.map((t) => t.schema);
|
|
739
|
+
},
|
|
740
|
+
get(name) {
|
|
741
|
+
return map.get(name);
|
|
742
|
+
},
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
//# sourceMappingURL=index.js.map
|