@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/src/subagent.js CHANGED
@@ -1,67 +1,126 @@
1
- // Sub-agent: chạy một runAgent() con với history độc lập, dùng chung tool
2
- // runtime của cha. Hỗ trợ phân cấp (sub-agent thể đẻ sub-agent tiếp) nhưng
3
- // giới hạn độ sâu để không nổ. Hỗ trợ song song qua spawn_agents (mảng).
4
- import { runAgent } from "./agent.js";
5
- import { TokenMeter } from "./tokens.js";
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 source of truth cho:
3
+ // - permission/approve UI
4
+ // - in log tool-call
5
+ // - xử 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
- // Tài liệu tool spawn_agent — chèn vào prompt khi agent mode bật. Mô tả cho
10
- // model biết WHEN dùng (task lớn, chia được) và HOW (song song vs tuần tự).
11
- export function spawnAgentToolsDoc(depth = 0) {
12
- const canSpawn = depth < MAX_SUBAGENT_DEPTH;
13
- return `# AGENT MODE — multi-agent
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
- Bạn đang chế độ AGENT. Khi gặp task LỚNthể chia nhỏ, hãy ủy thác cho sub-agent thay tự làm hết một mình:
41
+ // Prompt khởi tạo: nêu task + context (nếu). Sub-agent 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
- - spawn_agent {"task": str, "context"?: str} — đẻ MỘT sub-agent làm "task". "context" là phần ngữ cảnh cần truyền (file paths, quyết định đã chốt, ràng buộc). Sub-agent có TOÀN BỘ tool (read/write/edit/run/grep/glob…) và history RIÊNG (không thấy hội thoại của bạn). Nó trả về một chuỗi tóm tắt kết quả.
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
- Quy tắc dùng:
21
- 1. TUẦN TỰ (task B cần kết quả task A): gọi spawn_agent cho A, đọc kết quả, rồi spawn_agent cho B. KHÔNG dùng spawn_agents.
22
- 2. SONG SONG (các task không liên quan): dùng MỘT lần spawn_agents với mảng tasks. Tiết kiệm thời gian.
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
- dụ song song: "viết test cho 3 module độc lập" spawn_agents với 3 tasks.
28
- dụ tuần tự: "thiết kế schema rồi viết migration" → spawn_agent(thiết kế) đọc → spawn_agent(viết migration với schema đó).
29
- dụ phân cấp: cha giao "build full app" → đẻ 1 sub-agent "build backend" → sub-agent đó tự đẻ tiếp "viết auth", "viết CRUD" song song.`;
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: meter,
57
- extraToolsDoc: spawnAgentToolsDoc(depth),
58
- onTool: (name, input) => dispatchTool(name, input, depth),
59
- onStatus: () => {},
60
- onDelta: () => {},
61
- onSteer: () => [],
63
+ tokenMeter,
64
+ onTool,
65
+ extraToolsDoc,
66
+ goal: task,
62
67
  });
63
- const used = { input: meter.input - before.input, output: meter.output - before.output };
64
- onLog?.(`↳ sub-agent (depth=${depth}) xong (↑${used.input} ↓${used.output})`);
65
- // Trả về string sạch để cha (model) đọc dễ. Token đã cộng vào meter rồi.
66
- return result;
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.kill();
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
- const tail = timedOut
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
- return await fn(input || {});
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
  }
@@ -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
+ }