@noobdemon/noob-cli 1.10.20 → 1.11.1
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/CHANGELOG.md +476 -0
- package/README.md +113 -27
- package/bin/noob.js +40 -27
- package/package.json +30 -2
- package/src/agent.js +213 -124
- package/src/api.js +126 -52
- package/src/config.js +11 -11
- package/src/i18n.js +171 -148
- package/src/memory.js +24 -13
- package/src/models.js +96 -46
- package/src/prompts/system.md +85 -0
- package/src/repl/complete.js +120 -0
- package/src/repl/todos.js +38 -0
- package/src/repl/ultra.js +62 -0
- package/src/repl/workflow-commands.js +238 -0
- package/src/repl.js +794 -769
- package/src/sessions.js +20 -20
- package/src/skills.js +13 -9
- package/src/subagent.js +3 -3
- package/src/tokens.js +37 -12
- package/src/tools.js +211 -122
- package/src/tui.js +240 -124
- package/src/ui.js +44 -44
- package/src/update.js +21 -21
- package/src/workflows-builtin.js +16 -14
- package/src/workflows.js +29 -27
package/src/tools.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import fs from
|
|
2
|
-
import fssync from
|
|
3
|
-
import path from
|
|
4
|
-
import { spawn } from
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import fssync from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
5
|
|
|
6
6
|
const MAX_OUT = 30000; // hard cap on any tool result fed back to the model
|
|
7
7
|
const cwd = () => process.cwd();
|
|
@@ -18,8 +18,8 @@ const extraRoots = new Set();
|
|
|
18
18
|
export class OutOfScopeError extends Error {
|
|
19
19
|
constructor(p, suggestedRoot) {
|
|
20
20
|
super(`path nằm ngoài phạm vi (cwd + /add-dir): ${p}`);
|
|
21
|
-
this.name =
|
|
22
|
-
this.code =
|
|
21
|
+
this.name = 'OutOfScopeError';
|
|
22
|
+
this.code = 'OUT_OF_SCOPE';
|
|
23
23
|
this.path = p;
|
|
24
24
|
this.suggestedRoot = suggestedRoot;
|
|
25
25
|
}
|
|
@@ -51,14 +51,14 @@ export function nearestExistingDir(p) {
|
|
|
51
51
|
// Lưu NGAY khi addRoot được gọi (cả /add-dir lẫn auto-prompt path), nên user
|
|
52
52
|
// không phải /add-dir lại mỗi lần mở project. Nếu read-only hoặc permission
|
|
53
53
|
// deny → âm thầm bỏ qua (addRoot vẫn áp dụng cho phiên hiện tại).
|
|
54
|
-
const WORKSPACE_DIRS_FILE = () => path.join(cwd(),
|
|
54
|
+
const WORKSPACE_DIRS_FILE = () => path.join(cwd(), '.noob', 'dirs.json');
|
|
55
55
|
function loadWorkspaceRoots() {
|
|
56
56
|
try {
|
|
57
|
-
const raw = fssync.readFileSync(WORKSPACE_DIRS_FILE(),
|
|
57
|
+
const raw = fssync.readFileSync(WORKSPACE_DIRS_FILE(), 'utf8');
|
|
58
58
|
const arr = JSON.parse(raw);
|
|
59
59
|
if (!Array.isArray(arr)) return;
|
|
60
60
|
for (const r of arr) {
|
|
61
|
-
if (typeof r !==
|
|
61
|
+
if (typeof r !== 'string') continue;
|
|
62
62
|
const full = path.resolve(r);
|
|
63
63
|
try {
|
|
64
64
|
if (fssync.statSync(full).isDirectory()) extraRoots.add(full);
|
|
@@ -70,7 +70,7 @@ function saveWorkspaceRoots() {
|
|
|
70
70
|
try {
|
|
71
71
|
const file = WORKSPACE_DIRS_FILE();
|
|
72
72
|
fssync.mkdirSync(path.dirname(file), { recursive: true });
|
|
73
|
-
fssync.writeFileSync(file, JSON.stringify([...extraRoots], null, 2),
|
|
73
|
+
fssync.writeFileSync(file, JSON.stringify([...extraRoots], null, 2), 'utf8');
|
|
74
74
|
return true;
|
|
75
75
|
} catch {
|
|
76
76
|
return false;
|
|
@@ -79,11 +79,15 @@ function saveWorkspaceRoots() {
|
|
|
79
79
|
loadWorkspaceRoots();
|
|
80
80
|
|
|
81
81
|
export function addRoot(p) {
|
|
82
|
-
if (!p) throw new Error(
|
|
82
|
+
if (!p) throw new Error('thiếu path');
|
|
83
83
|
const full = path.resolve(p);
|
|
84
84
|
let st;
|
|
85
|
-
try {
|
|
86
|
-
|
|
85
|
+
try {
|
|
86
|
+
st = fssync.statSync(full);
|
|
87
|
+
} catch {
|
|
88
|
+
throw new Error('không tồn tại: ' + p);
|
|
89
|
+
}
|
|
90
|
+
if (!st.isDirectory()) throw new Error('không phải thư mục: ' + p);
|
|
87
91
|
extraRoots.add(full);
|
|
88
92
|
saveWorkspaceRoots(); // persist per-workspace — lần sau mở project auto-load
|
|
89
93
|
return full;
|
|
@@ -102,7 +106,7 @@ export function listRoots() {
|
|
|
102
106
|
function within(root, full) {
|
|
103
107
|
if (full === root) return true;
|
|
104
108
|
const rel = path.relative(root, full);
|
|
105
|
-
return !!rel && !rel.startsWith(
|
|
109
|
+
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
106
110
|
}
|
|
107
111
|
|
|
108
112
|
const abs = (p) => {
|
|
@@ -125,11 +129,11 @@ const abs = (p) => {
|
|
|
125
129
|
}
|
|
126
130
|
throw new OutOfScopeError(p, nearestExistingDir(p));
|
|
127
131
|
};
|
|
128
|
-
const rel = (p) => path.relative(cwd(), p) ||
|
|
132
|
+
const rel = (p) => path.relative(cwd(), p) || '.';
|
|
129
133
|
// Tên rút gọn để hiển thị: nếu path thuộc cwd → relative cwd; nếu thuộc một
|
|
130
134
|
// extra root → "<rootName>/<rel>" để user phân biệt được; còn lại fallback path tuyệt đối.
|
|
131
135
|
function displayPath(full) {
|
|
132
|
-
if (within(cwd(), full)) return path.relative(cwd(), full) ||
|
|
136
|
+
if (within(cwd(), full)) return path.relative(cwd(), full) || '.';
|
|
133
137
|
for (const r of extraRoots) {
|
|
134
138
|
if (within(r, full)) {
|
|
135
139
|
const sub = path.relative(r, full);
|
|
@@ -139,10 +143,22 @@ function displayPath(full) {
|
|
|
139
143
|
return full;
|
|
140
144
|
}
|
|
141
145
|
function relFrom(root, full) {
|
|
142
|
-
return path.relative(root, full) ||
|
|
146
|
+
return path.relative(root, full) || '.';
|
|
143
147
|
}
|
|
144
148
|
// Thư mục bỏ qua khi walk (glob/grep). node_modules + các thư mục build/cache phổ biến.
|
|
145
|
-
const SKIP_DIRS = new Set([
|
|
149
|
+
const SKIP_DIRS = new Set([
|
|
150
|
+
'node_modules',
|
|
151
|
+
'.next',
|
|
152
|
+
'dist',
|
|
153
|
+
'build',
|
|
154
|
+
'.venv',
|
|
155
|
+
'venv',
|
|
156
|
+
'__pycache__',
|
|
157
|
+
'.cache',
|
|
158
|
+
'.turbo',
|
|
159
|
+
'.parcel-cache',
|
|
160
|
+
'target',
|
|
161
|
+
]);
|
|
146
162
|
|
|
147
163
|
function clip(s) {
|
|
148
164
|
if (s.length <= MAX_OUT) return s;
|
|
@@ -150,7 +166,36 @@ function clip(s) {
|
|
|
150
166
|
}
|
|
151
167
|
|
|
152
168
|
// Tools that mutate the filesystem or run code require user approval.
|
|
153
|
-
export const DESTRUCTIVE = new Set([
|
|
169
|
+
export const DESTRUCTIVE = new Set(['write_file', 'edit_file', 'run_command']);
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Tên các tool model có thể gọi.
|
|
173
|
+
* @typedef {'read_file'|'write_file'|'edit_file'|'list_dir'|'glob'|'grep'|'run_command'|'bg_output'|'kill_bg'} ToolName
|
|
174
|
+
*/
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Input cho mỗi tool (chỉ liệt kê field thực sự dùng — gateway có thể gửi thêm).
|
|
178
|
+
* @typedef {Object} ToolInput
|
|
179
|
+
* @property {string} [path] read_file/write_file/edit_file/list_dir
|
|
180
|
+
* @property {number} [offset] read_file: dòng bắt đầu (1-indexed)
|
|
181
|
+
* @property {number} [limit] read_file: số dòng tối đa
|
|
182
|
+
* @property {string} [content] write_file
|
|
183
|
+
* @property {string} [old_string] edit_file
|
|
184
|
+
* @property {string} [new_string] edit_file
|
|
185
|
+
* @property {boolean} [replace_all] edit_file: thay tất cả thay vì 1 lần
|
|
186
|
+
* @property {string} [pattern] glob/grep
|
|
187
|
+
* @property {string} [glob] grep: filter file theo glob
|
|
188
|
+
* @property {string} [command] run_command
|
|
189
|
+
* @property {number} [timeout] run_command: ms (default 60000)
|
|
190
|
+
* @property {boolean} [background] run_command: spawn ngầm, trả về NGAY
|
|
191
|
+
* @property {number} [id] bg_output/kill_bg
|
|
192
|
+
*/
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Options khi chạy tool.
|
|
196
|
+
* @typedef {Object} ToolRunOpts
|
|
197
|
+
* @property {AbortSignal} [signal] Ctrl+C → tool bail giữa chừng
|
|
198
|
+
*/
|
|
154
199
|
|
|
155
200
|
// ── Tiến trình nền ─────────────────────────────────────────────────────────
|
|
156
201
|
// Lệnh chạy lâu / không bao giờ thoát (dev server, watcher) KHÔNG được chạy ở
|
|
@@ -160,7 +205,8 @@ const bg = new Map(); // id -> { child, command, out, exited, code, startedAt }
|
|
|
160
205
|
let bgSeq = 0;
|
|
161
206
|
function killBgTree(child) {
|
|
162
207
|
try {
|
|
163
|
-
if (process.platform ===
|
|
208
|
+
if (process.platform === 'win32' && child.pid)
|
|
209
|
+
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F']);
|
|
164
210
|
else child.kill();
|
|
165
211
|
} catch {}
|
|
166
212
|
}
|
|
@@ -171,48 +217,56 @@ function killBgTree(child) {
|
|
|
171
217
|
function cleanupBg() {
|
|
172
218
|
for (const p of bg.values()) killBgTree(p.child);
|
|
173
219
|
}
|
|
174
|
-
|
|
175
|
-
process.on("SIGTERM", () => {
|
|
220
|
+
function onSigterm() {
|
|
176
221
|
cleanupBg();
|
|
177
222
|
process.exit(143);
|
|
178
|
-
}
|
|
223
|
+
}
|
|
224
|
+
// Guard chống đăng ký kép: vitest hot-reload / dynamic import có thể re-evaluate
|
|
225
|
+
// module này → nhiều listener → MaxListenersExceededWarning + leak. Chỉ add khi
|
|
226
|
+
// chưa có handler chính xác này.
|
|
227
|
+
if (!process.listeners('exit').includes(cleanupBg)) {
|
|
228
|
+
process.on('exit', cleanupBg);
|
|
229
|
+
}
|
|
230
|
+
if (!process.listeners('SIGTERM').includes(onSigterm)) {
|
|
231
|
+
process.on('SIGTERM', onSigterm);
|
|
232
|
+
}
|
|
179
233
|
|
|
180
234
|
// Helper: throw nếu signal đã abort. Dùng ở đầu mỗi tool + giữa các vòng walk dài
|
|
181
235
|
// để tool fs (glob/grep) cũng phản ứng với Ctrl+C, không chỉ run_command.
|
|
182
236
|
function checkAbort(signal) {
|
|
183
|
-
if (signal?.aborted) throw new Error(
|
|
237
|
+
if (signal?.aborted) throw new Error('aborted');
|
|
184
238
|
}
|
|
185
239
|
|
|
186
240
|
export const TOOLS = {
|
|
187
241
|
async read_file({ path: p, offset, limit }, { signal } = {}) {
|
|
188
242
|
checkAbort(signal);
|
|
189
|
-
const data = await fs.readFile(abs(p),
|
|
190
|
-
let lines = data.split(
|
|
243
|
+
const data = await fs.readFile(abs(p), 'utf8');
|
|
244
|
+
let lines = data.split('\n');
|
|
191
245
|
const start = offset ? Math.max(0, offset - 1) : 0;
|
|
192
246
|
if (offset || limit) lines = lines.slice(start, limit ? start + limit : undefined);
|
|
193
247
|
const width = String(start + lines.length).length;
|
|
194
248
|
return clip(
|
|
195
|
-
lines.map((l, idx) => String(start + idx + 1).padStart(width) +
|
|
249
|
+
lines.map((l, idx) => String(start + idx + 1).padStart(width) + ' ' + l).join('\n')
|
|
196
250
|
);
|
|
197
251
|
},
|
|
198
252
|
|
|
199
253
|
async write_file({ path: p, content }, { signal } = {}) {
|
|
200
254
|
checkAbort(signal);
|
|
201
255
|
await fs.mkdir(path.dirname(abs(p)), { recursive: true });
|
|
202
|
-
await fs.writeFile(abs(p), content ??
|
|
203
|
-
const n = (content ??
|
|
256
|
+
await fs.writeFile(abs(p), content ?? '', 'utf8');
|
|
257
|
+
const n = (content ?? '').split('\n').length;
|
|
204
258
|
return `Wrote ${n} line(s) to ${rel(abs(p))}`;
|
|
205
259
|
},
|
|
206
260
|
|
|
207
261
|
async edit_file({ path: p, old_string, new_string, replace_all }, { signal } = {}) {
|
|
208
262
|
checkAbort(signal);
|
|
209
263
|
const file = abs(p);
|
|
210
|
-
const data = await fs.readFile(file,
|
|
211
|
-
if (old_string === new_string) throw new Error(
|
|
212
|
-
const useCRLF = data.includes(
|
|
264
|
+
const data = await fs.readFile(file, 'utf8');
|
|
265
|
+
if (old_string === new_string) throw new Error('old_string and new_string are identical');
|
|
266
|
+
const useCRLF = data.includes('\r\n');
|
|
213
267
|
const adapt = (s) => {
|
|
214
|
-
const lf = s.replace(/\r\n/g,
|
|
215
|
-
return useCRLF ? lf.replace(/\n/g,
|
|
268
|
+
const lf = s.replace(/\r\n/g, '\n');
|
|
269
|
+
return useCRLF ? lf.replace(/\n/g, '\r\n') : lf;
|
|
216
270
|
};
|
|
217
271
|
// Thay không diễn giải $&/$1… (split/slice, không dùng String.replace).
|
|
218
272
|
const applyExact = (cand, newS) => {
|
|
@@ -222,14 +276,18 @@ export const TOOLS = {
|
|
|
222
276
|
};
|
|
223
277
|
|
|
224
278
|
// Tier 1 + 2: khớp NGUYÊN VĂN, rồi khớp sau khi chỉnh CRLF cho hợp file.
|
|
225
|
-
for (const cand of old_string === adapt(old_string)
|
|
279
|
+
for (const cand of old_string === adapt(old_string)
|
|
280
|
+
? [old_string]
|
|
281
|
+
: [old_string, adapt(old_string)]) {
|
|
226
282
|
const count = data.split(cand).length - 1;
|
|
227
283
|
if (count === 0) continue;
|
|
228
284
|
if (count > 1 && !replace_all)
|
|
229
|
-
throw new Error(
|
|
285
|
+
throw new Error(
|
|
286
|
+
`old_string is not unique (${count} matches) in ${rel(file)}; set replace_all, or add surrounding lines to make it unique`
|
|
287
|
+
);
|
|
230
288
|
// LUÔN adapt new_string về line ending của file (kể cả khi cand match raw old_string),
|
|
231
289
|
// tránh tạo file mix CRLF/LF làm git/editor hiển thị diff lạ → user tưởng 'Edited nhưng không apply'.
|
|
232
|
-
await fs.writeFile(file, applyExact(cand, adapt(new_string)),
|
|
290
|
+
await fs.writeFile(file, applyExact(cand, adapt(new_string)), 'utf8');
|
|
233
291
|
return `Edited ${rel(file)} (${replace_all ? count : 1} replacement(s))`;
|
|
234
292
|
}
|
|
235
293
|
|
|
@@ -237,29 +295,33 @@ export const TOOLS = {
|
|
|
237
295
|
// (Thụt đầu dòng vẫn phải khớp → không bao giờ sửa nhầm chỗ.)
|
|
238
296
|
const m = matchByLines(data, old_string);
|
|
239
297
|
if (m && m.count > 1 && !replace_all)
|
|
240
|
-
throw new Error(
|
|
298
|
+
throw new Error(
|
|
299
|
+
`old_string khớp ${m.count} chỗ (sau khi bỏ qua khoảng trắng cuối dòng) trong ${rel(file)}; thêm dòng ngữ cảnh hoặc dùng replace_all`
|
|
300
|
+
);
|
|
241
301
|
if (m && m.count === 1) {
|
|
242
302
|
const next = data.slice(0, m.start) + adapt(new_string) + data.slice(m.end);
|
|
243
|
-
await fs.writeFile(file, next,
|
|
303
|
+
await fs.writeFile(file, next, 'utf8');
|
|
244
304
|
return `Edited ${rel(file)} (1 replacement(s))`;
|
|
245
305
|
}
|
|
246
306
|
|
|
247
307
|
// Không thấy → lỗi GIÀU THÔNG TIN: cho model thấy đúng byte trong file để sửa.
|
|
248
308
|
throw new Error(
|
|
249
309
|
`old_string not found in ${rel(file)}. Copy the target text EXACTLY (indentation/whitespace included, NO line-number prefix).` +
|
|
250
|
-
nearbyContext(data, old_string)
|
|
310
|
+
nearbyContext(data, old_string)
|
|
251
311
|
);
|
|
252
312
|
},
|
|
253
313
|
|
|
254
|
-
async list_dir({ path: p =
|
|
314
|
+
async list_dir({ path: p = '.' }, { signal } = {}) {
|
|
255
315
|
checkAbort(signal);
|
|
256
316
|
const dir = abs(p);
|
|
257
317
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
258
318
|
const rows = entries
|
|
259
|
-
.filter((e) => !e.name.startsWith(
|
|
260
|
-
.sort(
|
|
261
|
-
|
|
262
|
-
|
|
319
|
+
.filter((e) => !e.name.startsWith('.git') && e.name !== 'node_modules')
|
|
320
|
+
.sort(
|
|
321
|
+
(a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name)
|
|
322
|
+
)
|
|
323
|
+
.map((e) => (e.isDirectory() ? e.name + '/' : e.name));
|
|
324
|
+
return clip(`${rel(dir)}/ (${rows.length} entries)\n` + rows.map((r) => ' ' + r).join('\n'));
|
|
263
325
|
},
|
|
264
326
|
|
|
265
327
|
async glob({ pattern }, { signal } = {}) {
|
|
@@ -279,38 +341,40 @@ export const TOOLS = {
|
|
|
279
341
|
}
|
|
280
342
|
for (const e of ents) {
|
|
281
343
|
if ((++tickCounter & 0xff) === 0) checkAbort(signal);
|
|
282
|
-
if (SKIP_DIRS.has(e.name) || e.name.startsWith(
|
|
344
|
+
if (SKIP_DIRS.has(e.name) || e.name.startsWith('.git')) continue;
|
|
283
345
|
const full = path.join(dir, e.name);
|
|
284
346
|
if (e.isDirectory()) walk(full);
|
|
285
|
-
else if (rx.test(relFrom(root, full).split(path.sep).join(
|
|
347
|
+
else if (rx.test(relFrom(root, full).split(path.sep).join('/')))
|
|
348
|
+
hits.push(displayPath(full));
|
|
286
349
|
if (hits.length > 500) return;
|
|
287
350
|
}
|
|
288
351
|
})(root);
|
|
289
352
|
if (hits.length > 500) break;
|
|
290
353
|
}
|
|
291
|
-
return hits.length ? clip(hits.join(
|
|
354
|
+
return hits.length ? clip(hits.join('\n')) : 'No files matched.';
|
|
292
355
|
},
|
|
293
356
|
|
|
294
357
|
async grep({ pattern, path: p, glob: g }, { signal } = {}) {
|
|
295
358
|
checkAbort(signal);
|
|
296
|
-
const rx = new RegExp(pattern,
|
|
359
|
+
const rx = new RegExp(pattern, 'i');
|
|
297
360
|
const gRx = g ? globToRegExp(g) : null;
|
|
298
361
|
const out = [];
|
|
299
362
|
let tickCounter = 0;
|
|
300
363
|
function scanFile(full) {
|
|
301
364
|
if ((++tickCounter & 0xff) === 0) checkAbort(signal);
|
|
302
365
|
const disp = displayPath(full);
|
|
303
|
-
const relp = disp.split(path.sep).join(
|
|
366
|
+
const relp = disp.split(path.sep).join('/');
|
|
304
367
|
if (gRx && !gRx.test(relp)) return;
|
|
305
368
|
let txt;
|
|
306
369
|
try {
|
|
307
|
-
txt = fssync.readFileSync(full,
|
|
370
|
+
txt = fssync.readFileSync(full, 'utf8');
|
|
308
371
|
} catch {
|
|
309
372
|
return;
|
|
310
373
|
}
|
|
311
|
-
if (txt.includes(
|
|
312
|
-
txt.split(
|
|
313
|
-
if (rx.test(l) && out.length < 200)
|
|
374
|
+
if (txt.includes('\u0000')) return; // skip binary files
|
|
375
|
+
txt.split('\n').forEach((l, idx) => {
|
|
376
|
+
if (rx.test(l) && out.length < 200)
|
|
377
|
+
out.push(`${relp}:${idx + 1}: ${l.trim().slice(0, 200)}`);
|
|
314
378
|
});
|
|
315
379
|
}
|
|
316
380
|
function walkDir(dir) {
|
|
@@ -321,7 +385,7 @@ export const TOOLS = {
|
|
|
321
385
|
return;
|
|
322
386
|
}
|
|
323
387
|
for (const e of ents) {
|
|
324
|
-
if (SKIP_DIRS.has(e.name) || e.name.startsWith(
|
|
388
|
+
if (SKIP_DIRS.has(e.name) || e.name.startsWith('.git')) continue;
|
|
325
389
|
const full = path.join(dir, e.name);
|
|
326
390
|
if (e.isDirectory()) {
|
|
327
391
|
walkDir(full);
|
|
@@ -331,25 +395,25 @@ export const TOOLS = {
|
|
|
331
395
|
}
|
|
332
396
|
}
|
|
333
397
|
// Không truyền path → quét cwd + tất cả extra roots. Có path → chỉ vùng đó.
|
|
334
|
-
if (p == null || p ===
|
|
398
|
+
if (p == null || p === '' || p === '.') {
|
|
335
399
|
for (const root of listRoots()) walkDir(root);
|
|
336
400
|
} else {
|
|
337
401
|
let st;
|
|
338
402
|
try {
|
|
339
403
|
st = fssync.statSync(abs(p));
|
|
340
404
|
} catch {
|
|
341
|
-
return
|
|
405
|
+
return 'No matches.';
|
|
342
406
|
}
|
|
343
407
|
if (st.isFile()) scanFile(abs(p));
|
|
344
408
|
else walkDir(abs(p));
|
|
345
409
|
}
|
|
346
|
-
return out.length ? clip(out.join(
|
|
410
|
+
return out.length ? clip(out.join('\n')) : 'No matches.';
|
|
347
411
|
},
|
|
348
412
|
|
|
349
413
|
run_command({ command, timeout = 60000, background = false }, { signal } = {}) {
|
|
350
|
-
const isWin = process.platform ===
|
|
351
|
-
const shell = isWin ?
|
|
352
|
-
const args = isWin ? [
|
|
414
|
+
const isWin = process.platform === 'win32';
|
|
415
|
+
const shell = isWin ? 'powershell.exe' : '/bin/bash';
|
|
416
|
+
const args = isWin ? ['-NoProfile', '-NonInteractive', '-Command', command] : ['-c', command];
|
|
353
417
|
// stdin: "ignore" — tiến trình con KHÔNG được chạm vào console/stdin của
|
|
354
418
|
// CLI. Nếu để con kế thừa stdin, trên Windows nó có thể làm readline phát
|
|
355
419
|
// 'close' → CLI tự tắt. Đóng hẳn stdin con để tránh hoàn toàn.
|
|
@@ -358,32 +422,32 @@ export const TOOLS = {
|
|
|
358
422
|
// bằng bg_output, dừng bằng kill_bg.
|
|
359
423
|
if (background) {
|
|
360
424
|
const id = ++bgSeq;
|
|
361
|
-
const child = spawn(shell, args, { cwd: cwd(), stdio: [
|
|
362
|
-
const proc = { child, command, out:
|
|
425
|
+
const child = spawn(shell, args, { cwd: cwd(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
426
|
+
const proc = { child, command, out: '', exited: false, code: null, startedAt: Date.now() };
|
|
363
427
|
const cap = (d) => {
|
|
364
428
|
proc.out += d;
|
|
365
429
|
if (proc.out.length > MAX_OUT * 2) proc.out = proc.out.slice(-MAX_OUT * 2); // giữ phần mới nhất
|
|
366
430
|
};
|
|
367
|
-
child.stdout.on(
|
|
368
|
-
child.stderr.on(
|
|
369
|
-
child.on(
|
|
431
|
+
child.stdout.on('data', cap);
|
|
432
|
+
child.stderr.on('data', cap);
|
|
433
|
+
child.on('error', (e) => {
|
|
370
434
|
proc.exited = true;
|
|
371
435
|
proc.out += `\n[failed to start: ${e.message}]`;
|
|
372
436
|
});
|
|
373
|
-
child.on(
|
|
437
|
+
child.on('close', (code) => {
|
|
374
438
|
proc.exited = true;
|
|
375
439
|
proc.code = code;
|
|
376
440
|
});
|
|
377
441
|
bg.set(id, proc);
|
|
378
442
|
return Promise.resolve(
|
|
379
|
-
`Started background process #${id} (pid ${child.pid ??
|
|
380
|
-
`It keeps running and does NOT block further steps. Read its output with bg_output {"id": ${id}}, stop it with kill_bg {"id": ${id}}
|
|
443
|
+
`Started background process #${id} (pid ${child.pid ?? '?'}): ${command}\n` +
|
|
444
|
+
`It keeps running and does NOT block further steps. Read its output with bg_output {"id": ${id}}, stop it with kill_bg {"id": ${id}}.`
|
|
381
445
|
);
|
|
382
446
|
}
|
|
383
447
|
|
|
384
448
|
return new Promise((resolve) => {
|
|
385
|
-
const child = spawn(shell, args, { cwd: cwd(), stdio: [
|
|
386
|
-
let out =
|
|
449
|
+
const child = spawn(shell, args, { cwd: cwd(), stdio: ['ignore', 'pipe', 'pipe'] });
|
|
450
|
+
let out = '';
|
|
387
451
|
let timedOut = false;
|
|
388
452
|
let aborted = false;
|
|
389
453
|
const killer = setTimeout(() => {
|
|
@@ -398,24 +462,24 @@ export const TOOLS = {
|
|
|
398
462
|
};
|
|
399
463
|
if (signal) {
|
|
400
464
|
if (signal.aborted) onAbort();
|
|
401
|
-
else signal.addEventListener(
|
|
465
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
402
466
|
}
|
|
403
|
-
child.stdout.on(
|
|
404
|
-
child.stderr.on(
|
|
405
|
-
child.on(
|
|
467
|
+
child.stdout.on('data', (d) => (out += d));
|
|
468
|
+
child.stderr.on('data', (d) => (out += d));
|
|
469
|
+
child.on('error', (e) => {
|
|
406
470
|
clearTimeout(killer);
|
|
407
|
-
signal?.removeEventListener?.(
|
|
471
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
408
472
|
resolve(`Failed to start command: ${e.message}`);
|
|
409
473
|
});
|
|
410
|
-
child.on(
|
|
474
|
+
child.on('close', (code) => {
|
|
411
475
|
clearTimeout(killer);
|
|
412
|
-
signal?.removeEventListener?.(
|
|
476
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
413
477
|
const tail = aborted
|
|
414
478
|
? `\n[aborted by user (Ctrl+C) — killed.]`
|
|
415
479
|
: timedOut
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
resolve(clip((out.trim() ||
|
|
480
|
+
? `\n[timed out after ${Math.round(timeout / 1000)}s — killed. If this is a server or other long-running task, re-run with {"background": true} instead.]`
|
|
481
|
+
: `\n[exit code ${code}]`;
|
|
482
|
+
resolve(clip((out.trim() || '(no output)') + tail));
|
|
419
483
|
});
|
|
420
484
|
});
|
|
421
485
|
},
|
|
@@ -423,15 +487,20 @@ export const TOOLS = {
|
|
|
423
487
|
// Đọc trạng thái + output đã gom của tiến trình nền. Không có id → liệt kê tất cả.
|
|
424
488
|
bg_output({ id } = {}) {
|
|
425
489
|
if (id == null) {
|
|
426
|
-
if (!bg.size) return
|
|
490
|
+
if (!bg.size) return 'No background processes.';
|
|
427
491
|
return [...bg.entries()]
|
|
428
|
-
.map(
|
|
429
|
-
|
|
492
|
+
.map(
|
|
493
|
+
([i, p]) =>
|
|
494
|
+
`#${i} ${p.exited ? `exited(code ${p.code})` : `running(pid ${p.child.pid})`} — ${p.command}`
|
|
495
|
+
)
|
|
496
|
+
.join('\n');
|
|
430
497
|
}
|
|
431
498
|
const p = bg.get(id);
|
|
432
499
|
if (!p) return `No background process #${id}.`;
|
|
433
|
-
const status = p.exited
|
|
434
|
-
|
|
500
|
+
const status = p.exited
|
|
501
|
+
? `exited (code ${p.code})`
|
|
502
|
+
: `running (pid ${p.child.pid}, ${Math.round((Date.now() - p.startedAt) / 1000)}s)`;
|
|
503
|
+
return clip(`#${id} ${status} — ${p.command}\n${p.out.trim() || '(no output yet)'}`);
|
|
435
504
|
},
|
|
436
505
|
|
|
437
506
|
// Dừng một tiến trình nền (kèm cây con trên Windows).
|
|
@@ -448,9 +517,9 @@ export const TOOLS = {
|
|
|
448
517
|
// dòng. Trả {count, start, end} (offset ký tự trong data) khi khớp đúng 1 chỗ,
|
|
449
518
|
// {count>1} khi nhập nhằng, hoặc null khi không thấy.
|
|
450
519
|
function matchByLines(data, oldStr) {
|
|
451
|
-
const trim = (l) => l.replace(/\r$/,
|
|
452
|
-
const fileLines = data.split(
|
|
453
|
-
const oldLines = oldStr.replace(/\r\n/g,
|
|
520
|
+
const trim = (l) => l.replace(/\r$/, '').replace(/[ \t]+$/, '');
|
|
521
|
+
const fileLines = data.split('\n');
|
|
522
|
+
const oldLines = oldStr.replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n');
|
|
454
523
|
if (!oldLines.length) return null;
|
|
455
524
|
const nFile = fileLines.map(trim);
|
|
456
525
|
const nOld = oldLines.map(trim);
|
|
@@ -480,9 +549,14 @@ function matchByLines(data, oldStr) {
|
|
|
480
549
|
// Khi không khớp: in vùng file gần dòng giống nhất, dạng JSON-escaped để model
|
|
481
550
|
// thấy rõ tab/space → sửa old_string cho khớp ngay lần sau.
|
|
482
551
|
function nearbyContext(data, oldStr) {
|
|
483
|
-
const want = (
|
|
484
|
-
|
|
485
|
-
|
|
552
|
+
const want = (
|
|
553
|
+
oldStr
|
|
554
|
+
.replace(/\r\n/g, '\n')
|
|
555
|
+
.split('\n')
|
|
556
|
+
.find((l) => l.trim()) || ''
|
|
557
|
+
).trim();
|
|
558
|
+
if (!want) return '';
|
|
559
|
+
const lines = data.replace(/\r\n/g, '\n').split('\n');
|
|
486
560
|
// Dòng giống nhất = tiền tố chung dài nhất với `want` (sau khi trim).
|
|
487
561
|
const commonPrefix = (a, b) => {
|
|
488
562
|
let i = 0;
|
|
@@ -499,68 +573,83 @@ function nearbyContext(data, oldStr) {
|
|
|
499
573
|
hit = i;
|
|
500
574
|
}
|
|
501
575
|
}
|
|
502
|
-
if (hit < 0 || best < 6)
|
|
576
|
+
if (hit < 0 || best < 6)
|
|
577
|
+
return ` (no similar line found; the file has ${lines.length} lines — re-read it.)`;
|
|
503
578
|
const a = Math.max(0, hit - 2);
|
|
504
579
|
const b = Math.min(lines.length, hit + 3);
|
|
505
580
|
const snippet = lines
|
|
506
581
|
.slice(a, b)
|
|
507
582
|
.map((l, k) => ` ${a + k + 1}: ${JSON.stringify(l)}`)
|
|
508
|
-
.join(
|
|
583
|
+
.join('\n');
|
|
509
584
|
return `\nActual file lines near the closest match (JSON-escaped — match these bytes EXACTLY):\n${snippet}`;
|
|
510
585
|
}
|
|
511
586
|
|
|
512
587
|
function globToRegExp(glob) {
|
|
513
|
-
let rx =
|
|
588
|
+
let rx = '';
|
|
514
589
|
for (let i = 0; i < glob.length; i++) {
|
|
515
590
|
const ch = glob[i];
|
|
516
|
-
if (ch ===
|
|
517
|
-
if (glob[i + 1] ===
|
|
518
|
-
rx +=
|
|
591
|
+
if (ch === '*') {
|
|
592
|
+
if (glob[i + 1] === '*') {
|
|
593
|
+
rx += '.*';
|
|
519
594
|
i++;
|
|
520
|
-
if (glob[i + 1] ===
|
|
521
|
-
} else rx +=
|
|
522
|
-
} else if (ch ===
|
|
523
|
-
else if (
|
|
595
|
+
if (glob[i + 1] === '/') i++;
|
|
596
|
+
} else rx += '[^/]*';
|
|
597
|
+
} else if (ch === '?') rx += '[^/]';
|
|
598
|
+
else if ('.+^${}()|[]\\'.includes(ch)) rx += '\\' + ch;
|
|
524
599
|
else rx += ch;
|
|
525
600
|
}
|
|
526
|
-
return new RegExp(
|
|
601
|
+
return new RegExp('^' + rx + '$');
|
|
527
602
|
}
|
|
528
603
|
|
|
529
|
-
|
|
604
|
+
/**
|
|
605
|
+
* Mô tả 1 dòng tool call cho permission prompt / activity log.
|
|
606
|
+
* @param {ToolName} name
|
|
607
|
+
* @param {ToolInput} input
|
|
608
|
+
* @returns {string}
|
|
609
|
+
*/
|
|
530
610
|
export function describe(name, input) {
|
|
531
611
|
switch (name) {
|
|
532
|
-
case
|
|
612
|
+
case 'read_file':
|
|
533
613
|
return `read ${input.path}`;
|
|
534
|
-
case
|
|
535
|
-
return `write ${input.path} (${(input.content ??
|
|
536
|
-
case
|
|
614
|
+
case 'write_file':
|
|
615
|
+
return `write ${input.path} (${(input.content ?? '').split('\n').length} lines)`;
|
|
616
|
+
case 'edit_file':
|
|
537
617
|
return `edit ${input.path}`;
|
|
538
|
-
case
|
|
539
|
-
return `ls ${input.path ||
|
|
540
|
-
case
|
|
618
|
+
case 'list_dir':
|
|
619
|
+
return `ls ${input.path || '.'}`;
|
|
620
|
+
case 'glob':
|
|
541
621
|
return `glob ${input.pattern}`;
|
|
542
|
-
case
|
|
543
|
-
return `grep "${input.pattern}"${input.path ?
|
|
544
|
-
case
|
|
545
|
-
return (input.background ?
|
|
546
|
-
case
|
|
547
|
-
return input.id != null ? `xem tiến trình nền #${input.id}` :
|
|
548
|
-
case
|
|
622
|
+
case 'grep':
|
|
623
|
+
return `grep "${input.pattern}"${input.path ? ' in ' + input.path : ''}`;
|
|
624
|
+
case 'run_command':
|
|
625
|
+
return (input.background ? '$ (nền) ' : '$ ') + input.command;
|
|
626
|
+
case 'bg_output':
|
|
627
|
+
return input.id != null ? `xem tiến trình nền #${input.id}` : 'liệt kê tiến trình nền';
|
|
628
|
+
case 'kill_bg':
|
|
549
629
|
return `dừng tiến trình nền #${input.id}`;
|
|
550
|
-
case
|
|
551
|
-
return `↳ sub-agent: ${String(input.task ||
|
|
552
|
-
case
|
|
630
|
+
case 'spawn_agent':
|
|
631
|
+
return `↳ sub-agent: ${String(input.task || '').slice(0, 80)}`;
|
|
632
|
+
case 'spawn_agents':
|
|
553
633
|
return `↳ ${(input.tasks || []).length} sub-agent song song`;
|
|
554
634
|
default:
|
|
555
635
|
return name;
|
|
556
636
|
}
|
|
557
637
|
}
|
|
558
638
|
|
|
639
|
+
/**
|
|
640
|
+
* Chạy 1 tool theo tên. Ném `Error('Unknown tool: ...')` nếu name không tồn tại,
|
|
641
|
+
* ném `Error('aborted')` nếu signal đã abort trước/giữa khi chạy, hoặc
|
|
642
|
+
* `OutOfScopeError` nếu path nằm ngoài cwd + extraRoots.
|
|
643
|
+
* @param {ToolName} name
|
|
644
|
+
* @param {ToolInput} input
|
|
645
|
+
* @param {ToolRunOpts} [opts]
|
|
646
|
+
* @returns {Promise<string>} Kết quả text (đã clip ở `MAX_OUT`).
|
|
647
|
+
*/
|
|
559
648
|
export async function runTool(name, input, opts = {}) {
|
|
560
649
|
const fn = TOOLS[name];
|
|
561
650
|
if (!fn) throw new Error(`Unknown tool: ${name}`);
|
|
562
651
|
const { signal } = opts;
|
|
563
652
|
// Pre-check: nếu user đã Ctrl+C trước khi tool kịp chạy, bail ngay.
|
|
564
|
-
if (signal?.aborted) throw new Error(
|
|
653
|
+
if (signal?.aborted) throw new Error('aborted');
|
|
565
654
|
return await fn(input || {}, { signal });
|
|
566
655
|
}
|