@noobdemon/noob-cli 1.7.7 → 1.7.10
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/package.json +1 -1
- package/skills/dynamic-workflows/SKILL.md +154 -0
- package/src/agent.js +101 -24
- package/src/api.js +157 -16
- package/src/i18n.js +35 -0
- package/src/models.js +14 -1
- package/src/repl.js +338 -9
- package/src/subagent.js +112 -53
- package/src/tokens.js +16 -0
- package/src/tools.js +46 -11
- package/src/workflows.js +142 -0
package/src/subagent.js
CHANGED
|
@@ -1,67 +1,126 @@
|
|
|
1
|
-
// Sub-agent: chạy một runAgent() con với history
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
// Sub-agent: chạy một runAgent() con với history riêng, dùng chung dispatcher tool
|
|
2
|
+
// của cha. Cha (repl.js) truyền `dispatchTool` vào — cha là source of truth cho:
|
|
3
|
+
// - permission/approve UI
|
|
4
|
+
// - in log tool-call
|
|
5
|
+
// - xử lý spawn_agent lồng nhau (cha tự tăng depth khi forward xuống)
|
|
6
|
+
//
|
|
7
|
+
// Contract với cha (xem repl.js dispatcher spawn_agent/spawn_agents):
|
|
8
|
+
// Cha gọi: runSubAgent({ task, context, model, signal, tokenMeter, dispatchTool, depth, onLog })
|
|
9
|
+
// - dispatchTool(name, input) → Promise<{ allow, result }> (cùng format runAgent.onTool kỳ vọng)
|
|
10
|
+
// - tokenMeter: cha truyền meter của phiên → token sub-agent cộng dồn vào tổng
|
|
11
|
+
// - signal: cha truyền abort.signal → cha Ctrl+C thì con dừng theo
|
|
12
|
+
// - depth: cha tăng sẵn (depth+1) trước khi gọi; sub-agent chỉ dùng để biết còn được spawn cháu hay không
|
|
13
|
+
//
|
|
14
|
+
// Trả về: Promise<string> — text cuối của sub-agent, để cha ghép vào tool result.
|
|
15
|
+
//
|
|
16
|
+
// Depth guard: MAX_SUBAGENT_DEPTH = 3. Khi depth >= MAX, sub-agent vẫn chạy nhưng
|
|
17
|
+
// extraToolsDoc rỗng → model không thấy spawn_agent nữa, không spawn cháu được.
|
|
18
|
+
// Cha cũng có guard riêng (line ~1001 repl.js) chặn spawn vượt MAX, đây là defence-in-depth.
|
|
19
|
+
|
|
20
|
+
import { runAgent } from './agent.js';
|
|
6
21
|
|
|
7
22
|
export const MAX_SUBAGENT_DEPTH = 3;
|
|
8
23
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
24
|
+
export async function runSubAgent({
|
|
25
|
+
task,
|
|
26
|
+
context = '',
|
|
27
|
+
model,
|
|
28
|
+
signal,
|
|
29
|
+
tokenMeter,
|
|
30
|
+
dispatchTool,
|
|
31
|
+
depth = 1,
|
|
32
|
+
onLog,
|
|
33
|
+
}) {
|
|
34
|
+
if (typeof dispatchTool !== 'function') {
|
|
35
|
+
throw new Error('runSubAgent: dispatchTool (từ cha) là bắt buộc');
|
|
36
|
+
}
|
|
37
|
+
if (!task || typeof task !== 'string') {
|
|
38
|
+
throw new Error('runSubAgent: task (string) là bắt buộc');
|
|
39
|
+
}
|
|
14
40
|
|
|
15
|
-
|
|
41
|
+
// Prompt khởi tạo: nêu rõ task + context (nếu có). Sub-agent có history riêng,
|
|
42
|
+
// KHÔNG kế thừa từ cha → giảm nhiễu, tiết kiệm token.
|
|
43
|
+
const userPrompt = context
|
|
44
|
+
? `# Nhiệm vụ con\n${task}\n\n# Ngữ cảnh từ agent cha\n${context}`
|
|
45
|
+
: `# Nhiệm vụ con\n${task}`;
|
|
16
46
|
|
|
17
|
-
|
|
18
|
-
- spawn_agents {"tasks": [{"task": str, "context"?: str}, …]} — đẻ NHIỀU sub-agent CHẠY SONG SONG. Chỉ dùng khi các task ĐỘC LẬP (không phụ thuộc kết quả của nhau). Trả về mảng tóm tắt theo đúng thứ tự tasks.
|
|
47
|
+
const history = [{ role: 'user', content: userPrompt }];
|
|
19
48
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
3. PHÂN CẤP (task phức tạp): sub-agent của bạn cũng có spawn_agent, nó tự chia tiếp. Độ sâu tối đa hiện tại: ${MAX_SUBAGENT_DEPTH} (bạn đang ở depth=${depth}${canSpawn ? "" : " — đã chạm trần, KHÔNG được spawn nữa, tự làm"}).
|
|
24
|
-
4. Việc NHỎ/đơn giản: cứ tự làm, đừng spawn cho có. Spawn có overhead (mỗi sub-agent là 1 phiên model riêng → tốn token).
|
|
25
|
-
5. Sau khi gom kết quả từ sub-agent, BẠN là người tổng hợp + trả lời cuối cho user. Sub-agent không nói chuyện trực tiếp với user.
|
|
49
|
+
// Nếu chưa chạm trần depth, cho phép spawn cháu. Chạm trần → bỏ doc.
|
|
50
|
+
const extraToolsDoc =
|
|
51
|
+
depth < MAX_SUBAGENT_DEPTH ? spawnAgentToolsDoc(depth) : '';
|
|
26
52
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
53
|
+
// onTool: forward thẳng dispatcher cha. Cha sẽ tự tăng depth khi spawn lồng
|
|
54
|
+
// (xem repl.js line 1027: `(n, inp) => dispatchTool(n, inp, depth + 1)`).
|
|
55
|
+
const onTool = async (name, input) => dispatchTool(name, input);
|
|
56
|
+
|
|
57
|
+
if (onLog) onLog(`▶ sub-agent depth=${depth} bắt đầu: ${task.slice(0, 80)}${task.length > 80 ? '…' : ''}`);
|
|
31
58
|
|
|
32
|
-
// Chạy một sub-agent. dispatchTool: hàm để thực thi tool con (chia sẻ với cha).
|
|
33
|
-
// model: dùng chung model của cha. onLog: callback để log tiến độ ra UI cha.
|
|
34
|
-
export async function runSubAgent({ task, context, model, signal, dispatchTool, depth = 1, onLog, tokenMeter }) {
|
|
35
|
-
const sys = `Bạn là SUB-AGENT (depth=${depth}) được agent cha ủy thác MỘT nhiệm vụ cụ thể.
|
|
36
|
-
|
|
37
|
-
# Cách làm việc
|
|
38
|
-
- Tự quyết với thông tin được cấp + tự khám phá filesystem (list_dir/glob/grep/read_file). KHÔNG hỏi lại cha.
|
|
39
|
-
- History của bạn TÁCH BIỆT với cha. Cha CHỈ thấy chuỗi trả lời cuối của bạn → hãy là một bản tóm tắt cô đọng (mục tiêu 1–2k token): mọi file đã đụng, phát hiện then chốt, lỗi/cảnh báo, và các đầu mối cha cần để hành động tiếp. Bỏ chi tiết quá trình thừa.
|
|
40
|
-
- Làm điều nhỏ nhất giải quyết trọn vẹn nhiệm vụ. Không drive-by refactor.
|
|
41
|
-
- Verify khi hợp lý (chạy build/test/lint). Báo trung thực phần đã/chưa verify.
|
|
42
|
-
|
|
43
|
-
# NHIỆM VỤ
|
|
44
|
-
${task}
|
|
45
|
-
${context ? `\n# NGỮ CẢNH TỪ CHA\n${context}` : ""}`;
|
|
46
|
-
const history = [{ role: "user", content: sys }];
|
|
47
|
-
// Dùng chung meter của cha nếu được truyền vào → token sub-agent cộng dồn
|
|
48
|
-
// vào tổng phiên. Nếu không có thì tự tạo cục bộ (giữ tương thích cũ).
|
|
49
|
-
const meter = tokenMeter || new TokenMeter();
|
|
50
|
-
const before = { input: meter.input, output: meter.output };
|
|
51
|
-
onLog?.(`↳ sub-agent (depth=${depth}) bắt đầu: ${task.slice(0, 80)}${task.length > 80 ? "…" : ""}`);
|
|
52
59
|
const result = await runAgent({
|
|
53
60
|
history,
|
|
54
61
|
model,
|
|
55
62
|
signal,
|
|
56
|
-
tokenMeter
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
onDelta: () => {},
|
|
61
|
-
onSteer: () => [],
|
|
63
|
+
tokenMeter,
|
|
64
|
+
onTool,
|
|
65
|
+
extraToolsDoc,
|
|
66
|
+
goal: task,
|
|
62
67
|
});
|
|
63
|
-
|
|
64
|
-
onLog
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
|
|
69
|
+
if (onLog) onLog(`✓ sub-agent depth=${depth} xong`);
|
|
70
|
+
|
|
71
|
+
// runAgent trả về text cuối (assistant message không có tool block).
|
|
72
|
+
return typeof result === 'string' ? result : String(result ?? '');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Tài liệu tool spawn_agent / spawn_agents để chèn vào prompt khi agent mode bật.
|
|
76
|
+
// Dài & cụ thể vì model cần đủ context để dùng đúng (routing, depth, format).
|
|
77
|
+
export function spawnAgentToolsDoc(depth = 0) {
|
|
78
|
+
const remaining = MAX_SUBAGENT_DEPTH - depth;
|
|
79
|
+
if (remaining <= 0) return '';
|
|
80
|
+
return `
|
|
81
|
+
# Sub-agent tools (agent mode đang BẬT, depth=${depth}, MAX_SUBAGENT_DEPTH=${MAX_SUBAGENT_DEPTH})
|
|
82
|
+
|
|
83
|
+
Khi nhiệm vụ phức tạp / chia được thành nhiều phần độc lập, bạn có thể spawn sub-agent để xử lý song song hoặc cô lập ngữ cảnh. Sub-agent có history riêng (không kế thừa từ cha → giảm nhiễu + tiết kiệm token), dùng chung token meter + signal abort với cha.
|
|
84
|
+
|
|
85
|
+
## Tools
|
|
86
|
+
- spawn_agent {"task": str, "context"?: str, "model"?: str}
|
|
87
|
+
Chạy 1 sub-agent độc lập, trả về kết quả dạng string (text cuối của sub-agent).
|
|
88
|
+
- spawn_agents {"agents": [{"task": str, "context"?: str, "model"?: str}, ...]}
|
|
89
|
+
Chạy NHIỀU sub-agent SONG SONG (Promise.all). Trả về mảng kết quả ghép theo thứ tự, mỗi phần có header "── sub-agent #N ──".
|
|
90
|
+
|
|
91
|
+
## Rules
|
|
92
|
+
1. TASK PHẢI CỤ THỂ: nêu rõ goal + output mong đợi (vd "đọc src/api.js, liệt kê tất cả endpoint + method, trả về dạng bảng markdown"). Đừng giao task mơ hồ kiểu "phân tích code".
|
|
93
|
+
2. CONTEXT TRUYỀN GỌN: chỉ trích đoạn cần thiết (path, snippet, dữ liệu chốt). Không dump cả history cha vào — sub-agent có history riêng, dump = lãng phí token.
|
|
94
|
+
3. KHÔNG GIỚI HẠN TOKEN — sub-agent chạy tới khi xong task hoặc gặp lỗi/abort. KHÔNG set field token_budget (đã gỡ khỏi runtime).
|
|
95
|
+
4. DEPTH GUARD: depth hiện tại = ${depth}. Depth còn lại: ${remaining}. Khi depth >= ${MAX_SUBAGENT_DEPTH}, sub-agent KHÔNG thấy spawn_agent nữa (chống nổ đệ quy).
|
|
96
|
+
5. ROUTING MODEL (field "model"?: str — TỐI ƯU CHI PHÍ):
|
|
97
|
+
- Task đơn giản (đọc file, tóm tắt, grep, format): dùng model rẻ — vd "deepseek-v4-flash", "gpt-5-mini", "kimi".
|
|
98
|
+
- Task khó (refactor đa file, debug bug phức tạp, review kỹ thuật): dùng model mạnh — vd "claude-opus-4-7", "gpt-5", "deepseek-v4".
|
|
99
|
+
- Bỏ field "model" hoặc để rỗng → sub-agent kế thừa model của cha (mặc định an toàn).
|
|
100
|
+
- Tên model: gõ ngắn gọn ("claude-opus-4-7", "kimi", "o3-mini") — runtime tự fuzzy match (bỏ prefix gateway-, chuẩn hoá dấu/space).
|
|
101
|
+
6. KHI NÀO DÙNG spawn_agents (song song) vs spawn_agent (đơn):
|
|
102
|
+
- Song song khi N task độc lập, không phụ thuộc nhau (vd review 5 file riêng biệt, fan-out + synthesize, generate-and-filter).
|
|
103
|
+
- Đơn lẻ khi chỉ cô lập 1 task nặng (giữ history cha sạch) hoặc cần kết quả của task này trước khi quyết spawn task sau.
|
|
104
|
+
7. SAU KHI NHẬN KẾT QUẢ: tổng hợp/synthesize trong agent cha — đừng paste nguyên block kết quả ra cho user. Sub-agent là worker, cha là orchestrator.
|
|
105
|
+
|
|
106
|
+
## Ví dụ
|
|
107
|
+
spawn_agent đơn:
|
|
108
|
+
\`\`\`tool
|
|
109
|
+
{"name": "spawn_agent", "input": {"task": "Đọc src/api.js, liệt kê tất cả hàm export kèm signature ngắn (tên + tham số). Trả về list markdown.", "context": "File ESM, dùng fetch streaming.", "model": "deepseek-v4-flash"}}
|
|
110
|
+
\`\`\`
|
|
111
|
+
|
|
112
|
+
spawn_agents song song (fan-out review 3 file):
|
|
113
|
+
\`\`\`tool
|
|
114
|
+
{"name": "spawn_agents", "input": {"agents": [
|
|
115
|
+
{"task": "Review src/agent.js — tìm bug logic, race condition. Trả về list bullet.", "model": "claude-opus-4-7"},
|
|
116
|
+
{"task": "Review src/api.js — kiểm error handling streaming. Trả về list bullet.", "model": "claude-opus-4-7"},
|
|
117
|
+
{"task": "Review src/tools.js — kiểm input validation. Trả về list bullet.", "model": "claude-opus-4-7"}
|
|
118
|
+
]}}
|
|
119
|
+
\`\`\`
|
|
120
|
+
|
|
121
|
+
## Anti-pattern
|
|
122
|
+
- KHÔNG spawn sub-agent cho task 1 bước (vd "đọc 1 file rồi trả về") — tự đọc bằng read_file rẻ hơn nhiều.
|
|
123
|
+
- KHÔNG spawn nested sâu vô tội vạ — mỗi cấp depth = 1 lần gọi model + 1 history riêng = đắt.
|
|
124
|
+
- KHÔNG để sub-agent tự spawn tiếp khi task của nó đã đơn giản — depth guard chỉ là cứu cánh, không phải license đệ quy.
|
|
125
|
+
`;
|
|
67
126
|
}
|
package/src/tokens.js
CHANGED
|
@@ -100,4 +100,20 @@ export class TokenMeter {
|
|
|
100
100
|
this._tail = "";
|
|
101
101
|
this._tailTokens = 0;
|
|
102
102
|
}
|
|
103
|
+
// Serialize counter để persist qua --continue/--resume. Tail buffer là transient
|
|
104
|
+
// (chỉ phục vụ tính output realtime trong 1 lượt), KHÔNG cần lưu — endOutput()
|
|
105
|
+
// đã commit hết vào this.output trước khi persist() chạy ở cuối mỗi lượt.
|
|
106
|
+
serialize() {
|
|
107
|
+
return { input: this.input, output: this.output };
|
|
108
|
+
}
|
|
109
|
+
// Khôi phục từ snapshot — chỉ set 2 counter, tail giữ rỗng (lượt mới bắt đầu).
|
|
110
|
+
restore(data) {
|
|
111
|
+
if (!data || typeof data !== "object") return;
|
|
112
|
+
this.input = Math.max(0, data.input | 0);
|
|
113
|
+
this.output = Math.max(0, data.output | 0);
|
|
114
|
+
this._committedChars = 0;
|
|
115
|
+
this._committedTokens = 0;
|
|
116
|
+
this._tail = "";
|
|
117
|
+
this._tailTokens = 0;
|
|
118
|
+
}
|
|
103
119
|
}
|
package/src/tools.js
CHANGED
|
@@ -98,8 +98,15 @@ process.on("SIGTERM", () => {
|
|
|
98
98
|
process.exit(143);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
// Helper: throw nếu signal đã abort. Dùng ở đầu mỗi tool + giữa các vòng walk dài
|
|
102
|
+
// để tool fs (glob/grep) cũng phản ứng với Ctrl+C, không chỉ run_command.
|
|
103
|
+
function checkAbort(signal) {
|
|
104
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
105
|
+
}
|
|
106
|
+
|
|
101
107
|
export const TOOLS = {
|
|
102
|
-
async read_file({ path: p, offset, limit }) {
|
|
108
|
+
async read_file({ path: p, offset, limit }, { signal } = {}) {
|
|
109
|
+
checkAbort(signal);
|
|
103
110
|
const data = await fs.readFile(abs(p), "utf8");
|
|
104
111
|
let lines = data.split("\n");
|
|
105
112
|
const start = offset ? Math.max(0, offset - 1) : 0;
|
|
@@ -110,14 +117,16 @@ export const TOOLS = {
|
|
|
110
117
|
);
|
|
111
118
|
},
|
|
112
119
|
|
|
113
|
-
async write_file({ path: p, content }) {
|
|
120
|
+
async write_file({ path: p, content }, { signal } = {}) {
|
|
121
|
+
checkAbort(signal);
|
|
114
122
|
await fs.mkdir(path.dirname(abs(p)), { recursive: true });
|
|
115
123
|
await fs.writeFile(abs(p), content ?? "", "utf8");
|
|
116
124
|
const n = (content ?? "").split("\n").length;
|
|
117
125
|
return `Wrote ${n} line(s) to ${rel(abs(p))}`;
|
|
118
126
|
},
|
|
119
127
|
|
|
120
|
-
async edit_file({ path: p, old_string, new_string, replace_all }) {
|
|
128
|
+
async edit_file({ path: p, old_string, new_string, replace_all }, { signal } = {}) {
|
|
129
|
+
checkAbort(signal);
|
|
121
130
|
const file = abs(p);
|
|
122
131
|
const data = await fs.readFile(file, "utf8");
|
|
123
132
|
if (old_string === new_string) throw new Error("old_string and new_string are identical");
|
|
@@ -163,7 +172,8 @@ export const TOOLS = {
|
|
|
163
172
|
);
|
|
164
173
|
},
|
|
165
174
|
|
|
166
|
-
async list_dir({ path: p = "." }) {
|
|
175
|
+
async list_dir({ path: p = "." }, { signal } = {}) {
|
|
176
|
+
checkAbort(signal);
|
|
167
177
|
const dir = abs(p);
|
|
168
178
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
169
179
|
const rows = entries
|
|
@@ -173,10 +183,13 @@ export const TOOLS = {
|
|
|
173
183
|
return clip(`${rel(dir)}/ (${rows.length} entries)\n` + rows.map((r) => " " + r).join("\n"));
|
|
174
184
|
},
|
|
175
185
|
|
|
176
|
-
async glob({ pattern }) {
|
|
186
|
+
async glob({ pattern }, { signal } = {}) {
|
|
187
|
+
checkAbort(signal);
|
|
177
188
|
const hits = [];
|
|
178
189
|
const rx = globToRegExp(pattern);
|
|
179
190
|
const roots = listRoots();
|
|
191
|
+
// Check signal mỗi ~256 entries thay vì mỗi entry — đỡ overhead trên repo lớn.
|
|
192
|
+
let tickCounter = 0;
|
|
180
193
|
for (const root of roots) {
|
|
181
194
|
(function walk(dir) {
|
|
182
195
|
let ents;
|
|
@@ -186,6 +199,7 @@ export const TOOLS = {
|
|
|
186
199
|
return;
|
|
187
200
|
}
|
|
188
201
|
for (const e of ents) {
|
|
202
|
+
if ((++tickCounter & 0xff) === 0) checkAbort(signal);
|
|
189
203
|
if (SKIP_DIRS.has(e.name) || e.name.startsWith(".git")) continue;
|
|
190
204
|
const full = path.join(dir, e.name);
|
|
191
205
|
if (e.isDirectory()) walk(full);
|
|
@@ -198,11 +212,14 @@ export const TOOLS = {
|
|
|
198
212
|
return hits.length ? clip(hits.join("\n")) : "No files matched.";
|
|
199
213
|
},
|
|
200
214
|
|
|
201
|
-
async grep({ pattern, path: p, glob: g }) {
|
|
215
|
+
async grep({ pattern, path: p, glob: g }, { signal } = {}) {
|
|
216
|
+
checkAbort(signal);
|
|
202
217
|
const rx = new RegExp(pattern, "i");
|
|
203
218
|
const gRx = g ? globToRegExp(g) : null;
|
|
204
219
|
const out = [];
|
|
220
|
+
let tickCounter = 0;
|
|
205
221
|
function scanFile(full) {
|
|
222
|
+
if ((++tickCounter & 0xff) === 0) checkAbort(signal);
|
|
206
223
|
const disp = displayPath(full);
|
|
207
224
|
const relp = disp.split(path.sep).join("/");
|
|
208
225
|
if (gRx && !gRx.test(relp)) return;
|
|
@@ -250,7 +267,7 @@ export const TOOLS = {
|
|
|
250
267
|
return out.length ? clip(out.join("\n")) : "No matches.";
|
|
251
268
|
},
|
|
252
269
|
|
|
253
|
-
run_command({ command, timeout = 60000, background = false }) {
|
|
270
|
+
run_command({ command, timeout = 60000, background = false }, { signal } = {}) {
|
|
254
271
|
const isWin = process.platform === "win32";
|
|
255
272
|
const shell = isWin ? "powershell.exe" : "/bin/bash";
|
|
256
273
|
const args = isWin ? ["-NoProfile", "-NonInteractive", "-Command", command] : ["-c", command];
|
|
@@ -289,19 +306,34 @@ export const TOOLS = {
|
|
|
289
306
|
const child = spawn(shell, args, { cwd: cwd(), stdio: ["ignore", "pipe", "pipe"] });
|
|
290
307
|
let out = "";
|
|
291
308
|
let timedOut = false;
|
|
309
|
+
let aborted = false;
|
|
292
310
|
const killer = setTimeout(() => {
|
|
293
311
|
timedOut = true;
|
|
294
|
-
child
|
|
312
|
+
killBgTree(child);
|
|
295
313
|
}, timeout);
|
|
314
|
+
// Ctrl+C trong lúc command đang chạy → kill cây tiến trình con (Windows
|
|
315
|
+
// dùng taskkill /T để diệt cả grand-children, vd npm spawn node).
|
|
316
|
+
const onAbort = () => {
|
|
317
|
+
aborted = true;
|
|
318
|
+
killBgTree(child);
|
|
319
|
+
};
|
|
320
|
+
if (signal) {
|
|
321
|
+
if (signal.aborted) onAbort();
|
|
322
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
323
|
+
}
|
|
296
324
|
child.stdout.on("data", (d) => (out += d));
|
|
297
325
|
child.stderr.on("data", (d) => (out += d));
|
|
298
326
|
child.on("error", (e) => {
|
|
299
327
|
clearTimeout(killer);
|
|
328
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
300
329
|
resolve(`Failed to start command: ${e.message}`);
|
|
301
330
|
});
|
|
302
331
|
child.on("close", (code) => {
|
|
303
332
|
clearTimeout(killer);
|
|
304
|
-
|
|
333
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
334
|
+
const tail = aborted
|
|
335
|
+
? `\n[aborted by user (Ctrl+C) — killed.]`
|
|
336
|
+
: timedOut
|
|
305
337
|
? `\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.]`
|
|
306
338
|
: `\n[exit code ${code}]`;
|
|
307
339
|
resolve(clip((out.trim() || "(no output)") + tail));
|
|
@@ -445,8 +477,11 @@ export function describe(name, input) {
|
|
|
445
477
|
}
|
|
446
478
|
}
|
|
447
479
|
|
|
448
|
-
export async function runTool(name, input) {
|
|
480
|
+
export async function runTool(name, input, opts = {}) {
|
|
449
481
|
const fn = TOOLS[name];
|
|
450
482
|
if (!fn) throw new Error(`Unknown tool: ${name}`);
|
|
451
|
-
|
|
483
|
+
const { signal } = opts;
|
|
484
|
+
// Pre-check: nếu user đã Ctrl+C trước khi tool kịp chạy, bail ngay.
|
|
485
|
+
if (signal?.aborted) throw new Error("aborted");
|
|
486
|
+
return await fn(input || {}, { signal });
|
|
452
487
|
}
|
package/src/workflows.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
// CRUD workflow đã lưu. Cảm hứng từ tweet_dump.txt L183–193 ("saving and sharing
|
|
6
|
+
// dynamic workflows"): user nhấn 's' để snapshot prompt template ra file rồi tái
|
|
7
|
+
// dùng. Map sang noob: lưu Markdown ở ~/.noob/workflows/<name>.md, format có
|
|
8
|
+
// front-matter YAML-lite + body là prompt template.
|
|
9
|
+
|
|
10
|
+
const DIR = path.join(os.homedir(), ".noob", "workflows");
|
|
11
|
+
|
|
12
|
+
function ensureDir() {
|
|
13
|
+
try { fs.mkdirSync(DIR, { recursive: true }); } catch {}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Tên file an toàn — chỉ cho phép [a-z0-9-_], chống path traversal.
|
|
17
|
+
function sanitizeName(name) {
|
|
18
|
+
if (!name || typeof name !== "string") return null;
|
|
19
|
+
const trimmed = name.trim().toLowerCase();
|
|
20
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(trimmed)) return null;
|
|
21
|
+
return trimmed;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function filePath(name) {
|
|
25
|
+
const safe = sanitizeName(name);
|
|
26
|
+
if (!safe) return null;
|
|
27
|
+
return path.join(DIR, safe + ".md");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Parse front-matter cực tối giản: --- ... --- ở đầu file, key: value mỗi dòng.
|
|
31
|
+
function parseFile(raw) {
|
|
32
|
+
const meta = {};
|
|
33
|
+
let body = raw;
|
|
34
|
+
const m = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
|
35
|
+
if (m) {
|
|
36
|
+
for (const line of m[1].split(/\r?\n/)) {
|
|
37
|
+
const kv = line.match(/^([a-zA-Z][a-zA-Z0-9_-]*):\s*(.*)$/);
|
|
38
|
+
if (kv) meta[kv[1]] = kv[2].trim();
|
|
39
|
+
}
|
|
40
|
+
body = m[2];
|
|
41
|
+
}
|
|
42
|
+
return { meta, body };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function serialize(meta, body) {
|
|
46
|
+
const lines = ["---"];
|
|
47
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
48
|
+
lines.push(`${k}: ${String(v).replace(/\r?\n/g, " ")}`);
|
|
49
|
+
}
|
|
50
|
+
lines.push("---");
|
|
51
|
+
lines.push("");
|
|
52
|
+
lines.push(body);
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Lưu workflow. Trả về { ok, path?, error? }.
|
|
57
|
+
export function saveWorkflow(name, prompt, opts = {}) {
|
|
58
|
+
const safe = sanitizeName(name);
|
|
59
|
+
if (!safe) return { ok: false, error: "invalid_name" };
|
|
60
|
+
if (!prompt || typeof prompt !== "string" || !prompt.trim()) {
|
|
61
|
+
return { ok: false, error: "empty_prompt" };
|
|
62
|
+
}
|
|
63
|
+
ensureDir();
|
|
64
|
+
const fp = path.join(DIR, safe + ".md");
|
|
65
|
+
const meta = {
|
|
66
|
+
name: safe,
|
|
67
|
+
created: opts.created || new Date().toISOString(),
|
|
68
|
+
updated: new Date().toISOString(),
|
|
69
|
+
};
|
|
70
|
+
if (opts.description) meta.description = opts.description;
|
|
71
|
+
try {
|
|
72
|
+
fs.writeFileSync(fp, serialize(meta, prompt.trim()), "utf8");
|
|
73
|
+
return { ok: true, path: fp };
|
|
74
|
+
} catch (e) {
|
|
75
|
+
return { ok: false, error: e.message };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Đọc workflow. Trả về { ok, name, prompt, meta, path } hoặc { ok: false }.
|
|
80
|
+
export function loadWorkflow(name) {
|
|
81
|
+
const fp = filePath(name);
|
|
82
|
+
if (!fp) return { ok: false, error: "invalid_name" };
|
|
83
|
+
if (!fs.existsSync(fp)) return { ok: false, error: "not_found" };
|
|
84
|
+
try {
|
|
85
|
+
const raw = fs.readFileSync(fp, "utf8");
|
|
86
|
+
const { meta, body } = parseFile(raw);
|
|
87
|
+
return { ok: true, name: sanitizeName(name), prompt: body.trim(), meta, path: fp };
|
|
88
|
+
} catch (e) {
|
|
89
|
+
return { ok: false, error: e.message };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Liệt kê tất cả workflow đã lưu. Trả về mảng { name, description?, updated? }.
|
|
94
|
+
export function listWorkflows() {
|
|
95
|
+
ensureDir();
|
|
96
|
+
let entries;
|
|
97
|
+
try {
|
|
98
|
+
entries = fs.readdirSync(DIR);
|
|
99
|
+
} catch {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const out = [];
|
|
103
|
+
for (const f of entries) {
|
|
104
|
+
if (!f.endsWith(".md")) continue;
|
|
105
|
+
const name = f.slice(0, -3);
|
|
106
|
+
if (!sanitizeName(name)) continue;
|
|
107
|
+
try {
|
|
108
|
+
const raw = fs.readFileSync(path.join(DIR, f), "utf8");
|
|
109
|
+
const { meta } = parseFile(raw);
|
|
110
|
+
out.push({
|
|
111
|
+
name,
|
|
112
|
+
description: meta.description || "",
|
|
113
|
+
updated: meta.updated || "",
|
|
114
|
+
});
|
|
115
|
+
} catch {
|
|
116
|
+
out.push({ name, description: "", updated: "" });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
// Sort theo updated desc (mới nhất lên đầu), fallback alphabet.
|
|
120
|
+
out.sort((a, b) => {
|
|
121
|
+
if (a.updated && b.updated) return b.updated.localeCompare(a.updated);
|
|
122
|
+
return a.name.localeCompare(b.name);
|
|
123
|
+
});
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Xoá workflow. Trả về { ok, error? }.
|
|
128
|
+
export function deleteWorkflow(name) {
|
|
129
|
+
const fp = filePath(name);
|
|
130
|
+
if (!fp) return { ok: false, error: "invalid_name" };
|
|
131
|
+
if (!fs.existsSync(fp)) return { ok: false, error: "not_found" };
|
|
132
|
+
try {
|
|
133
|
+
fs.unlinkSync(fp);
|
|
134
|
+
return { ok: true };
|
|
135
|
+
} catch (e) {
|
|
136
|
+
return { ok: false, error: e.message };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function workflowsDir() {
|
|
141
|
+
return DIR;
|
|
142
|
+
}
|